Add server components
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:28:09 +08:00
parent 7ecc6a8923
commit 079ee4eaeb
168 changed files with 37475 additions and 0 deletions
@@ -0,0 +1,979 @@
package web
import (
"bytes"
"encoding/csv"
"encoding/json"
"errors"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"ymhut-box/server/unified-management/internal/auth"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
"ymhut-box/server/unified-management/internal/feedback"
"ymhut-box/server/unified-management/internal/health"
"ymhut-box/server/unified-management/internal/legacy"
"ymhut-box/server/unified-management/internal/notices"
"ymhut-box/server/unified-management/internal/releases"
"ymhut-box/server/unified-management/internal/sources"
"ymhut-box/server/unified-management/internal/synclegacy"
webassets "ymhut-box/server/unified-management/web"
)
type router struct {
cfg *config.Config
store *db.Store
auth *auth.Service
feedback *feedback.Service
releases *releases.Service
sources *sources.Service
legacy *legacy.Service
notices *notices.Service
syncer *synclegacy.Service
}
func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, feedbackService *feedback.Service, releaseService *releases.Service, sourceService *sources.Service, legacyService *legacy.Service, optional ...any) http.Handler {
r := &router{
cfg: cfg,
store: store,
auth: authService,
feedback: feedbackService,
releases: releaseService,
sources: sourceService,
legacy: legacyService,
}
for _, item := range optional {
switch typed := item.(type) {
case *notices.Service:
r.notices = typed
case *synclegacy.Service:
r.syncer = typed
}
}
return withSecurity(r)
}
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
switch {
case path == "/" && req.Method == http.MethodPost:
r.handleFeedbackSubmit(w, req)
case path == "/" && req.URL.Query().Get("api") == "status":
r.handleFeedbackStatus(w, req)
case isPortalRoute(path):
r.servePortal(w, req)
case path == "/api/auth/bootstrap" || path == "/api/admin/auth/bootstrap":
r.handleAuthBootstrap(w, req)
case path == "/api/auth/captcha" || path == "/api/admin/auth/captcha":
r.handleCaptcha(w, req)
case path == "/api/auth/login" || path == "/api/admin/auth/login":
r.handleLogin(w, req)
case path == "/api/auth/logout" || path == "/api/admin/auth/logout":
r.auth.Require(http.HandlerFunc(r.handleLogout)).ServeHTTP(w, req)
case path == "/api/admin/auth/password":
r.auth.Require(http.HandlerFunc(r.handleChangePassword)).ServeHTTP(w, req)
case path == "/api/client/bootstrap":
r.handleClientBootstrap(w, req)
case path == "/api/client/releases" || path == "/api/releases" || path == "/api/update-info":
writeJSON(w, http.StatusOK, r.releases.Manifest(req))
case path == "/api/client/sources":
r.handleClientSources(w, req)
case path == "/api/client/endpoints":
r.handleClientEndpoints(w, req)
case path == "/api/client/notices" || strings.HasPrefix(path, "/api/client/notices/"):
r.handleClientNotices(w, req)
case path == "/api/client/endpoint-calls" || path == "/api/client/source-calls":
r.handleSourceCall(w, req)
case path == "/update-info.json" || path == "/update-info":
writeJSON(w, http.StatusOK, r.releases.LegacyUpdateInfo(req))
case path == "/tool-status.json" || path == "/tool-status":
writeJSON(w, http.StatusOK, r.releases.StaticJSON("tool-status.json"))
case path == "/modules.json" || path == "/modules" || path == "/api/modules":
writeJSON(w, http.StatusOK, r.releases.StaticJSON("modules.json"))
case path == "/media-types.json" || path == "/media-types":
r.handleLegacyMediaTypes(w, req)
case strings.HasPrefix(path, "/downloads/"):
r.handleDownload(w, req)
case strings.HasPrefix(path, "/admin/assets/"):
serveStaticAsset(w, req, r.cfg.AdminWebDir, "admin/dist", strings.TrimPrefix(path, "/admin/"))
case strings.HasPrefix(path, "/assets/"):
serveStaticAsset(w, req, r.cfg.PortalWebDir, "portal/dist", strings.TrimPrefix(path, "/"))
case strings.HasPrefix(path, "/api/admin/feedbacks"):
r.auth.Require(http.HandlerFunc(r.handleAdminFeedbacks)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/dashboard"):
r.auth.Require(http.HandlerFunc(r.handleAdminDashboard)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/sync"):
r.auth.Require(http.HandlerFunc(r.handleAdminSync)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/releases"):
r.auth.Require(http.HandlerFunc(r.handleAdminReleases)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/sources"):
r.auth.Require(http.HandlerFunc(r.handleAdminSources)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/endpoints"):
r.auth.Require(http.HandlerFunc(r.handleAdminEndpoints)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/legacy"):
r.auth.Require(http.HandlerFunc(r.handleAdminLegacy)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/database"):
r.auth.Require(http.HandlerFunc(r.handleAdminDatabase)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/system"):
r.auth.Require(http.HandlerFunc(r.handleAdminSystem)).ServeHTTP(w, req)
case path == "/admin" || path == "/admin/":
http.Redirect(w, req, "/admin/dashboard", http.StatusFound)
case path == "/admin/login" || strings.HasPrefix(path, "/admin/"):
r.serveAdmin(w, req)
default:
http.NotFound(w, req)
}
}
func (r *router) handleAuthBootstrap(w http.ResponseWriter, req *http.Request) {
payload, err := r.auth.Bootstrap(req.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "BOOTSTRAP_FAILED", err)
return
}
writeJSON(w, http.StatusOK, payload)
}
func (r *router) handleCaptcha(w http.ResponseWriter, req *http.Request) {
captcha, err := r.auth.NewCaptcha()
if err != nil {
writeError(w, http.StatusInternalServerError, "CAPTCHA_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "captchaId": captcha.ID, "image": captcha.Image})
}
func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
CaptchaID string `json:"captchaId"`
Captcha string `json:"captcha"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
if body.Username == "" {
body.Username = "admin"
}
sessionID, csrf, ok, err := r.auth.Login(req.Context(), body.Username, body.Password, body.CaptchaID, body.Captcha)
if err != nil {
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
return
}
if !ok {
writeError(w, http.StatusUnauthorized, "LOGIN_FAILED", errors.New("invalid password or captcha"))
return
}
auth.SetSessionCookie(w, sessionID)
_ = r.store.InsertAudit(db.AuditLog{Actor: body.Username, Type: "auth.login", Target: "admin", Message: "Admin login", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrfToken": csrf, "user": map[string]any{"username": body.Username}})
}
func (r *router) handleLogout(w http.ResponseWriter, req *http.Request) {
r.auth.Logout(w, req)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (r *router) handleChangePassword(w http.ResponseWriter, req *http.Request) {
var body struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
if err := r.store.ChangeAdminPassword(req.Context(), "admin", body.CurrentPassword, body.NewPassword); err != nil {
writeError(w, http.StatusBadRequest, "PASSWORD_CHANGE_FAILED", err)
return
}
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "Admin password changed", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) {
release := r.releases.Manifest(req)
sourceCatalog, _ := r.sources.Catalog(false)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"serviceVersion": config.Version,
"baseUrl": requestBaseURL(req, r.cfg.BaseURL),
"capabilities": map[string]bool{
"dynamicSources": true,
"sourceHealth": true,
"feedbackStatus": true,
"releaseManifest": true,
"endpointCalls": true,
"legacyJson": true,
},
"endpoints": map[string]string{
"releases": "/api/client/releases",
"sources": "/api/client/sources",
"clientEndpoints": "/api/client/endpoints",
"endpointCalls": "/api/client/endpoint-calls",
"notices": "/api/client/notices",
"feedback": "/",
},
"cache": map[string]int{
"bootstrapSeconds": 300,
"releasesSeconds": 300,
"sourcesSeconds": 600,
"healthSeconds": 300,
},
"legacyRoutes": []string{"/update-info.json", "/update-info", "/api/update-info", "/api/releases", "/tool-status.json", "/media-types.json", "/modules.json", "/downloads/:filename"},
"release": release,
"sources": sourceCatalog,
"feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"},
"health": health.Snapshot(r.cfg, r.store),
})
}
func (r *router) handleClientSources(w http.ResponseWriter, req *http.Request) {
catalog, err := r.sources.Catalog(false)
if err != nil {
writeError(w, http.StatusInternalServerError, "SOURCES_FAILED", err)
return
}
writeJSON(w, http.StatusOK, catalog)
}
func (r *router) handleClientEndpoints(w http.ResponseWriter, req *http.Request) {
items, err := r.sources.Endpoints(false)
if err != nil {
writeError(w, http.StatusInternalServerError, "ENDPOINTS_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
}
func (r *router) handleClientNotices(w http.ResponseWriter, req *http.Request) {
if r.notices == nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": []any{}})
return
}
path := cleanPath(req.URL.Path)
if path == "/api/client/notices" {
items, err := r.notices.List(100)
if err != nil {
writeError(w, http.StatusInternalServerError, "NOTICES_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": notices.PublicList(items)})
return
}
version := strings.TrimPrefix(path, "/api/client/notices/")
if version == "" {
http.NotFound(w, req)
return
}
doc, err := r.notices.Get(version)
if err != nil {
writeError(w, http.StatusNotFound, "NOTICE_NOT_FOUND", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "notice": notices.PublicNotice(doc.Notice), "raw": doc.Parsed})
}
func (r *router) handleLegacyMediaTypes(w http.ResponseWriter, req *http.Request) {
catalog, err := r.sources.Catalog(false)
if err != nil {
writeError(w, http.StatusInternalServerError, "MEDIA_TYPES_FAILED", err)
return
}
writeJSON(w, http.StatusOK, catalog)
}
func (r *router) handleSourceCall(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
return
}
var body db.SourceCall
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
body.Client = firstNonEmpty(body.Client, req.UserAgent())
if err := r.store.RecordSourceCall(body); err != nil {
writeError(w, http.StatusInternalServerError, "SOURCE_CALL_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (r *router) handleFeedbackSubmit(w http.ResponseWriter, req *http.Request) {
item, err := r.feedback.Submit(req)
if err != nil {
writeError(w, http.StatusBadRequest, "FEEDBACK_FAILED", err)
return
}
_ = r.store.InsertAudit(db.AuditLog{Actor: "client", Type: "feedback.created", Target: item.Code, Message: item.Title, IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "code": item.Code})
}
func (r *router) handleFeedbackStatus(w http.ResponseWriter, req *http.Request) {
code := strings.TrimSpace(req.URL.Query().Get("code"))
if code == "" {
writeError(w, http.StatusBadRequest, "INVALID_CODE", errors.New("code is required"))
return
}
item, err := r.store.GetFeedback(code)
if err != nil {
writeError(w, http.StatusNotFound, "NOT_FOUND", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "feedback": item})
}
func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
if req.Method == http.MethodGet && path == "/api/admin/feedbacks" {
if req.URL.Query().Get("page") != "" {
page, _ := strconv.Atoi(req.URL.Query().Get("page"))
perPage, _ := strconv.Atoi(req.URL.Query().Get("perPage"))
items, total, err := r.store.ListFeedbacksFiltered(page, perPage, db.FeedbackFilters{
Status: req.URL.Query().Get("status"),
Category: req.URL.Query().Get("category"),
Priority: req.URL.Query().Get("priority"),
Query: req.URL.Query().Get("q"),
Assignee: req.URL.Query().Get("assignee"),
Sort: req.URL.Query().Get("sort"),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "FEEDBACK_LIST_FAILED", err)
return
}
if page <= 0 {
page = 1
}
if perPage <= 0 {
perPage = 20
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "page": map[string]any{"items": items, "total": total, "page": page, "perPage": perPage}})
return
}
limit, _ := strconv.Atoi(req.URL.Query().Get("limit"))
items, err := r.store.ListFeedbacks(limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "FEEDBACK_LIST_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
return
}
if req.Method == http.MethodGet && path == "/api/admin/feedbacks/export" {
items, _, err := r.store.ListFeedbacksFiltered(1, 100, db.FeedbackFilters{
Status: req.URL.Query().Get("status"),
Category: req.URL.Query().Get("category"),
Priority: req.URL.Query().Get("priority"),
Query: req.URL.Query().Get("q"),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "EXPORT_FAILED", err)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="feedbacks.csv"`)
writer := csv.NewWriter(w)
_ = writer.Write([]string{"code", "created_at", "title", "status", "category", "priority", "contact", "status_detail", "public_reply"})
for _, item := range items {
_ = writer.Write([]string{item.Code, item.CreatedAt, item.Title, item.Status, item.Category, item.Priority, item.Contact, item.StatusDetail, item.PublicReply})
}
writer.Flush()
return
}
if req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/feedbacks/") {
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
detail, err := r.store.GetFeedbackDetail(code)
if err != nil {
writeError(w, http.StatusNotFound, "NOT_FOUND", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "feedback": detail})
return
}
if req.Method == http.MethodPatch && path == "/api/admin/feedbacks/bulk" {
var body struct {
Codes []string `json:"codes"`
Status string `json:"status"`
StatusDetail string `json:"statusDetail"`
PublicReply string `json:"publicReply"`
Assignee string `json:"assignee"`
Tags []string `json:"tags"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || len(body.Codes) == 0 {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("codes are required"))
return
}
if err := r.store.BulkUpdateFeedback(body.Codes, db.FeedbackUpdate{Status: body.Status, StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Assignee: body.Assignee, Actor: "admin", Tags: body.Tags}); err != nil {
writeError(w, http.StatusInternalServerError, "BULK_UPDATE_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updated": len(body.Codes)})
return
}
if req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/feedbacks/") && strings.HasSuffix(path, "/comments") {
code := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/feedbacks/"), "/comments")
var body struct {
Author string `json:"author"`
Body string `json:"body"`
Internal bool `json:"internal"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
comment, err := r.store.InsertFeedbackComment(db.FeedbackComment{Code: code, Author: firstNonEmpty(body.Author, "admin"), Body: body.Body, Internal: body.Internal})
if err != nil {
writeError(w, http.StatusInternalServerError, "COMMENT_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "comment": comment})
return
}
if req.Method == http.MethodPatch && strings.HasPrefix(path, "/api/admin/feedbacks/") {
code := strings.TrimPrefix(path, "/api/admin/feedbacks/")
var body struct {
Status string `json:"status"`
StatusDetail string `json:"statusDetail"`
PublicReply string `json:"publicReply"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
if err := r.store.UpdateFeedbackTicket(code, db.FeedbackUpdate{Status: firstNonEmpty(body.Status, "new"), StatusDetail: body.StatusDetail, PublicReply: body.PublicReply, Actor: "admin"}); err != nil {
writeError(w, http.StatusInternalServerError, "FEEDBACK_UPDATE_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
http.NotFound(w, req)
}
func (r *router) handleAdminLegacy(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
name := ""
switch {
case strings.HasPrefix(path, "/api/admin/legacy/update-info"):
name = "update-info"
case strings.HasPrefix(path, "/api/admin/legacy/media-types"):
name = "media-types"
default:
parts := strings.Split(strings.TrimPrefix(path, "/api/admin/legacy/"), "/")
if len(parts) > 0 {
name = parts[0]
}
}
if name == "" {
http.NotFound(w, req)
return
}
if req.Method == http.MethodGet && (path == "/api/admin/legacy/update-info" || path == "/api/admin/legacy/media-types") {
doc, err := r.legacy.Get(req.Context(), name)
if err != nil {
writeError(w, http.StatusBadRequest, "LEGACY_GET_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
return
}
if req.Method == http.MethodPut && (path == "/api/admin/legacy/update-info" || path == "/api/admin/legacy/media-types") {
var body legacy.SaveRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
doc, err := r.legacy.Save(req.Context(), name, body, "admin")
if err != nil {
writeError(w, http.StatusBadRequest, "LEGACY_SAVE_FAILED", err)
return
}
if name == "media-types" {
_ = r.sources.ImportLegacyMediaTypes(req.Context())
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
return
}
if req.Method == http.MethodPost && strings.HasSuffix(path, "/validate") {
var body legacy.SaveRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
doc, err := r.legacy.Validate(req.Context(), name, body)
if err != nil {
writeError(w, http.StatusBadRequest, "LEGACY_VALIDATE_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
return
}
if req.Method == http.MethodPost && strings.HasSuffix(path, "/restore") {
var body struct {
RevisionID int64 `json:"revisionId"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.RevisionID <= 0 {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("revisionId is required"))
return
}
doc, err := r.legacy.Restore(req.Context(), name, body.RevisionID, "admin")
if err != nil {
writeError(w, http.StatusBadRequest, "LEGACY_RESTORE_FAILED", err)
return
}
if name == "media-types" {
_ = r.sources.ImportLegacyMediaTypes(req.Context())
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
return
}
http.NotFound(w, req)
}
func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
switch {
case req.Method == http.MethodGet && path == "/api/admin/database/status":
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status()})
case req.Method == http.MethodPost && path == "/api/admin/database/test":
var body config.DatabaseConfig
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
if body.Provider == "" {
body.Provider = r.cfg.Database.Provider
}
if body.SQLitePath == "" {
body.SQLitePath = r.cfg.Database.SQLitePath
}
if body.MySQLDSN == "" {
body.MySQLDSN = r.cfg.Database.MySQLDSN
}
if err := db.TestDatabase(body); err != nil {
writeError(w, http.StatusBadGateway, "DATABASE_TEST_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
case req.Method == http.MethodPost && path == "/api/admin/database/import-sqlite":
result, err := r.store.ImportSQLiteToRemote()
if err != nil {
writeError(w, http.StatusBadGateway, "DATABASE_IMPORT_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result})
case req.Method == http.MethodPost && path == "/api/admin/database/sync":
result, err := r.store.SyncNow()
if err != nil {
writeError(w, http.StatusBadGateway, "DATABASE_SYNC_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result})
default:
http.NotFound(w, req)
}
}
func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
if req.Method != http.MethodGet || path != "/api/admin/dashboard/overview" {
http.NotFound(w, req)
return
}
overview, err := r.store.DashboardOverview(80)
if err != nil {
writeError(w, http.StatusInternalServerError, "DASHBOARD_FAILED", err)
return
}
overview["health"] = health.Snapshot(r.cfg, r.store)
writeJSON(w, http.StatusOK, overview)
}
func (r *router) handleAdminSync(w http.ResponseWriter, req *http.Request) {
if r.syncer == nil {
writeError(w, http.StatusNotFound, "SYNC_DISABLED", errors.New("legacy sync service is not configured"))
return
}
path := cleanPath(req.URL.Path)
switch {
case req.Method == http.MethodGet && path == "/api/admin/sync/legacy/preview":
writeJSON(w, http.StatusOK, r.syncer.Preview(req.Context()))
case req.Method == http.MethodPost && path == "/api/admin/sync/legacy/run":
writeJSON(w, http.StatusOK, r.syncer.Run(req.Context()))
default:
http.NotFound(w, req)
}
}
func (r *router) handleAdminEndpoints(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.NotFound(w, req)
return
}
items, err := r.sources.Endpoints(true)
if err != nil {
writeError(w, http.StatusInternalServerError, "ENDPOINTS_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
}
func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
if strings.HasPrefix(path, "/api/admin/releases/notices") {
r.handleAdminReleaseNotices(w, req)
return
}
switch path {
case "/api/admin/releases":
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "manifest": r.releases.Manifest(req)})
case "/api/admin/releases/legacy-preview":
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updateInfo": r.releases.LegacyUpdateInfo(req), "toolStatus": r.releases.StaticJSON("tool-status.json")})
default:
http.NotFound(w, req)
}
}
func (r *router) handleAdminReleaseNotices(w http.ResponseWriter, req *http.Request) {
if r.notices == nil {
writeError(w, http.StatusNotFound, "NOTICES_DISABLED", errors.New("release notices are not configured"))
return
}
path := cleanPath(req.URL.Path)
if req.Method == http.MethodPost && path == "/api/admin/releases/notices/import" {
if err := r.notices.Import(req.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "NOTICE_IMPORT_FAILED", err)
return
}
items, _ := r.notices.List(100)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
return
}
if req.Method == http.MethodGet && path == "/api/admin/releases/notices" {
items, err := r.notices.List(100)
if err != nil {
writeError(w, http.StatusInternalServerError, "NOTICE_LIST_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
return
}
rest := strings.TrimPrefix(path, "/api/admin/releases/notices/")
if rest == "" || rest == path {
http.NotFound(w, req)
return
}
parts := strings.Split(rest, "/")
version := parts[0]
if req.Method == http.MethodGet && len(parts) == 1 {
doc, err := r.notices.Get(version)
if err != nil {
writeError(w, http.StatusNotFound, "NOTICE_NOT_FOUND", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
return
}
if req.Method == http.MethodPut && len(parts) == 1 {
var body notices.SaveRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
doc, err := r.notices.Save(req.Context(), version, body, "admin")
if err != nil {
writeError(w, http.StatusBadRequest, "NOTICE_SAVE_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
return
}
if req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "validate" {
var body notices.SaveRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
doc, err := r.notices.Validate(req.Context(), version, body)
if err != nil {
writeError(w, http.StatusBadRequest, "NOTICE_VALIDATE_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
return
}
if req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "restore" {
var body struct {
RevisionID int64 `json:"revisionId"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.RevisionID <= 0 {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("revisionId is required"))
return
}
doc, err := r.notices.Restore(req.Context(), version, body.RevisionID, "admin")
if err != nil {
writeError(w, http.StatusBadRequest, "NOTICE_RESTORE_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
return
}
http.NotFound(w, req)
}
func (r *router) handleAdminSources(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
switch {
case req.Method == http.MethodGet && path == "/api/admin/sources":
catalog, err := r.sources.Catalog(true)
if err != nil {
writeError(w, http.StatusInternalServerError, "SOURCES_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "catalog": catalog})
case req.Method == http.MethodPost && path == "/api/admin/sources/import-media-types":
if err := r.sources.ImportLegacyMediaTypes(req.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "IMPORT_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
case req.Method == http.MethodPost && path == "/api/admin/sources/check":
go r.sources.CheckDue(req.Context())
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": true})
case req.Method == http.MethodPost && strings.HasPrefix(path, "/api/admin/sources/") && strings.HasSuffix(path, "/check"):
sourceID := strings.TrimSuffix(strings.TrimPrefix(path, "/api/admin/sources/"), "/check")
item, err := r.sources.CheckSourceID(req.Context(), sourceID)
if err != nil {
writeError(w, http.StatusBadRequest, "CHECK_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": item})
case (req.Method == http.MethodPost || req.Method == http.MethodPut) && path == "/api/admin/sources":
var item db.Source
if err := json.NewDecoder(req.Body).Decode(&item); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
saved, err := r.store.UpsertSource(item)
if err != nil {
writeError(w, http.StatusInternalServerError, "SOURCE_SAVE_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "source": saved})
case req.Method == http.MethodDelete && strings.HasPrefix(path, "/api/admin/sources/"):
sourceID := strings.TrimPrefix(path, "/api/admin/sources/")
if err := r.store.DeleteSource(sourceID); err != nil {
writeError(w, http.StatusInternalServerError, "SOURCE_DELETE_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
default:
http.NotFound(w, req)
}
}
func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
switch path {
case "/api/admin/system/health":
writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store))
case "/api/admin/system/audit":
items, err := r.store.ListAuditLogs(100)
if err != nil {
writeError(w, http.StatusInternalServerError, "AUDIT_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
case "/api/admin/system/database/sync":
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
return
}
finishedAt, err := r.store.CopySQLiteToRemote()
if err != nil {
writeError(w, http.StatusBadRequest, "SYNC_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "finishedAt": finishedAt})
default:
http.NotFound(w, req)
}
}
func (r *router) handleDownload(w http.ResponseWriter, req *http.Request) {
name := strings.TrimPrefix(cleanPath(req.URL.Path), "/downloads/")
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid filename"))
return
}
path := filepath.Join(r.cfg.DownloadsDir, name)
resolved, err := filepath.Abs(path)
if err != nil {
writeError(w, http.StatusInternalServerError, "PATH_FAILED", err)
return
}
base, _ := filepath.Abs(r.cfg.DownloadsDir)
if !strings.HasPrefix(resolved, base) {
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("path escape rejected"))
return
}
http.ServeFile(w, req, resolved)
}
func serveStaticAsset(w http.ResponseWriter, req *http.Request, root, embedRoot, assetPath string) {
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid asset path"))
return
}
if tryServeDiskFile(w, req, root, assetPath) {
return
}
if serveEmbeddedFile(w, req, embedRoot+"/"+filepath.ToSlash(assetPath)) {
return
}
http.NotFound(w, req)
}
func tryServeDiskFile(w http.ResponseWriter, req *http.Request, root, assetPath string) bool {
path := filepath.Join(root, filepath.FromSlash(assetPath))
resolved, err := filepath.Abs(path)
if err != nil {
writeError(w, http.StatusInternalServerError, "PATH_FAILED", err)
return true
}
base, _ := filepath.Abs(root)
if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) {
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("path escape rejected"))
return true
}
info, err := os.Stat(resolved)
if err != nil || info.IsDir() {
return false
}
http.ServeFile(w, req, resolved)
return true
}
func serveEmbeddedFile(w http.ResponseWriter, req *http.Request, name string) bool {
if strings.Contains(name, "..") || strings.ContainsAny(name, `\`) {
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid embedded asset path"))
return true
}
data, err := webassets.ReadFile(name)
if err != nil {
return false
}
if contentType := mime.TypeByExtension(filepath.Ext(name)); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
http.ServeContent(w, req, filepath.Base(name), time.Time{}, bytes.NewReader(data))
return true
}
func (r *router) servePortal(w http.ResponseWriter, req *http.Request) {
index := filepath.Join(r.cfg.PortalWebDir, "index.html")
if _, err := os.Stat(index); err == nil {
http.ServeFile(w, req, index)
return
}
if serveEmbeddedFile(w, req, "portal/dist/index.html") {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Box</title></head><body><main><h1>YMhut Box</h1><p>Unified management service is running.</p><p><a href="/api/client/bootstrap">Client bootstrap</a> | <a href="/admin/login">Admin</a></p></main></body></html>`))
}
func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
index := filepath.Join(r.cfg.AdminWebDir, "index.html")
if _, err := os.Stat(index); err == nil {
http.ServeFile(w, req, index)
return
}
if serveEmbeddedFile(w, req, "admin/dist/index.html") {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Admin</title></head><body><main><h1>YMhut Admin</h1><p>Build web/admin to enable the Vue console.</p></main></body></html>`))
}
func isPortalRoute(path string) bool {
switch path {
case "/", "/releases", "/sources", "/feedback", "/compatibility":
return true
default:
return false
}
}
func withSecurity(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, code string, err error) {
message := ""
if err != nil {
message = err.Error()
}
writeJSON(w, status, map[string]any{"ok": false, "error": code, "message": message})
}
func cleanPath(path string) string {
if path == "" {
return "/"
}
if path != "/" {
path = strings.TrimRight(path, "/")
}
if path == "" {
return "/"
}
return path
}
func requestBaseURL(r *http.Request, fallback string) string {
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
if r.Host != "" {
return scheme + "://" + r.Host
}
return strings.TrimRight(fallback, "/")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
@@ -0,0 +1,247 @@
package web
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"ymhut-box/server/unified-management/internal/auth"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
"ymhut-box/server/unified-management/internal/feedback"
"ymhut-box/server/unified-management/internal/legacy"
"ymhut-box/server/unified-management/internal/notices"
"ymhut-box/server/unified-management/internal/releases"
"ymhut-box/server/unified-management/internal/sources"
)
func TestCompatibilityRoutes(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
for _, path := range []string{"/api/client/bootstrap", "/update-info.json", "/media-types.json", "/modules.json"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatalf("%s did not return JSON: %v", path, err)
}
}
}
func TestClientBootstrapAndEndpointsShape(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
for _, path := range []string{"/api/client/bootstrap", "/api/client/endpoints", "/api/client/sources", "/api/client/notices"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if path == "/api/client/sources" {
if payload["categories"] == nil {
t.Fatalf("%s missing categories: %#v", path, payload)
}
continue
}
if payload["ok"] != true {
t.Fatalf("%s missing ok=true: %#v", path, payload)
}
}
}
func TestBuiltFrontendAssetsAreServed(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
for _, item := range []struct {
Path string
ContentTypes []string
}{
{Path: "/assets/portal.css", ContentTypes: []string{"text/css"}},
{Path: "/assets/portal.js", ContentTypes: []string{"text/javascript", "application/javascript"}},
{Path: "/admin/assets/admin.css", ContentTypes: []string{"text/css"}},
{Path: "/admin/assets/admin.js", ContentTypes: []string{"text/javascript", "application/javascript"}},
} {
req := httptest.NewRequest(http.MethodGet, item.Path, nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("%s returned %d: %s", item.Path, res.Code, res.Body.String())
}
if got := res.Header().Get("Content-Type"); !containsAny(got, item.ContentTypes) {
t.Fatalf("%s content type = %q, want one of %v", item.Path, got, item.ContentTypes)
}
}
}
func containsAny(value string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
return true
}
}
return false
}
func TestReleaseNoticesRoutes(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
for _, path := range []string{"/api/client/notices", "/api/client/notices/2.0.0"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String())
}
}
req := httptest.NewRequest(http.MethodGet, "/api/client/releases", nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
var payload map[string]any
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["notices"] == nil {
t.Fatalf("release manifest missing notices: %#v", payload)
}
}
func TestAdminLegacyRequiresAuth(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodPut, "/api/admin/legacy/media-types", bytes.NewBufferString(`{"raw":"{}"}`))
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized, got %d", res.Code)
}
}
func testRouter(t *testing.T) (http.Handler, func()) {
t.Helper()
root := t.TempDir()
public := filepath.Join(root, "public")
noticeDir := filepath.Join(root, "update-notice")
if err := os.MkdirAll(filepath.Join(public, "downloads"), 0o755); err != nil {
t.Fatal(err)
}
adminDist := filepath.Join(root, "admin")
portalDist := filepath.Join(root, "portal")
for _, dir := range []string{filepath.Join(adminDist, "assets"), filepath.Join(portalDist, "assets")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(portalDist, "index.html"), []byte(`<!doctype html><link rel="stylesheet" href="/assets/portal.css"><script type="module" src="/assets/portal.js"></script>`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(portalDist, "assets", "portal.css"), []byte(`body{}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(portalDist, "assets", "portal.js"), []byte(`console.log("portal")`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(adminDist, "index.html"), []byte(`<!doctype html><link rel="stylesheet" href="/admin/assets/admin.css"><script type="module" src="/admin/assets/admin.js"></script>`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(adminDist, "assets", "admin.css"), []byte(`body{}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(adminDist, "assets", "admin.js"), []byte(`console.log("admin")`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(noticeDir, 0o755); err != nil {
t.Fatal(err)
}
mustWriteJSON(t, filepath.Join(public, "update-info.json"), map[string]any{"app_version": "0.0.1"})
mustWriteJSON(t, filepath.Join(public, "tool-status.json"), map[string]any{"ok": true})
mustWriteJSON(t, filepath.Join(public, "modules.json"), map[string]any{"modules": []any{}})
mustWriteJSON(t, filepath.Join(public, "media-types.json"), map[string]any{
"categories": []map[string]any{{
"id": "image", "name": "image",
"subcategories": []map[string]any{{"id": "demo", "name": "demo", "api_url": "https://example.com/demo"}},
}},
})
mustWriteJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{
"schema_version": 1,
"latest_version": "2.0.0",
"latest_notice_file": "2.0.0.json",
"latest": map[string]any{"version": "2.0.0", "title": "YMhut Box 2.0.0", "release_notes": "Initial release"},
"versions": []map[string]any{{"version": "2.0.0", "notice_file": "2.0.0.json", "summary": "Initial release"}},
})
mustWriteJSON(t, filepath.Join(noticeDir, "2.0.0.json"), map[string]any{"app_version": "2.0.0", "title": "YMhut Box 2.0.0", "release_notes": "Initial release", "release_notes_md": "## Initial"})
cfg := &config.Config{
Listen: ":0",
BaseURL: "https://update.ymhut.cn",
StorageDir: filepath.Join(root, "storage"),
UpdatePublicDir: public,
UpdateNoticeDir: noticeDir,
DownloadsDir: filepath.Join(public, "downloads"),
AdminWebDir: adminDist,
PortalWebDir: portalDist,
SourceCheckSeconds: 3600,
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
FailoverEnabled: true,
HotSyncEnabled: true,
HealthIntervalSec: 3600,
},
}
store, err := db.Open(cfg)
if err != nil {
t.Fatal(err)
}
if err := store.EnsureDefaultAdmin(context.Background()); err != nil {
t.Fatal(err)
}
sourceService := sources.NewService(cfg, store)
if err := sourceService.ImportLegacyMediaTypesIfEmpty(context.Background()); err != nil {
t.Fatal(err)
}
noticeService := notices.NewService(cfg, store)
if err := noticeService.Import(context.Background()); err != nil {
t.Fatal(err)
}
handler := NewRouter(
cfg,
store,
auth.NewService(store),
feedback.NewService(cfg, store),
releases.NewService(cfg, store, noticeService),
sourceService,
legacy.NewService(cfg, store),
noticeService,
)
return handler, func() { _ = store.Close() }
}
func mustWriteJSON(t *testing.T, path string, payload any) {
t.Helper()
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,264 @@
package web
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path/filepath"
"strconv"
"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 setupMySQLConfig `json:"mysql"`
}
type setupMySQLConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
Charset string `json:"charset"`
ParseTime bool `json:"parseTime"`
TLS string `json:"tls"`
}
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": r.cfg.BaseDir,
"configPath": r.cfg.ConfigPath,
"defaults": map[string]any{
"provider": firstNonEmpty(r.cfg.Database.Provider, "sqlite"),
"sqlitePath": relativeToBase(r.cfg.BaseDir, r.cfg.Database.SQLitePath),
"mysqlDsn": 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": 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
}
next := r.cfg.Database
next.Provider = strings.ToLower(strings.TrimSpace(firstNonEmpty(body.Provider, next.Provider, "sqlite")))
if body.SQLitePath != "" {
next.SQLitePath = body.SQLitePath
}
if next.SQLitePath != "" && !filepath.IsAbs(next.SQLitePath) && !strings.HasPrefix(strings.ToLower(next.SQLitePath), "file:") {
next.SQLitePath = filepath.Join(r.cfg.BaseDir, next.SQLitePath)
}
if next.Provider == "sqlite" {
next.MySQLDSN = ""
} else if body.MySQLDSN != "" {
next.MySQLDSN = body.MySQLDSN
} else if body.MySQL.Host != "" || body.MySQL.Database != "" || body.MySQL.Username != "" {
dsn, err := buildMySQLDSN(body.MySQL)
if err != nil {
return config.DatabaseConfig{}, body, err
}
next.MySQLDSN = dsn
}
if next.Provider != "sqlite" && next.Provider != "mysql" {
return config.DatabaseConfig{}, body, errors.New("provider must be sqlite or mysql")
}
if next.Provider == "mysql" && strings.TrimSpace(next.MySQLDSN) == "" {
return config.DatabaseConfig{}, body, errors.New("mysql connection is required")
}
if next.MaxOpenConns <= 0 {
next.MaxOpenConns = 10
}
if next.MaxIdleConns <= 0 {
next.MaxIdleConns = 4
}
if next.ConnMaxLifetimeSeconds <= 0 {
next.ConnMaxLifetimeSeconds = 300
}
if next.HealthIntervalSec <= 0 {
next.HealthIntervalSec = 30
}
return next, body, nil
}
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 buildMySQLDSN(input setupMySQLConfig) (string, error) {
host := strings.TrimSpace(input.Host)
if host == "" {
host = "127.0.0.1"
}
port := input.Port
if port <= 0 {
port = 3306
}
database := strings.TrimSpace(input.Database)
username := strings.TrimSpace(input.Username)
if database == "" {
return "", errors.New("mysql database is required")
}
if username == "" {
return "", errors.New("mysql username is required")
}
params := url.Values{}
params.Set("charset", firstNonEmpty(strings.TrimSpace(input.Charset), "utf8mb4"))
params.Set("parseTime", strconv.FormatBool(input.ParseTime))
if tls := strings.TrimSpace(input.TLS); tls != "" {
params.Set("tls", tls)
}
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s", username, input.Password, host, port, database, params.Encode()), nil
}
func maskDSN(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
at := strings.Index(value, "@")
colon := strings.Index(value, ":")
if at > -1 && colon > -1 && colon < at {
return value[:colon+1] + "******" + value[at:]
}
return value
}
func maskedDatabaseTarget(base string, cfg config.DatabaseConfig) string {
if strings.EqualFold(cfg.Provider, "mysql") {
return 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)
}
@@ -0,0 +1,132 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"ymhut-box/server/unified-management/internal/config"
)
func TestSetupRouterServesBuiltAssetsAndBlocksBusinessAPI(t *testing.T) {
root := t.TempDir()
setupDist := filepath.Join(root, "setup")
if err := os.MkdirAll(filepath.Join(setupDist, "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(setupDist, "index.html"), []byte(`<!doctype html><script type="module" src="/setup/assets/setup.js"></script><link rel="stylesheet" href="/setup/assets/setup.css">`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(setupDist, "assets", "setup.css"), []byte(`body{}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(setupDist, "assets", "setup.js"), []byte(`console.log("setup")`), 0o644); err != nil {
t.Fatal(err)
}
handler := NewSetupRouter(setupConfig(root, setupDist))
for _, item := range []struct {
path string
want int
typ string
}{
{"/setup", http.StatusOK, "text/html"},
{"/setup/assets/setup.css", http.StatusOK, "text/css"},
{"/setup/assets/setup.js", http.StatusOK, "javascript"},
{"/api/client/bootstrap", http.StatusServiceUnavailable, "application/json"},
} {
req := httptest.NewRequest(http.MethodGet, item.path, nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != item.want {
t.Fatalf("%s returned %d: %s", item.path, res.Code, res.Body.String())
}
if got := res.Header().Get("Content-Type"); !strings.Contains(got, item.typ) {
t.Fatalf("%s content-type = %q, want %q", item.path, got, item.typ)
}
}
}
func TestSetupSQLiteCompleteCreatesConfigAndDefaultAdmin(t *testing.T) {
root := t.TempDir()
handler := NewSetupRouter(setupConfig(root, filepath.Join(root, "missing-setup-dist")))
body := bytes.NewBufferString(`{"provider":"sqlite","baseUrl":"https://update.ymhut.cn","sqlitePath":"storage/unified.sqlite"}`)
req := httptest.NewRequest(http.MethodPost, "/api/setup/complete", body)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("complete returned %d: %s", res.Code, res.Body.String())
}
data, err := os.ReadFile(filepath.Join(root, "config.json"))
if err != nil {
t.Fatal(err)
}
var cfg config.Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
if !cfg.Initialized || cfg.Database.Provider != "sqlite" {
t.Fatalf("unexpected config: %#v", cfg)
}
if _, err := os.Stat(filepath.Join(root, "storage", "unified.sqlite")); err != nil {
t.Fatal(err)
}
}
func TestSetupSQLiteIgnoresStructuredMySQLDefaults(t *testing.T) {
root := t.TempDir()
handler := NewSetupRouter(setupConfig(root, filepath.Join(root, "missing-setup-dist")))
body := bytes.NewBufferString(`{"provider":"sqlite","baseUrl":"https://update.ymhut.cn","sqlitePath":"storage/unified.sqlite","mysql":{"host":"127.0.0.1","port":3306,"database":"ymhut_unified","username":"","password":"","charset":"utf8mb4","parseTime":true,"tls":"false"}}`)
req := httptest.NewRequest(http.MethodPost, "/api/setup/database/test", body)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("sqlite test returned %d: %s", res.Code, res.Body.String())
}
if strings.Contains(res.Body.String(), "mysql username is required") {
t.Fatalf("sqlite test should ignore structured mysql defaults: %s", res.Body.String())
}
}
func TestSetupStructuredMySQLValidationReturnsFailureWithoutSaving(t *testing.T) {
root := t.TempDir()
handler := NewSetupRouter(setupConfig(root, filepath.Join(root, "missing-setup-dist")))
body := bytes.NewBufferString(`{"provider":"mysql","mysql":{"host":"127.0.0.1","port":1,"database":"ymhut","username":"root","password":"secret","charset":"utf8mb4","parseTime":true,"tls":"false"}}`)
req := httptest.NewRequest(http.MethodPost, "/api/setup/database/test", body)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusBadGateway {
t.Fatalf("mysql test returned %d, want 502: %s", res.Code, res.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "config.json")); !os.IsNotExist(err) {
t.Fatalf("config should not be written on failed test: %v", err)
}
if strings.Contains(res.Body.String(), "secret") {
t.Fatalf("response leaked password: %s", res.Body.String())
}
}
func setupConfig(root, setupDist string) *config.Config {
return &config.Config{
BaseDir: root,
ConfigPath: filepath.Join(root, "config.json"),
BaseURL: "https://update.ymhut.cn",
StorageDir: filepath.Join(root, "storage"),
SetupWebDir: setupDist,
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
MaxOpenConns: 1,
MaxIdleConns: 1,
},
}
}