diff --git a/CHANGELOG.md b/CHANGELOG.md index 721aac002..ab85d3d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Setup wizard: web-based first-run configuration at `http://localhost:8080` when `config.json` is missing — guides users through database connection, schema initialization, and server settings - CI: Coverage threshold enforcement — fails build if total coverage drops below 50% - CI: Release workflow that automatically builds and uploads Linux/Windows binaries to GitHub Releases on tag push - Monthly guild item claim tracking per character per type (standard/HLC/EXC), with schema migration (`31-monthly-items.sql`) adding claim timestamps to the `stamps` table diff --git a/main.go b/main.go index 7a16dc515..6aa495607 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( "erupe-ce/server/channelserver" "erupe-ce/server/discordbot" "erupe-ce/server/entranceserver" + "erupe-ce/server/setup" "erupe-ce/server/signserver" "strings" @@ -79,11 +80,18 @@ func main() { config, cfgErr := cfg.LoadConfig() if cfgErr != nil { - fmt.Println("\nFailed to start Erupe:\n" + fmt.Sprintf("Failed to load config: %s", cfgErr.Error())) - go wait() - fmt.Println("\nPress Enter/Return to exit...") - _, _ = fmt.Scanln() - os.Exit(0) + if _, err := os.Stat("config.json"); os.IsNotExist(err) { + logger.Info("No config.json found, launching setup wizard") + if err := setup.Run(logger.Named("setup"), 8080); err != nil { + logger.Fatal("Setup wizard failed", zap.Error(err)) + } + config, cfgErr = cfg.LoadConfig() + if cfgErr != nil { + logger.Fatal("Config still invalid after setup", zap.Error(cfgErr)) + } + } else { + preventClose(config, fmt.Sprintf("Failed to load config: %s", cfgErr.Error())) + } } logger.Info(fmt.Sprintf("Starting Erupe (9.3b-%s)", Commit())) diff --git a/server/setup/handlers.go b/server/setup/handlers.go new file mode 100644 index 000000000..a2e42d3f3 --- /dev/null +++ b/server/setup/handlers.go @@ -0,0 +1,197 @@ +package setup + +import ( + "database/sql" + "embed" + "encoding/json" + "fmt" + "net/http" + "path/filepath" + + _ "github.com/lib/pq" + "go.uber.org/zap" +) + +//go:embed wizard.html +var wizardHTML embed.FS + +// wizardServer holds state for the setup wizard HTTP handlers. +type wizardServer struct { + logger *zap.Logger + done chan struct{} // closed when setup is complete +} + +func (ws *wizardServer) handleIndex(w http.ResponseWriter, _ *http.Request) { + data, err := wizardHTML.ReadFile("wizard.html") + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(data) +} + +func (ws *wizardServer) handleDetectIP(w http.ResponseWriter, _ *http.Request) { + ip, err := detectOutboundIP() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"ip": ip}) +} + +func (ws *wizardServer) handleClientModes(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]interface{}{"modes": clientModes()}) +} + +// testDBRequest is the JSON body for POST /api/setup/test-db. +type testDBRequest struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + DBName string `json:"dbName"` +} + +func (ws *wizardServer) handleTestDB(w http.ResponseWriter, r *http.Request) { + var req testDBRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + status, err := testDBConnection(req.Host, req.Port, req.User, req.Password, req.DBName) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "error": err.Error(), + "status": status, + }) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"status": status}) +} + +// initDBRequest is the JSON body for POST /api/setup/init-db. +type initDBRequest struct { + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + DBName string `json:"dbName"` + CreateDB bool `json:"createDB"` + ApplyInit bool `json:"applyInit"` + ApplyUpdate bool `json:"applyUpdate"` + ApplyPatch bool `json:"applyPatch"` + ApplyBundled bool `json:"applyBundled"` +} + +func (ws *wizardServer) handleInitDB(w http.ResponseWriter, r *http.Request) { + var req initDBRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + var log []string + addLog := func(msg string) { + log = append(log, msg) + ws.logger.Info(msg) + } + + if req.CreateDB { + addLog(fmt.Sprintf("Creating database '%s'...", req.DBName)) + if err := createDatabase(req.Host, req.Port, req.User, req.Password, req.DBName); err != nil { + addLog(fmt.Sprintf("ERROR: %s", err)) + writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "log": log}) + return + } + addLog("Database created successfully") + } + + if req.ApplyInit { + addLog("Applying init schema (pg_restore)...") + if err := applyInitSchema(req.Host, req.Port, req.User, req.Password, req.DBName); err != nil { + addLog(fmt.Sprintf("ERROR: %s", err)) + writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "log": log}) + return + } + addLog("Init schema applied successfully") + } + + // For update/patch/bundled schemas, connect to the target DB. + if req.ApplyUpdate || req.ApplyPatch || req.ApplyBundled { + connStr := fmt.Sprintf( + "host='%s' port='%d' user='%s' password='%s' dbname='%s' sslmode=disable", + req.Host, req.Port, req.User, req.Password, req.DBName, + ) + db, err := sql.Open("postgres", connStr) + if err != nil { + addLog(fmt.Sprintf("ERROR connecting to database: %s", err)) + writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "log": log}) + return + } + defer func() { _ = db.Close() }() + + applyDir := func(dir, label string) bool { + addLog(fmt.Sprintf("Applying %s schemas from %s...", label, dir)) + applied, err := applySQLFiles(db, filepath.Join("schemas", dir)) + for _, f := range applied { + addLog(fmt.Sprintf(" Applied: %s", f)) + } + if err != nil { + addLog(fmt.Sprintf("ERROR: %s", err)) + return false + } + addLog(fmt.Sprintf("%s schemas applied (%d files)", label, len(applied))) + return true + } + + if req.ApplyUpdate { + if !applyDir("update-schema", "update") { + writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "log": log}) + return + } + } + if req.ApplyPatch { + if !applyDir("patch-schema", "patch") { + writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "log": log}) + return + } + } + if req.ApplyBundled { + if !applyDir("bundled-schema", "bundled") { + writeJSON(w, http.StatusOK, map[string]interface{}{"success": false, "log": log}) + return + } + } + } + + addLog("Database initialization complete!") + writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "log": log}) +} + +func (ws *wizardServer) handleFinish(w http.ResponseWriter, r *http.Request) { + var req FinishRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + config := buildDefaultConfig(req) + if err := writeConfig(config); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + ws.logger.Info("config.json written successfully") + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + + // Signal completion — this will cause the HTTP server to shut down. + close(ws.done) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/server/setup/setup.go b/server/setup/setup.go new file mode 100644 index 000000000..2514d6b31 --- /dev/null +++ b/server/setup/setup.go @@ -0,0 +1,55 @@ +package setup + +import ( + "context" + "fmt" + "net/http" + + "github.com/gorilla/mux" + "go.uber.org/zap" +) + +// Run starts a temporary HTTP server serving the setup wizard. +// It blocks until the user completes setup and config.json is written. +func Run(logger *zap.Logger, port int) error { + ws := &wizardServer{ + logger: logger, + done: make(chan struct{}), + } + + r := mux.NewRouter() + r.HandleFunc("/", ws.handleIndex).Methods("GET") + r.HandleFunc("/api/setup/detect-ip", ws.handleDetectIP).Methods("GET") + r.HandleFunc("/api/setup/client-modes", ws.handleClientModes).Methods("GET") + r.HandleFunc("/api/setup/test-db", ws.handleTestDB).Methods("POST") + r.HandleFunc("/api/setup/init-db", ws.handleInitDB).Methods("POST") + r.HandleFunc("/api/setup/finish", ws.handleFinish).Methods("POST") + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: r, + } + + logger.Info(fmt.Sprintf("Setup wizard available at http://localhost:%d", port)) + fmt.Printf("\n >>> Open http://localhost:%d in your browser to configure Erupe <<<\n\n", port) + + // Start the HTTP server in a goroutine. + errCh := make(chan error, 1) + go func() { + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + errCh <- err + } + }() + + // Wait for either completion or server error. + select { + case <-ws.done: + logger.Info("Setup complete, shutting down wizard") + if err := srv.Shutdown(context.Background()); err != nil { + logger.Warn("Error shutting down wizard server", zap.Error(err)) + } + return nil + case err := <-errCh: + return fmt.Errorf("setup wizard server error: %w", err) + } +} diff --git a/server/setup/wizard.go b/server/setup/wizard.go new file mode 100644 index 000000000..689b92256 --- /dev/null +++ b/server/setup/wizard.go @@ -0,0 +1,443 @@ +package setup + +import ( + "database/sql" + "encoding/json" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// clientModes returns all supported client version strings. +func clientModes() []string { + return []string{ + "S1.0", "S1.5", "S2.0", "S2.5", "S3.0", "S3.5", "S4.0", "S5.0", "S5.5", "S6.0", "S7.0", + "S8.0", "S8.5", "S9.0", "S10", "FW.1", "FW.2", "FW.3", "FW.4", "FW.5", "G1", "G2", "G3", + "G3.1", "G3.2", "GG", "G5", "G5.1", "G5.2", "G6", "G6.1", "G7", "G8", "G8.1", "G9", "G9.1", + "G10", "G10.1", "Z1", "Z2", "ZZ", + } +} + +// FinishRequest holds the user's configuration choices from the wizard. +type FinishRequest struct { + DBHost string `json:"dbHost"` + DBPort int `json:"dbPort"` + DBUser string `json:"dbUser"` + DBPassword string `json:"dbPassword"` + DBName string `json:"dbName"` + Host string `json:"host"` + ClientMode string `json:"clientMode"` + AutoCreateAccount bool `json:"autoCreateAccount"` +} + +// buildDefaultConfig produces a config map matching config.example.json structure +// with the user's values merged in. +func buildDefaultConfig(req FinishRequest) map[string]interface{} { + config := map[string]interface{}{ + "Host": req.Host, + "BinPath": "bin", + "Language": "en", + "DisableSoftCrash": false, + "HideLoginNotice": true, + "LoginNotices": []string{"
Welcome to Erupe!"}, + "PatchServerManifest": "", + "PatchServerFile": "", + "DeleteOnSaveCorruption": false, + "ClientMode": req.ClientMode, + "QuestCacheExpiry": 300, + "CommandPrefix": "!", + "AutoCreateAccount": req.AutoCreateAccount, + "LoopDelay": 50, + "DefaultCourses": []int{1, 23, 24}, + "EarthStatus": 0, + "EarthID": 0, + "EarthMonsters": []int{0, 0, 0, 0}, + "Screenshots": map[string]interface{}{ + "Enabled": true, + "Host": "127.0.0.1", + "Port": 8080, + "OutputDir": "screenshots", + "UploadQuality": 100, + }, + "SaveDumps": map[string]interface{}{ + "Enabled": true, + "RawEnabled": false, + "OutputDir": "save-backups", + }, + "Capture": map[string]interface{}{ + "Enabled": false, + "OutputDir": "captures", + "ExcludeOpcodes": []int{}, + "CaptureSign": true, + "CaptureEntrance": true, + "CaptureChannel": true, + }, + "DebugOptions": map[string]interface{}{ + "CleanDB": false, + "MaxLauncherHR": false, + "LogInboundMessages": false, + "LogOutboundMessages": false, + "LogMessageData": false, + "MaxHexdumpLength": 256, + "DivaOverride": 0, + "FestaOverride": -1, + "TournamentOverride": 0, + "DisableTokenCheck": false, + "QuestTools": false, + "AutoQuestBackport": true, + "ProxyPort": 0, + "CapLink": map[string]interface{}{ + "Values": []int{51728, 20000, 51729, 1, 20000}, + "Key": "", + "Host": "", + "Port": 80, + }, + }, + "GameplayOptions": map[string]interface{}{ + "MinFeatureWeapons": 0, + "MaxFeatureWeapons": 1, + "MaximumNP": 100000, + "MaximumRP": 50000, + "MaximumFP": 120000, + "TreasureHuntExpiry": 604800, + "DisableLoginBoost": false, + "DisableBoostTime": false, + "BoostTimeDuration": 7200, + "ClanMealDuration": 3600, + "ClanMemberLimits": [][]int{{0, 30}, {3, 40}, {7, 50}, {10, 60}}, + "BonusQuestAllowance": 3, + "DailyQuestAllowance": 1, + "LowLatencyRaviente": false, + "RegularRavienteMaxPlayers": 8, + "ViolentRavienteMaxPlayers": 8, + "BerserkRavienteMaxPlayers": 32, + "ExtremeRavienteMaxPlayers": 32, + "SmallBerserkRavienteMaxPlayers": 8, + "GUrgentRate": 0.10, + "GCPMultiplier": 1.00, + "HRPMultiplier": 1.00, + "HRPMultiplierNC": 1.00, + "SRPMultiplier": 1.00, + "SRPMultiplierNC": 1.00, + "GRPMultiplier": 1.00, + "GRPMultiplierNC": 1.00, + "GSRPMultiplier": 1.00, + "GSRPMultiplierNC": 1.00, + "ZennyMultiplier": 1.00, + "ZennyMultiplierNC": 1.00, + "GZennyMultiplier": 1.00, + "GZennyMultiplierNC": 1.00, + "MaterialMultiplier": 1.00, + "MaterialMultiplierNC": 1.00, + "GMaterialMultiplier": 1.00, + "GMaterialMultiplierNC": 1.00, + "ExtraCarves": 0, + "ExtraCarvesNC": 0, + "GExtraCarves": 0, + "GExtraCarvesNC": 0, + "DisableHunterNavi": false, + "MezFesSoloTickets": 5, + "MezFesGroupTickets": 1, + "MezFesDuration": 172800, + "MezFesSwitchMinigame": false, + "EnableKaijiEvent": false, + "EnableHiganjimaEvent": false, + "EnableNierEvent": false, + "DisableRoad": false, + "SeasonOverride": false, + }, + "Discord": map[string]interface{}{ + "Enabled": false, + "BotToken": "", + "RelayChannel": map[string]interface{}{ + "Enabled": false, + "MaxMessageLength": 183, + "RelayChannelID": "", + }, + }, + "Commands": []map[string]interface{}{ + {"Name": "Help", "Enabled": true, "Description": "Show enabled chat commands", "Prefix": "help"}, + {"Name": "Rights", "Enabled": false, "Description": "Overwrite the Rights value on your account", "Prefix": "rights"}, + {"Name": "Raviente", "Enabled": true, "Description": "Various Raviente siege commands", "Prefix": "ravi"}, + {"Name": "Teleport", "Enabled": false, "Description": "Teleport to specified coordinates", "Prefix": "tele"}, + {"Name": "Reload", "Enabled": true, "Description": "Reload all players in your Land", "Prefix": "reload"}, + {"Name": "KeyQuest", "Enabled": false, "Description": "Overwrite your HR Key Quest progress", "Prefix": "kqf"}, + {"Name": "Course", "Enabled": true, "Description": "Toggle Courses on your account", "Prefix": "course"}, + {"Name": "PSN", "Enabled": true, "Description": "Link a PlayStation Network ID to your account", "Prefix": "psn"}, + {"Name": "Discord", "Enabled": true, "Description": "Generate a token to link your Discord account", "Prefix": "discord"}, + {"Name": "Ban", "Enabled": false, "Description": "Ban/Temp Ban a user", "Prefix": "ban"}, + {"Name": "Timer", "Enabled": true, "Description": "Toggle the Quest timer", "Prefix": "timer"}, + {"Name": "Playtime", "Enabled": true, "Description": "Show your playtime", "Prefix": "playtime"}, + }, + "Courses": []map[string]interface{}{ + {"Name": "HunterLife", "Enabled": true}, + {"Name": "Extra", "Enabled": true}, + {"Name": "Premium", "Enabled": true}, + {"Name": "Assist", "Enabled": false}, + {"Name": "N", "Enabled": false}, + {"Name": "Hiden", "Enabled": false}, + {"Name": "HunterSupport", "Enabled": false}, + {"Name": "NBoost", "Enabled": false}, + {"Name": "NetCafe", "Enabled": true}, + {"Name": "HLRenewing", "Enabled": true}, + {"Name": "EXRenewing", "Enabled": true}, + }, + "Database": map[string]interface{}{ + "Host": req.DBHost, + "Port": req.DBPort, + "User": req.DBUser, + "Password": req.DBPassword, + "Database": req.DBName, + }, + "Sign": map[string]interface{}{ + "Enabled": true, + "Port": 53312, + }, + "API": map[string]interface{}{ + "Enabled": true, + "Port": 8080, + "PatchServer": "", + "Banners": []interface{}{}, + "Messages": []interface{}{}, + "Links": []interface{}{}, + "LandingPage": map[string]interface{}{ + "Enabled": true, + "Title": "My Frontier Server", + "Content": "

Welcome! Server is running.

", + }, + }, + "Channel": map[string]interface{}{ + "Enabled": true, + }, + "Entrance": map[string]interface{}{ + "Enabled": true, + "Port": 53310, + "Entries": []map[string]interface{}{ + { + "Name": "Newbie", "Description": "", "IP": "", "Type": 3, "Recommended": 2, "AllowedClientFlags": 0, + "Channels": []map[string]interface{}{ + {"Port": 54001, "MaxPlayers": 100, "Enabled": true}, + {"Port": 54002, "MaxPlayers": 100, "Enabled": true}, + }, + }, + { + "Name": "Normal", "Description": "", "IP": "", "Type": 1, "Recommended": 0, "AllowedClientFlags": 0, + "Channels": []map[string]interface{}{ + {"Port": 54003, "MaxPlayers": 100, "Enabled": true}, + {"Port": 54004, "MaxPlayers": 100, "Enabled": true}, + }, + }, + { + "Name": "Cities", "Description": "", "IP": "", "Type": 2, "Recommended": 0, "AllowedClientFlags": 0, + "Channels": []map[string]interface{}{ + {"Port": 54005, "MaxPlayers": 100, "Enabled": true}, + }, + }, + { + "Name": "Tavern", "Description": "", "IP": "", "Type": 4, "Recommended": 0, "AllowedClientFlags": 0, + "Channels": []map[string]interface{}{ + {"Port": 54006, "MaxPlayers": 100, "Enabled": true}, + }, + }, + { + "Name": "Return", "Description": "", "IP": "", "Type": 5, "Recommended": 0, "AllowedClientFlags": 0, + "Channels": []map[string]interface{}{ + {"Port": 54007, "MaxPlayers": 100, "Enabled": true}, + }, + }, + { + "Name": "MezFes", "Description": "", "IP": "", "Type": 6, "Recommended": 6, "AllowedClientFlags": 0, + "Channels": []map[string]interface{}{ + {"Port": 54008, "MaxPlayers": 100, "Enabled": true}, + }, + }, + }, + }, + } + + return config +} + +// writeConfig writes the config map to config.json with pretty formatting. +func writeConfig(config map[string]interface{}) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("marshalling config: %w", err) + } + if err := os.WriteFile("config.json", data, 0600); err != nil { + return fmt.Errorf("writing config.json: %w", err) + } + return nil +} + +// detectOutboundIP returns the preferred outbound IPv4 address. +func detectOutboundIP() (string, error) { + conn, err := net.Dial("udp4", "8.8.8.8:80") + if err != nil { + return "", fmt.Errorf("detecting outbound IP: %w", err) + } + defer func() { _ = conn.Close() }() + localAddr := conn.LocalAddr().(*net.UDPAddr) + return localAddr.IP.To4().String(), nil +} + +// testDBConnection tests connectivity to the PostgreSQL server and checks +// whether the target database and its tables exist. +func testDBConnection(host string, port int, user, password, dbName string) (*DBStatus, error) { + status := &DBStatus{} + + // Connect to the 'postgres' maintenance DB to check if target DB exists. + adminConn := fmt.Sprintf( + "host='%s' port='%d' user='%s' password='%s' dbname='postgres' sslmode=disable", + host, port, user, password, + ) + adminDB, err := sql.Open("postgres", adminConn) + if err != nil { + return nil, fmt.Errorf("connecting to PostgreSQL: %w", err) + } + defer func() { _ = adminDB.Close() }() + + if err := adminDB.Ping(); err != nil { + return nil, fmt.Errorf("cannot reach PostgreSQL: %w", err) + } + status.ServerReachable = true + + var exists bool + err = adminDB.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists) + if err != nil { + return status, fmt.Errorf("checking database existence: %w", err) + } + status.DatabaseExists = exists + + if !exists { + return status, nil + } + + // Connect to the target DB to check for tables. + targetConn := fmt.Sprintf( + "host='%s' port='%d' user='%s' password='%s' dbname='%s' sslmode=disable", + host, port, user, password, dbName, + ) + targetDB, err := sql.Open("postgres", targetConn) + if err != nil { + return status, nil + } + defer func() { _ = targetDB.Close() }() + + var tableCount int + err = targetDB.QueryRow("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'").Scan(&tableCount) + if err != nil { + return status, nil + } + status.TablesExist = tableCount > 0 + status.TableCount = tableCount + + return status, nil +} + +// DBStatus holds the result of a database connectivity check. +type DBStatus struct { + ServerReachable bool `json:"serverReachable"` + DatabaseExists bool `json:"databaseExists"` + TablesExist bool `json:"tablesExist"` + TableCount int `json:"tableCount"` +} + +// createDatabase creates the target database by connecting to the 'postgres' maintenance DB. +func createDatabase(host string, port int, user, password, dbName string) error { + adminConn := fmt.Sprintf( + "host='%s' port='%d' user='%s' password='%s' dbname='postgres' sslmode=disable", + host, port, user, password, + ) + db, err := sql.Open("postgres", adminConn) + if err != nil { + return fmt.Errorf("connecting to PostgreSQL: %w", err) + } + defer func() { _ = db.Close() }() + + // Database names can't be parameterized; validate it's alphanumeric + underscores. + for _, c := range dbName { + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '_' { + return fmt.Errorf("invalid database name %q: only alphanumeric characters and underscores allowed", dbName) + } + } + + _, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)) + if err != nil { + return fmt.Errorf("creating database: %w", err) + } + return nil +} + +// applyInitSchema runs pg_restore to load the init.sql (PostgreSQL custom dump format). +func applyInitSchema(host string, port int, user, password, dbName string) error { + pgRestore, err := exec.LookPath("pg_restore") + if err != nil { + return fmt.Errorf("pg_restore not found in PATH: %w (install PostgreSQL client tools)", err) + } + + schemaPath := filepath.Join("schemas", "init.sql") + if _, err := os.Stat(schemaPath); err != nil { + return fmt.Errorf("schema file not found: %s", schemaPath) + } + + cmd := exec.Command(pgRestore, + "--host", host, + "--port", fmt.Sprint(port), + "--username", user, + "--dbname", dbName, + "--no-owner", + "--no-privileges", + schemaPath, + ) + cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", password)) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("pg_restore failed: %w\n%s", err, string(output)) + } + return nil +} + +// collectSQLFiles returns sorted .sql filenames from a directory. +func collectSQLFiles(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading directory %s: %w", dir, err) + } + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + files = append(files, e.Name()) + } + } + sort.Strings(files) + return files, nil +} + +// applySQLFiles executes all .sql files in a directory in sorted order. +func applySQLFiles(db *sql.DB, dir string) ([]string, error) { + files, err := collectSQLFiles(dir) + if err != nil { + return nil, err + } + + var applied []string + for _, f := range files { + path := filepath.Join(dir, f) + data, err := os.ReadFile(path) + if err != nil { + return applied, fmt.Errorf("reading %s: %w", f, err) + } + _, err = db.Exec(string(data)) + if err != nil { + return applied, fmt.Errorf("executing %s: %w", f, err) + } + applied = append(applied, f) + } + return applied, nil +} diff --git a/server/setup/wizard.html b/server/setup/wizard.html new file mode 100644 index 000000000..729235535 --- /dev/null +++ b/server/setup/wizard.html @@ -0,0 +1,420 @@ + + + + + +Erupe Setup Wizard + + + +
+

Erupe Setup Wizard

+

First-run configuration — let's get your server running

+ +
+
+
+
+
+
+
+ 1. Database + 2. Schema + 3. Server + 4. Finish +
+ + +
+

Database Connection

+

Enter your PostgreSQL connection details.

+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
+ + + + + + + + + + +
+ + + + diff --git a/server/setup/wizard_test.go b/server/setup/wizard_test.go new file mode 100644 index 000000000..4587e4da0 --- /dev/null +++ b/server/setup/wizard_test.go @@ -0,0 +1,239 @@ +package setup + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "go.uber.org/zap" +) + +func TestBuildDefaultConfig(t *testing.T) { + req := FinishRequest{ + DBHost: "myhost", + DBPort: 5433, + DBUser: "myuser", + DBPassword: "secret", + DBName: "mydb", + Host: "10.0.0.1", + ClientMode: "ZZ", + AutoCreateAccount: true, + } + cfg := buildDefaultConfig(req) + + // Check top-level keys from user input + if cfg["Host"] != "10.0.0.1" { + t.Errorf("Host = %v, want 10.0.0.1", cfg["Host"]) + } + if cfg["ClientMode"] != "ZZ" { + t.Errorf("ClientMode = %v, want ZZ", cfg["ClientMode"]) + } + if cfg["AutoCreateAccount"] != true { + t.Errorf("AutoCreateAccount = %v, want true", cfg["AutoCreateAccount"]) + } + + // Check database section + db, ok := cfg["Database"].(map[string]interface{}) + if !ok { + t.Fatal("Database section not a map") + } + if db["Host"] != "myhost" { + t.Errorf("Database.Host = %v, want myhost", db["Host"]) + } + if db["Port"] != 5433 { + t.Errorf("Database.Port = %v, want 5433", db["Port"]) + } + if db["User"] != "myuser" { + t.Errorf("Database.User = %v, want myuser", db["User"]) + } + if db["Password"] != "secret" { + t.Errorf("Database.Password = %v, want secret", db["Password"]) + } + if db["Database"] != "mydb" { + t.Errorf("Database.Database = %v, want mydb", db["Database"]) + } + + // Check that critical sections exist + requiredKeys := []string{ + "Host", "BinPath", "Language", "ClientMode", "Database", + "Sign", "API", "Channel", "Entrance", "DebugOptions", + "GameplayOptions", "Discord", "Commands", "Courses", + "SaveDumps", "Capture", "Screenshots", + } + for _, key := range requiredKeys { + if _, ok := cfg[key]; !ok { + t.Errorf("missing required key %q", key) + } + } + + // Verify it marshals to valid JSON + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if len(data) < 100 { + t.Errorf("config JSON unexpectedly short: %d bytes", len(data)) + } +} + +func TestDetectIP(t *testing.T) { + ws := &wizardServer{ + logger: zap.NewNop(), + done: make(chan struct{}), + } + req := httptest.NewRequest("GET", "/api/setup/detect-ip", nil) + w := httptest.NewRecorder() + ws.handleDetectIP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var resp map[string]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + ip, ok := resp["ip"] + if !ok || ip == "" { + t.Error("expected non-empty IP in response") + } +} + +func TestClientModes(t *testing.T) { + ws := &wizardServer{ + logger: zap.NewNop(), + done: make(chan struct{}), + } + req := httptest.NewRequest("GET", "/api/setup/client-modes", nil) + w := httptest.NewRecorder() + ws.handleClientModes(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var resp map[string][]string + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + modes := resp["modes"] + if len(modes) != 41 { + t.Errorf("got %d modes, want 41", len(modes)) + } + // First should be S1.0, last should be ZZ + if modes[0] != "S1.0" { + t.Errorf("first mode = %q, want S1.0", modes[0]) + } + if modes[len(modes)-1] != "ZZ" { + t.Errorf("last mode = %q, want ZZ", modes[len(modes)-1]) + } +} + +func TestApplySQLFiles(t *testing.T) { + // This test doesn't need a real database — we test the file reading/sorting logic + // by verifying it returns errors when the directory doesn't exist. + _, err := applySQLFiles(nil, "/nonexistent/path") + if err == nil { + t.Error("expected error for nonexistent directory") + } +} + +func TestApplySQLFilesOrdering(t *testing.T) { + // Verify that collectSQLFiles returns files in sorted order and skips non-.sql files. + dir := t.TempDir() + files := []string{"03_c.sql", "01_a.sql", "02_b.sql"} + for _, f := range files { + if err := os.WriteFile(filepath.Join(dir, f), []byte("-- "+f), 0644); err != nil { + t.Fatal(err) + } + } + // Non-SQL file should be skipped + if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("not sql"), 0644); err != nil { + t.Fatal(err) + } + + collected, err := collectSQLFiles(dir) + if err != nil { + t.Fatalf("collectSQLFiles failed: %v", err) + } + if len(collected) != 3 { + t.Fatalf("got %d files, want 3", len(collected)) + } + expected := []string{"01_a.sql", "02_b.sql", "03_c.sql"} + for i, f := range collected { + if f != expected[i] { + t.Errorf("file[%d] = %q, want %q", i, f, expected[i]) + } + } +} + +func TestWriteConfig(t *testing.T) { + dir := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(origDir) }() + + cfg := buildDefaultConfig(FinishRequest{ + DBHost: "localhost", + DBPort: 5432, + DBUser: "postgres", + DBPassword: "pass", + DBName: "erupe", + Host: "127.0.0.1", + ClientMode: "ZZ", + }) + + if err := writeConfig(cfg); err != nil { + t.Fatalf("writeConfig failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatalf("reading config.json: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("config.json is not valid JSON: %v", err) + } + if parsed["Host"] != "127.0.0.1" { + t.Errorf("Host = %v, want 127.0.0.1", parsed["Host"]) + } +} + +func TestHandleIndex(t *testing.T) { + ws := &wizardServer{ + logger: zap.NewNop(), + done: make(chan struct{}), + } + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + ws.handleIndex(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Errorf("Content-Type = %q, want text/html", ct) + } + body := w.Body.String() + if !contains(body, "Erupe Setup Wizard") { + t.Error("response body missing wizard title") + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}