mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
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:
197
server/setup/handlers.go
Normal file
197
server/setup/handlers.go
Normal 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
55
server/setup/setup.go
Normal 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
443
server/setup/wizard.go
Normal 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
420
server/setup/wizard.html
Normal 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 & 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 & 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
239
server/setup/wizard_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user