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! 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 @@ + + + + + +First-run configuration — let's get your server running
+ +Enter your PostgreSQL connection details.
+