186 lines
5.9 KiB
Go
186 lines
5.9 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"ymhut-box/server/unified-management/internal/config"
|
|
"ymhut-box/server/unified-management/internal/db"
|
|
)
|
|
|
|
type setupRouter struct {
|
|
cfg *config.Config
|
|
}
|
|
|
|
type setupRequest struct {
|
|
Provider string `json:"provider"`
|
|
BaseURL string `json:"baseUrl"`
|
|
SQLitePath string `json:"sqlitePath"`
|
|
MySQLDSN string `json:"mysqlDsn"`
|
|
MySQL config.MySQLInput `json:"mysql"`
|
|
}
|
|
|
|
func NewSetupRouter(cfg *config.Config) http.Handler {
|
|
return withSecurity(&setupRouter{cfg: cfg})
|
|
}
|
|
|
|
func (r *setupRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
path := cleanPath(req.URL.Path)
|
|
switch {
|
|
case path == "/" || path == "/setup":
|
|
r.serveSetup(w, req)
|
|
case strings.HasPrefix(path, "/setup/assets/"):
|
|
serveStaticAsset(w, req, r.cfg.SetupWebDir, "setup/dist", strings.TrimPrefix(path, "/setup/"))
|
|
case path == "/api/setup/status":
|
|
writeJSON(w, http.StatusOK, r.status())
|
|
case path == "/api/setup/database/test":
|
|
r.handleDatabaseTest(w, req)
|
|
case path == "/api/setup/complete":
|
|
r.handleComplete(w, req)
|
|
default:
|
|
if strings.HasPrefix(path, "/api/") {
|
|
writeError(w, http.StatusServiceUnavailable, "SETUP_REQUIRED", errors.New("system setup is required"))
|
|
return
|
|
}
|
|
http.Redirect(w, req, "/setup", http.StatusFound)
|
|
}
|
|
}
|
|
|
|
func (r *setupRouter) status() map[string]any {
|
|
return map[string]any{
|
|
"ok": true,
|
|
"initialized": r.cfg.Initialized,
|
|
"baseDir": ".",
|
|
"configPath": relativeToBase(r.cfg.BaseDir, r.cfg.ConfigPath),
|
|
"defaults": map[string]any{
|
|
"provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"),
|
|
"sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.SQLitePath),
|
|
"mysqlDsn": config.MaskDSN(r.cfg.Database.MySQLDSN),
|
|
"baseUrl": r.cfg.BaseURL,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (r *setupRouter) handleDatabaseTest(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
|
return
|
|
}
|
|
next, body, err := r.decodeSetupDatabase(req)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
|
return
|
|
}
|
|
started := time.Now()
|
|
if err := db.TestDatabase(next); err != nil {
|
|
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"provider": next.Provider,
|
|
"latencyMs": time.Since(started).Milliseconds(),
|
|
"maskedDsn": maskedDatabaseTarget(r.cfg.BaseDir, next),
|
|
"normalized": map[string]any{
|
|
"provider": next.Provider,
|
|
"baseUrl": firstNonEmpty(body.BaseURL, r.cfg.BaseURL),
|
|
"sqlitePath": relativeToBase(r.cfg.BaseDir, next.SQLitePath),
|
|
"mysqlDsn": config.MaskDSN(next.MySQLDSN),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (r *setupRouter) handleComplete(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
|
return
|
|
}
|
|
nextDB, body, err := r.decodeSetupDatabase(req)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
|
return
|
|
}
|
|
if err := db.TestDatabase(nextDB); err != nil {
|
|
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
|
|
return
|
|
}
|
|
next := *r.cfg
|
|
next.Initialized = true
|
|
next.BaseURL = firstNonEmpty(strings.TrimSpace(body.BaseURL), next.BaseURL)
|
|
next.Database = nextDB
|
|
if strings.EqualFold(next.Database.Provider, "mysql") {
|
|
next.Database.FailoverEnabled = true
|
|
next.Database.HotSyncEnabled = true
|
|
}
|
|
store, err := db.Open(&next)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "DATABASE_OPEN_FAILED", err)
|
|
return
|
|
}
|
|
if err := store.EnsureDefaultAdmin(req.Context()); err != nil {
|
|
_ = store.Close()
|
|
writeError(w, http.StatusInternalServerError, "ADMIN_INIT_FAILED", err)
|
|
return
|
|
}
|
|
_ = store.Close()
|
|
if err := config.Save(&next); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "CONFIG_SAVE_FAILED", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "initialized": true, "message": "Setup completed. Restart the service, then open /admin/login."})
|
|
}
|
|
|
|
func (r *setupRouter) decodeSetupDatabase(req *http.Request) (config.DatabaseConfig, setupRequest, error) {
|
|
var body setupRequest
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
return config.DatabaseConfig{}, body, err
|
|
}
|
|
incoming := config.DatabaseConfig{
|
|
Provider: body.Provider,
|
|
SQLitePath: body.SQLitePath,
|
|
MySQLDSN: body.MySQLDSN,
|
|
MySQLHost: body.MySQL.Host,
|
|
MySQLPort: body.MySQL.Port,
|
|
MySQLDatabase: body.MySQL.Database,
|
|
MySQLUser: body.MySQL.Username,
|
|
MySQLPassword: body.MySQL.Password,
|
|
}
|
|
next, err := config.NormalizeDatabase(r.cfg.BaseDir, r.cfg.Database, incoming, false)
|
|
return next, body, err
|
|
}
|
|
|
|
func (r *setupRouter) serveSetup(w http.ResponseWriter, req *http.Request) {
|
|
index := filepath.Join(r.cfg.SetupWebDir, "index.html")
|
|
if tryServeDiskFile(w, req, r.cfg.SetupWebDir, "index.html") {
|
|
return
|
|
}
|
|
if serveEmbeddedFile(w, req, "setup/dist/index.html") {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>YMhut Setup</title></head><body><main><h1>YMhut Setup</h1><p>Setup frontend is not built. Run npm install && npm run build in web/setup.</p><p>` + index + `</p></main></body></html>`))
|
|
}
|
|
|
|
func maskedDatabaseTarget(base string, cfg config.DatabaseConfig) string {
|
|
if strings.EqualFold(cfg.Provider, "mysql") {
|
|
return config.MaskDSN(cfg.MySQLDSN)
|
|
}
|
|
return relativeToBase(base, cfg.SQLitePath)
|
|
}
|
|
|
|
func relativeToBase(base, value string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return ""
|
|
}
|
|
if base != "" {
|
|
if rel, err := filepath.Rel(base, value); err == nil && !strings.HasPrefix(rel, "..") && rel != "." {
|
|
return filepath.ToSlash(rel)
|
|
}
|
|
}
|
|
return filepath.ToSlash(value)
|
|
}
|