mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-26 01:23:13 +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)
|
||||
}
|
||||
Reference in New Issue
Block a user