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: "管理员登录", 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: "后台密码已修改", 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/packages": if req.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required")) return } if err := req.ParseMultipartForm(256 << 20); err != nil { writeError(w, http.StatusBadRequest, "INVALID_UPLOAD", err) return } file, header, err := req.FormFile("file") if err != nil { writeError(w, http.StatusBadRequest, "FILE_REQUIRED", err) return } defer file.Close() pkg, err := r.releases.SaveUploadedPackage(req, file, releases.UploadOptions{ FileName: firstNonEmpty(req.FormValue("fileName"), header.Filename), Version: req.FormValue("version"), Platform: req.FormValue("platform"), Arch: req.FormValue("arch"), Channel: req.FormValue("channel"), Notes: req.FormValue("notes"), UpdateManifest: req.FormValue("updateManifest") == "true" || req.FormValue("updateManifest") == "1", }, "admin") if err != nil { writeError(w, http.StatusBadRequest, "PACKAGE_UPLOAD_FAILED", err) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg}) 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(`YMhut Box

YMhut Box

Unified management service is running.

Client bootstrap | Admin

`)) } 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(`YMhut Admin

YMhut Admin

Build web/admin to enable the Vue console.

`)) } 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 "" }