mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-26 17:43:21 +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:
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Setup wizard: web-based first-run configuration at `http://localhost:8080` when `config.json` is missing — guides users through database connection, schema initialization, and server settings
|
||||||
- CI: Coverage threshold enforcement — fails build if total coverage drops below 50%
|
- CI: Coverage threshold enforcement — fails build if total coverage drops below 50%
|
||||||
- CI: Release workflow that automatically builds and uploads Linux/Windows binaries to GitHub Releases on tag push
|
- CI: Release workflow that automatically builds and uploads Linux/Windows binaries to GitHub Releases on tag push
|
||||||
- Monthly guild item claim tracking per character per type (standard/HLC/EXC), with schema migration (`31-monthly-items.sql`) adding claim timestamps to the `stamps` table
|
- Monthly guild item claim tracking per character per type (standard/HLC/EXC), with schema migration (`31-monthly-items.sql`) adding claim timestamps to the `stamps` table
|
||||||
|
|||||||
18
main.go
18
main.go
@@ -16,6 +16,7 @@ import (
|
|||||||
"erupe-ce/server/channelserver"
|
"erupe-ce/server/channelserver"
|
||||||
"erupe-ce/server/discordbot"
|
"erupe-ce/server/discordbot"
|
||||||
"erupe-ce/server/entranceserver"
|
"erupe-ce/server/entranceserver"
|
||||||
|
"erupe-ce/server/setup"
|
||||||
"erupe-ce/server/signserver"
|
"erupe-ce/server/signserver"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -79,11 +80,18 @@ func main() {
|
|||||||
|
|
||||||
config, cfgErr := cfg.LoadConfig()
|
config, cfgErr := cfg.LoadConfig()
|
||||||
if cfgErr != nil {
|
if cfgErr != nil {
|
||||||
fmt.Println("\nFailed to start Erupe:\n" + fmt.Sprintf("Failed to load config: %s", cfgErr.Error()))
|
if _, err := os.Stat("config.json"); os.IsNotExist(err) {
|
||||||
go wait()
|
logger.Info("No config.json found, launching setup wizard")
|
||||||
fmt.Println("\nPress Enter/Return to exit...")
|
if err := setup.Run(logger.Named("setup"), 8080); err != nil {
|
||||||
_, _ = fmt.Scanln()
|
logger.Fatal("Setup wizard failed", zap.Error(err))
|
||||||
os.Exit(0)
|
}
|
||||||
|
config, cfgErr = cfg.LoadConfig()
|
||||||
|
if cfgErr != nil {
|
||||||
|
logger.Fatal("Config still invalid after setup", zap.Error(cfgErr))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
preventClose(config, fmt.Sprintf("Failed to load config: %s", cfgErr.Error()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info(fmt.Sprintf("Starting Erupe (9.3b-%s)", Commit()))
|
logger.Info(fmt.Sprintf("Starting Erupe (9.3b-%s)", Commit()))
|
||||||
|
|||||||
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