feat(setup): add web-based first-run configuration wizard

When config.json is missing, Erupe now launches a temporary HTTP server
on port 8080 serving a guided setup wizard instead of exiting with a
cryptic error. The wizard walks users through database connection,
schema initialization (pg_restore + SQL migrations), and server settings,
then writes config.json and continues normal startup without restart.
This commit is contained in:
Houmgaor
2026-02-23 20:55:56 +01:00
parent 085dc57648
commit 6a7db47723
7 changed files with 1368 additions and 5 deletions

197
server/setup/handlers.go Normal file
View File

@@ -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)
}

55
server/setup/setup.go Normal file
View File

@@ -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)
}
}

443
server/setup/wizard.go Normal file
View File

@@ -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{"<BODY><CENTER><SIZE_3><C_4>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": "<p>Welcome! Server is running.</p>",
},
},
"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
}

420
server/setup/wizard.html Normal file
View File

@@ -0,0 +1,420 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Erupe Setup Wizard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e;color:#e0e0e0;min-height:100vh;display:flex;justify-content:center;align-items:flex-start;padding:2rem 1rem}
.wizard{max-width:680px;width:100%}
h1{font-size:1.75rem;margin-bottom:.5rem;color:#e94560;text-align:center}
.subtitle{text-align:center;color:#888;margin-bottom:2rem;font-size:.9rem}
/* Progress bar */
.progress{display:flex;gap:4px;margin-bottom:2rem}
.progress-step{flex:1;height:6px;border-radius:3px;background:#0f3460;transition:background .3s}
.progress-step.active{background:#e94560}
.progress-step.done{background:#4ecdc4}
/* Steps */
.step-labels{display:flex;justify-content:space-between;margin-bottom:1.5rem;font-size:.75rem;color:#666}
.step-labels span.active{color:#e94560;font-weight:600}
.step-labels span.done{color:#4ecdc4}
/* Cards */
.card{background:#16213e;border-radius:12px;padding:2rem;box-shadow:0 8px 32px rgba(0,0,0,.4);margin-bottom:1rem}
.card h2{font-size:1.2rem;margin-bottom:1.2rem;color:#e94560}
/* Form */
.field{margin-bottom:1rem}
.field label{display:block;font-size:.85rem;color:#aaa;margin-bottom:.3rem}
.field input,.field select{width:100%;padding:.6rem .8rem;background:#0f3460;border:1px solid #1a3a6e;border-radius:6px;color:#e0e0e0;font-size:.9rem;outline:none;transition:border-color .2s}
.field input:focus,.field select:focus{border-color:#e94560}
.field input::placeholder{color:#556}
.field-row{display:flex;gap:1rem}
.field-row .field{flex:1}
.field-sm{max-width:120px}
/* Checkbox */
.checkbox{display:flex;align-items:center;gap:.5rem;margin-bottom:.7rem;cursor:pointer;font-size:.9rem}
.checkbox input{accent-color:#e94560;width:16px;height:16px}
/* Buttons */
.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.6rem 1.4rem;border:none;border-radius:6px;font-size:.9rem;cursor:pointer;transition:background .2s,opacity .2s}
.btn:disabled{opacity:.5;cursor:not-allowed}
.btn-primary{background:#e94560;color:#fff}
.btn-primary:hover:not(:disabled){background:#c73651}
.btn-secondary{background:#0f3460;color:#e0e0e0;border:1px solid #1a3a6e}
.btn-secondary:hover:not(:disabled){background:#1a3a6e}
.btn-success{background:#4ecdc4;color:#1a1a2e;font-weight:600}
.btn-success:hover:not(:disabled){background:#3dbdb5}
.actions{display:flex;justify-content:space-between;margin-top:1.5rem}
/* Status indicators */
.status{font-size:.85rem;padding:.5rem .8rem;border-radius:6px;margin-top:.7rem}
.status-ok{background:rgba(78,205,196,.15);color:#4ecdc4}
.status-warn{background:rgba(233,69,96,.15);color:#e94560}
.status-info{background:rgba(15,52,96,.5);color:#aaa}
/* Log area */
.log{background:#0a0e1a;border-radius:6px;padding:.8rem;max-height:250px;overflow-y:auto;font-family:"Cascadia Code",Consolas,monospace;font-size:.8rem;line-height:1.5;margin-top:.7rem}
.log-line{color:#8892b0}
.log-line.error{color:#e94560}
.log-line.success{color:#4ecdc4}
/* Review table */
.review-table{width:100%;border-collapse:collapse}
.review-table td{padding:.4rem 0;font-size:.9rem;border-bottom:1px solid rgba(255,255,255,.05)}
.review-table td:first-child{color:#888;width:40%}
.review-table td:last-child{color:#e0e0e0;word-break:break-all}
/* Spinner */
.spinner{display:inline-block;width:14px;height:14px;border:2px solid #e94560;border-top-color:transparent;border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Hidden */
.hidden{display:none !important}
</style>
</head>
<body>
<div class="wizard">
<h1>Erupe Setup Wizard</h1>
<p class="subtitle">First-run configuration — let's get your server running</p>
<div class="progress">
<div class="progress-step" id="prog-1"></div>
<div class="progress-step" id="prog-2"></div>
<div class="progress-step" id="prog-3"></div>
<div class="progress-step" id="prog-4"></div>
</div>
<div class="step-labels">
<span id="lbl-1">1. Database</span>
<span id="lbl-2">2. Schema</span>
<span id="lbl-3">3. Server</span>
<span id="lbl-4">4. Finish</span>
</div>
<!-- Step 1: Database Connection -->
<div class="card step" id="step-1">
<h2>Database Connection</h2>
<p style="font-size:.85rem;color:#888;margin-bottom:1rem">Enter your PostgreSQL connection details.</p>
<div class="field-row">
<div class="field"><label>Host</label><input id="db-host" type="text" value="localhost" placeholder="localhost"></div>
<div class="field field-sm"><label>Port</label><input id="db-port" type="number" value="5432"></div>
</div>
<div class="field-row">
<div class="field"><label>User</label><input id="db-user" type="text" value="postgres" placeholder="postgres"></div>
<div class="field"><label>Password</label><input id="db-password" type="password" placeholder="Enter password"></div>
</div>
<div class="field"><label>Database Name</label><input id="db-name" type="text" value="erupe" placeholder="erupe"></div>
<button class="btn btn-secondary" id="btn-test-db" onclick="testConnection()">Test Connection</button>
<div id="db-status" class="hidden"></div>
<div class="actions">
<div></div>
<button class="btn btn-primary" id="btn-step1-next" onclick="goToStep(2)">Next</button>
</div>
</div>
<!-- Step 2: Database Setup -->
<div class="card step hidden" id="step-2">
<h2>Database Setup</h2>
<p style="font-size:.85rem;color:#888;margin-bottom:1rem">Select which schema operations to perform.</p>
<div id="schema-options">
<label class="checkbox" id="chk-create-db-label"><input type="checkbox" id="chk-create-db" checked> Create database</label>
<label class="checkbox"><input type="checkbox" id="chk-init" checked> Apply init schema (pg_restore — required for new databases)</label>
<label class="checkbox"><input type="checkbox" id="chk-update" checked> Apply update schemas</label>
<label class="checkbox"><input type="checkbox" id="chk-patch" checked> Apply patch schemas (development patches)</label>
<label class="checkbox"><input type="checkbox" id="chk-bundled" checked> Apply bundled data (shops, events, gacha — recommended)</label>
</div>
<button class="btn btn-primary" id="btn-init-db" onclick="initDB()">Initialize Database</button>
<div id="init-log" class="log hidden"></div>
<div id="init-status" class="hidden"></div>
<div class="actions">
<button class="btn btn-secondary" onclick="goToStep(1)">Back</button>
<button class="btn btn-primary" id="btn-step2-next" onclick="goToStep(3)">Next</button>
</div>
</div>
<!-- Step 3: Server Settings -->
<div class="card step hidden" id="step-3">
<h2>Server Settings</h2>
<div class="field">
<label>Host IP Address</label>
<div style="display:flex;gap:.5rem">
<input id="srv-host" type="text" value="127.0.0.1" placeholder="127.0.0.1" style="flex:1">
<button class="btn btn-secondary" id="btn-detect-ip" onclick="detectIP()">Auto-detect</button>
</div>
<div style="font-size:.75rem;color:#666;margin-top:.3rem">Use 127.0.0.1 for local play, or auto-detect for LAN/internet play.</div>
</div>
<div class="field">
<label>Client Mode</label>
<select id="srv-client-mode"></select>
<div style="font-size:.75rem;color:#666;margin-top:.3rem">Must match your game client version. ZZ is the latest.</div>
</div>
<label class="checkbox" style="margin-top:1rem"><input type="checkbox" id="srv-auto-create" checked> Auto-create accounts (recommended for private servers)</label>
<div class="actions">
<button class="btn btn-secondary" onclick="goToStep(2)">Back</button>
<button class="btn btn-primary" onclick="goToStep(4)">Next</button>
</div>
</div>
<!-- Step 4: Review & Finish -->
<div class="card step hidden" id="step-4">
<h2>Review &amp; Finish</h2>
<p style="font-size:.85rem;color:#888;margin-bottom:1rem">Verify your settings before creating config.json.</p>
<table class="review-table" id="review-table"></table>
<div id="finish-status" class="hidden"></div>
<div class="actions">
<button class="btn btn-secondary" onclick="goToStep(3)">Back</button>
<button class="btn btn-success" id="btn-finish" onclick="finish()">Create config &amp; Start Server</button>
</div>
</div>
</div>
<script>
let currentStep = 1;
let dbTestResult = null;
function goToStep(n) {
if (n === 4) buildReview();
if (n === 2) updateSchemaOptions();
document.querySelectorAll('.step').forEach(el => el.classList.add('hidden'));
document.getElementById('step-' + n).classList.remove('hidden');
currentStep = n;
updateProgress();
}
function updateProgress() {
for (let i = 1; i <= 4; i++) {
const p = document.getElementById('prog-' + i);
const l = document.getElementById('lbl-' + i);
p.className = 'progress-step';
l.className = '';
if (i < currentStep) { p.classList.add('done'); l.classList.add('done'); }
else if (i === currentStep) { p.classList.add('active'); l.classList.add('active'); }
}
}
function updateSchemaOptions() {
const createLabel = document.getElementById('chk-create-db-label');
const createCheck = document.getElementById('chk-create-db');
if (dbTestResult && dbTestResult.databaseExists) {
createCheck.checked = false;
createCheck.disabled = true;
createLabel.style.opacity = '0.5';
} else {
createCheck.disabled = false;
createLabel.style.opacity = '1';
}
// If tables already exist, uncheck init
if (dbTestResult && dbTestResult.tablesExist) {
document.getElementById('chk-init').checked = false;
}
}
async function testConnection() {
const btn = document.getElementById('btn-test-db');
const status = document.getElementById('db-status');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Testing...';
status.className = 'status status-info';
status.classList.remove('hidden');
status.textContent = 'Connecting...';
try {
const res = await fetch('/api/setup/test-db', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
host: document.getElementById('db-host').value,
port: parseInt(document.getElementById('db-port').value),
user: document.getElementById('db-user').value,
password: document.getElementById('db-password').value,
dbName: document.getElementById('db-name').value,
})
});
const data = await res.json();
if (data.error) {
status.className = 'status status-warn';
status.textContent = 'Connection failed: ' + data.error;
dbTestResult = null;
} else {
dbTestResult = data.status;
let msg = 'Connected to PostgreSQL.';
if (data.status.databaseExists) {
msg += ' Database exists';
if (data.status.tablesExist) msg += ' (' + data.status.tableCount + ' tables).';
else msg += ' (no tables yet).';
} else {
msg += ' Database does not exist yet (will be created in next step).';
}
status.className = 'status status-ok';
status.textContent = msg;
}
} catch (e) {
status.className = 'status status-warn';
status.textContent = 'Request failed: ' + e.message;
dbTestResult = null;
}
btn.disabled = false;
btn.textContent = 'Test Connection';
}
async function initDB() {
const btn = document.getElementById('btn-init-db');
const logEl = document.getElementById('init-log');
const status = document.getElementById('init-status');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Initializing...';
logEl.classList.remove('hidden');
logEl.innerHTML = '';
status.classList.add('hidden');
try {
const res = await fetch('/api/setup/init-db', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
host: document.getElementById('db-host').value,
port: parseInt(document.getElementById('db-port').value),
user: document.getElementById('db-user').value,
password: document.getElementById('db-password').value,
dbName: document.getElementById('db-name').value,
createDB: document.getElementById('chk-create-db').checked,
applyInit: document.getElementById('chk-init').checked,
applyUpdate: document.getElementById('chk-update').checked,
applyPatch: document.getElementById('chk-patch').checked,
applyBundled: document.getElementById('chk-bundled').checked,
})
});
const data = await res.json();
if (data.log) {
data.log.forEach(line => {
const div = document.createElement('div');
div.className = 'log-line';
if (line.startsWith('ERROR')) div.classList.add('error');
if (line.includes('successfully') || line.includes('complete')) div.classList.add('success');
div.textContent = line;
logEl.appendChild(div);
});
logEl.scrollTop = logEl.scrollHeight;
}
if (data.success) {
status.className = 'status status-ok';
status.textContent = 'Database initialized successfully!';
} else {
status.className = 'status status-warn';
status.textContent = 'Database initialization failed. Check the log above.';
}
status.classList.remove('hidden');
} catch (e) {
status.className = 'status status-warn';
status.textContent = 'Request failed: ' + e.message;
status.classList.remove('hidden');
}
btn.disabled = false;
btn.textContent = 'Initialize Database';
}
async function detectIP() {
const btn = document.getElementById('btn-detect-ip');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>';
try {
const res = await fetch('/api/setup/detect-ip');
const data = await res.json();
if (data.ip) {
document.getElementById('srv-host').value = data.ip;
}
} catch (e) { /* ignore */ }
btn.disabled = false;
btn.textContent = 'Auto-detect';
}
function buildReview() {
const table = document.getElementById('review-table');
const password = document.getElementById('db-password').value;
const masked = password ? '\u2022'.repeat(Math.min(password.length, 12)) : '(empty)';
const rows = [
['Database Host', document.getElementById('db-host').value + ':' + document.getElementById('db-port').value],
['Database User', document.getElementById('db-user').value],
['Database Password', masked],
['Database Name', document.getElementById('db-name').value],
['Server Host', document.getElementById('srv-host').value],
['Client Mode', document.getElementById('srv-client-mode').value],
['Auto-create Accounts', document.getElementById('srv-auto-create').checked ? 'Yes' : 'No'],
];
table.innerHTML = rows.map(r => '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>').join('');
}
async function finish() {
const btn = document.getElementById('btn-finish');
const status = document.getElementById('finish-status');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Creating config...';
try {
const res = await fetch('/api/setup/finish', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
dbHost: document.getElementById('db-host').value,
dbPort: parseInt(document.getElementById('db-port').value),
dbUser: document.getElementById('db-user').value,
dbPassword: document.getElementById('db-password').value,
dbName: document.getElementById('db-name').value,
host: document.getElementById('srv-host').value,
clientMode: document.getElementById('srv-client-mode').value,
autoCreateAccount: document.getElementById('srv-auto-create').checked,
})
});
const data = await res.json();
if (data.status === 'ok') {
status.className = 'status status-ok';
status.innerHTML = '<strong>config.json created!</strong> The server is now starting. You can close this page.';
status.classList.remove('hidden');
btn.textContent = 'Done!';
btn.disabled = true;
} else {
status.className = 'status status-warn';
status.textContent = 'Error: ' + (data.error || 'unknown error');
status.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Create config & Start Server';
}
} catch (e) {
status.className = 'status status-warn';
status.textContent = 'Request failed: ' + e.message;
status.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Create config & Start Server';
}
}
// Load client modes on startup
(async function() {
try {
const res = await fetch('/api/setup/client-modes');
const data = await res.json();
const select = document.getElementById('srv-client-mode');
data.modes.forEach(mode => {
const opt = document.createElement('option');
opt.value = mode;
opt.textContent = mode;
if (mode === 'ZZ') opt.selected = true;
select.appendChild(opt);
});
} catch (e) {
const select = document.getElementById('srv-client-mode');
const opt = document.createElement('option');
opt.value = 'ZZ';
opt.textContent = 'ZZ';
select.appendChild(opt);
}
updateProgress();
})();
</script>
</body>
</html>

239
server/setup/wizard_test.go Normal file
View File

@@ -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
}