继续更新 update 门户站点界面和功能
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 20:17:34 +08:00
parent f525e5f3ba
commit 2513eb2903
68 changed files with 5586 additions and 3195 deletions
@@ -0,0 +1,139 @@
package web
import (
"encoding/csv"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"ymhut-box/server/unified-management/internal/db"
)
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)
}
@@ -0,0 +1,90 @@
package web
import (
"encoding/json"
"errors"
"net/http"
"strings"
"ymhut-box/server/unified-management/internal/legacy"
)
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)
}
@@ -0,0 +1,143 @@
package web
import (
"encoding/json"
"errors"
"net/http"
"strings"
"ymhut-box/server/unified-management/internal/notices"
"ymhut-box/server/unified-management/internal/releases"
)
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)
}
@@ -0,0 +1,70 @@
package web
import (
"encoding/json"
"errors"
"net/http"
"strings"
"ymhut-box/server/unified-management/internal/db"
)
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":
job := r.sources.QueueCheckAll()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": true, "jobId": job.ID, "job": job})
case req.Method == http.MethodGet && path == "/api/admin/sources/check/status":
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": r.sources.CheckJobs()})
case req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/sources/check/status/"):
jobID := strings.TrimPrefix(path, "/api/admin/sources/check/status/")
if job, ok := r.sources.CheckJob(jobID); ok {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": job})
return
}
writeError(w, http.StatusNotFound, "CHECK_JOB_NOT_FOUND", errors.New("check job not found"))
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)
}
}
@@ -0,0 +1,164 @@
package web
import (
"encoding/json"
"errors"
"net/http"
"time"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
"ymhut-box/server/unified-management/internal/health"
)
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) handleAdminEvents(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "SSE_UNSUPPORTED", errors.New("streaming is not supported"))
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
events, unsubscribe := r.sources.SubscribeEvents()
defer unsubscribe()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
writeSSE(w, "ready", map[string]any{"ok": true, "time": time.Now().UTC().Format(time.RFC3339)})
flusher.Flush()
for {
select {
case event, ok := <-events:
if !ok {
return
}
writeSSE(w, event.Type, event.Data)
flusher.Flush()
case <-ticker.C:
writeSSE(w, "heartbeat", map[string]any{"time": time.Now().UTC().Format(time.RFC3339)})
flusher.Flush()
case <-req.Context().Done():
return
}
}
}
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
}
result, err := r.store.ImportSQLiteToRemote()
if err != nil {
writeError(w, http.StatusBadRequest, "SYNC_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result, "finishedAt": result.FinishedAt})
default:
http.NotFound(w, req)
}
}
@@ -0,0 +1,93 @@
package web
import (
"net/http"
"strings"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/health"
"ymhut-box/server/unified-management/internal/notices"
)
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})
}
@@ -0,0 +1,42 @@
package web
import (
"strings"
"ymhut-box/server/unified-management/internal/db"
)
type legacyFeedbackStatusDTO struct {
OK bool `json:"ok"`
Code string `json:"code"`
Status string `json:"status"`
StatusLabel string `json:"statusLabel"`
StatusDetail string `json:"statusDetail"`
Category string `json:"category"`
Priority string `json:"priority"`
HasReply bool `json:"hasReply"`
Reply string `json:"reply"`
ReceivedAt string `json:"receivedAt"`
UpdatedAt string `json:"updatedAt"`
MailSent bool `json:"mailSent"`
Duplicate bool `json:"duplicate,omitempty"`
}
func legacyFeedbackStatus(item db.Feedback, duplicate bool) legacyFeedbackStatusDTO {
reply := strings.TrimSpace(item.PublicReply)
return legacyFeedbackStatusDTO{
OK: true,
Code: item.Code,
Status: firstNonEmpty(item.Status, "new"),
StatusLabel: feedbackStatusLabel(item.Status),
StatusDetail: item.StatusDetail,
Category: item.Category,
Priority: item.Priority,
HasReply: reply != "",
Reply: reply,
ReceivedAt: item.CreatedAt,
UpdatedAt: firstNonEmpty(item.LastActivityAt, item.UpdatedAt, item.CreatedAt),
MailSent: item.MailSent,
Duplicate: duplicate,
}
}
@@ -0,0 +1,77 @@
package web
import (
"encoding/json"
"errors"
"net/http"
"strings"
"ymhut-box/server/unified-management/internal/db"
"ymhut-box/server/unified-management/internal/feedback"
)
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 {
code, status := feedback.LegacyError(err)
_ = r.store.InsertAudit(db.AuditLog{Actor: "client", Type: "feedback.rejected", Target: "feedback", Message: "旧反馈提交失败:" + localizedErrorMessage(code, err.Error()), IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeError(w, status, code, 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, legacyFeedbackStatus(item, feedback.DuplicateSubmission(req)))
}
func (r *router) handleFeedbackStatus(w http.ResponseWriter, req *http.Request) {
code := feedback.NormalizeCode(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, legacyFeedbackStatus(item, false))
}
func feedbackStatusLabel(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "processing", "in_progress":
return "处理中"
case "closed", "resolved", "done":
return "已关闭"
case "rejected":
return "已驳回"
default:
return "已接收"
}
}
@@ -0,0 +1,166 @@
package web
import (
"encoding/json"
"net/http"
"strings"
)
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 writeSSE(w http.ResponseWriter, event string, payload any) {
data, _ := json.Marshal(payload)
_, _ = w.Write([]byte("event: " + event + "\n"))
_, _ = w.Write([]byte("data: " + string(data) + "\n\n"))
}
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": localizedErrorMessage(code, message)})
}
func localizedErrorMessage(code, message string) string {
raw := strings.TrimSpace(message)
lower := strings.ToLower(raw)
exact := map[string]string{
"current password is invalid": "当前密码不正确",
"new password is required": "新密码不能为空",
"new password must be at least 8 characters": "新密码至少需要 8 位",
"new password cannot be admin": "新密码不能为 admin",
"new password must be different from current password": "新密码不能与当前密码相同",
"invalid password or captcha": "密码或验证码不正确",
"login required": "需要登录后继续操作",
"csrf token required": "页面安全令牌已失效,请刷新后重试",
"csrf token invalid": "页面安全令牌无效,请刷新后重试",
"code is required": "缺少反馈编号",
"revisionid is required": "请选择要恢复的历史版本",
"post required": "该操作需要使用 POST 请求",
"get required": "该操作需要使用 GET 请求",
"file is required": "请选择要上传的文件",
"invalid filename": "文件名不合法",
"path escape rejected": "文件路径不合法",
"check job not found": "未找到心跳检测任务",
"streaming is not supported": "当前运行环境不支持实时事件流",
"source api_url is empty": "接口地址不能为空",
"database is not available": "数据库当前不可用",
"provider must be sqlite or mysql": "数据库类型必须是 SQLite 或 MySQL",
"mysql connection is required": "请填写 MySQL 连接信息",
"sqlite path is required": "请填写 SQLite 路径",
"mysql_dsn is required": "请填写 MySQL DSN",
"release notices are not configured": "版本日志功能尚未配置",
"legacy sync service is not configured": "旧项目同步服务尚未配置",
}
if translated, ok := exact[lower]; ok {
return translated
}
byCode := map[string]string{
"UNAUTHORIZED": "需要登录后继续操作",
"LOGIN_FAILED": "登录失败,请检查密码和验证码",
"PASSWORD_CHANGE_FAILED": "密码修改失败",
"INVALID_PAYLOAD": "提交内容格式不正确",
"DATABASE_TEST_FAILED": "数据库连接测试失败",
"DATABASE_IMPORT_FAILED": "SQLite 导入远端库失败",
"DATABASE_SYNC_FAILED": "远端库同步回本地失败",
"LEGACY_SAVE_FAILED": "兼容 JSON 保存失败",
"LEGACY_VALIDATE_FAILED": "兼容 JSON 校验失败",
"LEGACY_RESTORE_FAILED": "兼容 JSON 恢复失败",
"NOTICE_SAVE_FAILED": "版本日志保存失败",
"NOTICE_VALIDATE_FAILED": "版本日志校验失败",
"NOTICE_RESTORE_FAILED": "版本日志恢复失败",
"PACKAGE_UPLOAD_FAILED": "发布包上传失败",
"SOURCE_SAVE_FAILED": "接口源保存失败",
"CHECK_FAILED": "接口健康检测失败",
"SYNC_FAILED": "同步操作失败",
"FORBIDDEN": "没有权限执行该操作",
"METHOD_NOT_ALLOWED": "请求方法不正确",
"FILE_REQUIRED": "请选择要上传的文件",
"CHECK_JOB_NOT_FOUND": "未找到心跳检测任务",
"SSE_UNSUPPORTED": "当前运行环境不支持实时事件流",
"SOURCES_FAILED": "接口源数据加载失败",
"ENDPOINTS_FAILED": "客户端接口数据加载失败",
"DASHBOARD_FAILED": "仪表盘数据加载失败",
"AUDIT_FAILED": "审计日志加载失败",
"FEEDBACK_LIST_FAILED": "反馈列表加载失败",
"FEEDBACK_UPDATE_FAILED": "反馈工单更新失败",
"NOTICE_NOT_FOUND": "未找到版本日志",
"NOTICES_FAILED": "版本日志加载失败",
"MEDIA_TYPES_FAILED": "媒体源 JSON 加载失败",
"SOURCE_CALL_FAILED": "接口调用状态上报失败",
"IMPORT_FAILED": "导入失败",
"PATH_FAILED": "路径解析失败",
"INVALID_UPLOAD": "上传内容不正确",
"BOOTSTRAP_FAILED": "后台初始化信息加载失败",
"CAPTCHA_FAILED": "验证码加载失败",
"TOO_LARGE": "反馈包过大",
"MISSING_FIELD": "缺少旧反馈提交字段",
"INVALID_TIMESTAMP": "反馈提交时间已过期",
"INVALID_SIGNATURE": "反馈签名校验失败",
"INVALID_PACKAGE": "反馈包格式不正确",
"INVALID_ENCRYPTED_PACKAGE": "反馈加密包格式不正确",
"DECRYPT_FAILED": "反馈包解密失败",
"HASH_MISMATCH": "反馈包哈希校验失败",
"SERVER_CONFIG": "反馈服务配置异常",
}
if translated, ok := byCode[code]; ok {
if raw == "" || strings.EqualFold(raw, code) {
return translated
}
return translated + "" + raw
}
if raw == "" {
return "操作失败"
}
return raw
}
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 ""
}
@@ -1,29 +1,20 @@
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 {
@@ -169,16 +160,16 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
if body.Username == "" {
body.Username = "admin"
}
sessionID, csrf, ok, err := r.auth.Login(req.Context(), body.Username, body.Password, body.CaptchaID, body.Captcha)
sessionID, csrf, ok, err := r.auth.Login(req.Context(), body.Username, body.Password, body.CaptchaID, body.Captcha, req.RemoteAddr)
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"))
writeError(w, http.StatusOK, "LOGIN_FAILED", errors.New("invalid password or captcha"))
return
}
auth.SetSessionCookie(w, sessionID)
auth.SetSessionCookieForRequest(w, req, 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}})
}
@@ -209,854 +200,3 @@ func (r *router) handleChangePassword(w http.ResponseWriter, req *http.Request)
}
writeJSON(w, http.StatusOK, payload)
}
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) handleAdminEvents(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "SSE_UNSUPPORTED", errors.New("streaming is not supported"))
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
events := r.sources.Events()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
writeSSE(w, "ready", map[string]any{"ok": true, "time": time.Now().UTC().Format(time.RFC3339)})
flusher.Flush()
for {
select {
case event := <-events:
writeSSE(w, event.Type, event.Data)
flusher.Flush()
case <-ticker.C:
writeSSE(w, "heartbeat", map[string]any{"time": time.Now().UTC().Format(time.RFC3339)})
flusher.Flush()
case <-req.Context().Done():
return
}
}
}
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":
job := r.sources.QueueCheckAll()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "queued": true, "jobId": job.ID, "job": job})
case req.Method == http.MethodGet && path == "/api/admin/sources/check/status":
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": r.sources.CheckJobs()})
case req.Method == http.MethodGet && strings.HasPrefix(path, "/api/admin/sources/check/status/"):
jobID := strings.TrimPrefix(path, "/api/admin/sources/check/status/")
if job, ok := r.sources.CheckJob(jobID); ok {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "job": job})
return
}
writeError(w, http.StatusNotFound, "CHECK_JOB_NOT_FOUND", errors.New("check job not found"))
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 writeSSE(w http.ResponseWriter, event string, payload any) {
data, _ := json.Marshal(payload)
_, _ = w.Write([]byte("event: " + event + "\n"))
_, _ = w.Write([]byte("data: " + string(data) + "\n\n"))
}
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 ""
}
@@ -1,15 +1,28 @@
package web
import (
"archive/zip"
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"image/color"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"ymhut-box/server/unified-management/internal/auth"
"ymhut-box/server/unified-management/internal/config"
@@ -25,7 +38,7 @@ 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"} {
for _, path := range []string{"/api/client/bootstrap", "/update-info.json", "/update-info", "/tool-status.json", "/tool-status", "/media-types.json", "/media-types", "/modules.json", "/modules", "/api/modules"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
@@ -39,6 +52,63 @@ func TestCompatibilityRoutes(t *testing.T) {
}
}
func TestLegacyPublicContractFields(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
cases := []struct {
Path string
RequiredKeys []string
}{
{Path: "/update-info.json", RequiredKeys: []string{"app_version", "manifest_version", "packages", "modules"}},
{Path: "/update-info", RequiredKeys: []string{"app_version", "manifest_version", "packages", "modules"}},
{Path: "/tool-status.json", RequiredKeys: []string{"ok"}},
{Path: "/tool-status", RequiredKeys: []string{"ok"}},
{Path: "/modules.json", RequiredKeys: []string{"modules"}},
{Path: "/modules", RequiredKeys: []string{"modules"}},
{Path: "/api/modules", RequiredKeys: []string{"modules"}},
{Path: "/media-types.json", RequiredKeys: []string{"layout_version", "last_updated", "categories"}},
{Path: "/media-types", RequiredKeys: []string{"layout_version", "last_updated", "categories"}},
}
for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, tc.Path, nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("%s returned %d: %s", tc.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", tc.Path, err)
}
assertJSONKeys(t, tc.Path, payload, tc.RequiredKeys)
}
req := httptest.NewRequest(http.MethodGet, "/downloads/fixture.txt", nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("download returned %d: %s", res.Code, res.Body.String())
}
if strings.TrimSpace(res.Body.String()) != "download fixture" {
t.Fatalf("unexpected download body: %q", res.Body.String())
}
}
func TestDownloadRejectsPathEscape(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
for _, path := range []string{"/downloads/../update-info.json", "/downloads/%2e%2e/update-info.json", "/downloads/foo\\bar.exe"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusForbidden && res.Code != http.StatusNotFound {
t.Fatalf("%s returned %d, want forbidden or not found: %s", path, res.Code, res.Body.String())
}
}
}
func TestClientBootstrapAndEndpointsShape(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
@@ -66,6 +136,193 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) {
}
}
func TestLegacyFeedbackStatusDTOContract(t *testing.T) {
payload := legacyFeedbackStatus(db.Feedback{
Code: "FB-20260626-ABCDEF",
Status: "processing",
StatusDetail: "公开进度",
Category: "issue",
Priority: "normal",
PublicReply: "公开回复",
Note: "内部备注",
Assignee: "owner",
HandledBy: "admin",
Attachment: "private.zip",
PackagePath: "storage/feedback/private.zip",
EncryptedPackagePath: "storage/feedback/private.ymfb",
MailSent: true,
CreatedAt: "2026-06-26T00:00:00Z",
UpdatedAt: "2026-06-26T00:10:00Z",
LastActivityAt: "2026-06-26T00:20:00Z",
}, true)
data, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
t.Fatal(err)
}
assertJSONKeys(t, "legacy feedback status", out, []string{"ok", "code", "status", "statusLabel", "statusDetail", "category", "priority", "hasReply", "reply", "receivedAt", "updatedAt", "mailSent", "duplicate"})
for _, privateKey := range []string{"note", "assignee", "handledBy", "attachments", "events", "legacyEvents", "mailRecords", "path", "attachment", "packagePath", "encryptedPackagePath", "packageSha256", "plainPackageSha256"} {
if _, ok := out[privateKey]; ok {
t.Fatalf("legacy DTO leaked private key %q: %#v", privateKey, out)
}
}
}
func TestLegacyFeedbackPublicStatusShape(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"title":"旧版反馈","type":"issue","severity":"normal","body":"客户端反馈内容"}`))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("submit returned %d: %s", res.Code, res.Body.String())
}
var submitted map[string]any
if err := json.Unmarshal(res.Body.Bytes(), &submitted); err != nil {
t.Fatal(err)
}
code, _ := submitted["code"].(string)
if code == "" || submitted["statusLabel"] == nil || submitted["feedback"] != nil {
t.Fatalf("unexpected submit payload: %#v", submitted)
}
req = httptest.NewRequest(http.MethodGet, "/?api=status&code="+code, nil)
res = httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status returned %d: %s", res.Code, res.Body.String())
}
var status map[string]any
if err := json.Unmarshal(res.Body.Bytes(), &status); err != nil {
t.Fatal(err)
}
if status["code"] != code || status["statusLabel"] == nil || status["feedback"] != nil {
t.Fatalf("unexpected status payload: %#v", status)
}
for _, privateKey := range []string{"note", "assignee", "handledBy", "attachment", "attachments", "path", "packagePath", "encryptedPackagePath", "events", "legacyEvents", "mailRecords", "packageSha256", "plainPackageSha256"} {
if _, ok := status[privateKey]; ok {
t.Fatalf("status leaked private key %q: %#v", privateKey, status)
}
}
}
func TestLegacyFeedbackMultipartFallback(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("subject", "Multipart legacy feedback")
_ = writer.WriteField("category", "issue")
_ = writer.WriteField("priority", "normal")
_ = writer.WriteField("email", "user@example.com")
_ = writer.WriteField("message", "Submitted by an old multipart client.")
if part, err := writer.CreateFormFile("ignored", "note.txt"); err == nil {
_, _ = io.WriteString(part, "not signed, should fall back")
}
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("multipart submit returned %d: %s", res.Code, res.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["code"] == "" || payload["feedback"] != nil {
t.Fatalf("unexpected multipart payload: %#v", payload)
}
}
func TestLegacyFeedbackSignedEncryptedMultipartRoute(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
plain := routeZipBytes(t, map[string]string{
"feedback.json": `{"request":{"title":"Signed route feedback","type":"issue","severity":"major","contact":"user@example.com","body":"Signed package body."}}`,
"summary.txt": "signed route summary",
})
encrypted := routeEncryptPackage(t, plain, "ymhut-box-feedback-package-v1")
encryptedHash := routeSHA256Hex(encrypted)
plainHash := routeSHA256Hex(plain)
payloadData, err := json.Marshal(map[string]any{
"feedbackCode": "FB-20260626-ABC123",
"title": "Signed route feedback",
"type": "issue",
"severity": "major",
"contact": "user@example.com",
"bodyLength": 20,
"packageEncrypted": true,
"encryption": feedback.PackageMagic,
"packageBytes": len(encrypted),
"packageSha256": encryptedHash,
"plainPackageBytes": len(plain),
"plainPackageSha256": plainHash,
"createdAt": "2026-06-26T00:00:00Z",
})
if err != nil {
t.Fatal(err)
}
payload := string(payloadData)
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("payload", payload)
_ = writer.WriteField("timestamp", timestamp)
_ = writer.WriteField("nonce", "route-test")
_ = writer.WriteField("packageSha256", encryptedHash)
_ = writer.WriteField("signature", feedback.SignWithKey("ymhut-box-feedback-client-v1", timestamp, "route-test", encryptedHash, payload))
part, err := writer.CreateFormFile("package", "feedback.ymfb")
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(part, bytes.NewReader(encrypted))
_ = writer.Close()
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("signed multipart submit returned %d: %s", res.Code, res.Body.String())
}
var submitted map[string]any
if err := json.Unmarshal(res.Body.Bytes(), &submitted); err != nil {
t.Fatal(err)
}
if submitted["code"] != "FB-20260626-ABC123" || submitted["duplicate"] != nil {
t.Fatalf("unexpected signed submit payload: %#v", submitted)
}
req = httptest.NewRequest(http.MethodGet, "/?api=status&code=FB-20260626-ABC123", nil)
res = httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("signed status returned %d: %s", res.Code, res.Body.String())
}
var status map[string]any
if err := json.Unmarshal(res.Body.Bytes(), &status); err != nil {
t.Fatal(err)
}
if status["code"] != "FB-20260626-ABC123" || status["statusLabel"] == nil || status["reply"] == nil {
t.Fatalf("unexpected signed status payload: %#v", status)
}
for _, privateKey := range []string{"note", "assignee", "handledBy", "attachments", "events", "mailRecords", "packagePath", "encryptedPackagePath", "path"} {
if _, ok := status[privateKey]; ok {
t.Fatalf("signed status leaked private key %q: %#v", privateKey, status)
}
}
}
func TestBuiltFrontendAssetsAreServed(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
@@ -91,6 +348,23 @@ func TestBuiltFrontendAssetsAreServed(t *testing.T) {
}
}
func TestAdminSystemAndLegacyAdminPagesServeSPA(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
for _, path := range []string{"/admin/system", "/admin/database", "/admin/health", "/admin/settings", "/admin/audit"} {
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())
}
if !strings.Contains(res.Body.String(), "/admin/assets/admin.js") {
t.Fatalf("%s did not serve admin SPA shell: %s", path, res.Body.String())
}
}
}
func containsAny(value string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
@@ -100,6 +374,15 @@ func containsAny(value string, needles []string) bool {
return false
}
func assertJSONKeys(t *testing.T, label string, payload map[string]any, keys []string) {
t.Helper()
for _, key := range keys {
if _, ok := payload[key]; !ok {
t.Fatalf("%s missing key %q: %#v", label, key, payload)
}
}
}
func TestReleaseNoticesRoutes(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
@@ -136,6 +419,129 @@ func TestAdminLegacyRequiresAuth(t *testing.T) {
}
}
func TestAdminWriteRequiresCSRF(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
session, _, err := loginForTest(handler)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/admin/sources/check", bytes.NewBufferString(`{}`))
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusForbidden {
t.Fatalf("expected forbidden without csrf, got %d: %s", res.Code, res.Body.String())
}
}
func loginForTest(handler http.Handler) (string, string, error) {
captchaReq := httptest.NewRequest(http.MethodGet, "/api/admin/auth/captcha", nil)
captchaRes := httptest.NewRecorder()
handler.ServeHTTP(captchaRes, captchaReq)
if captchaRes.Code != http.StatusOK {
return "", "", errors.New(captchaRes.Body.String())
}
var captchaPayload struct {
CaptchaID string `json:"captchaId"`
Image string `json:"image"`
}
if err := json.Unmarshal(captchaRes.Body.Bytes(), &captchaPayload); err != nil {
return "", "", err
}
answer, err := readTestCaptcha(captchaPayload.Image)
if err != nil {
return "", "", err
}
loginBody, _ := json.Marshal(map[string]string{
"username": "admin",
"password": "admin",
"captchaId": captchaPayload.CaptchaID,
"captcha": answer,
})
loginReq := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", bytes.NewReader(loginBody))
loginReq.Header.Set("Content-Type", "application/json")
loginRes := httptest.NewRecorder()
handler.ServeHTTP(loginRes, loginReq)
if loginRes.Code != http.StatusOK {
return "", "", errors.New(loginRes.Body.String())
}
var loginPayload struct {
OK bool `json:"ok"`
CSRFToken string `json:"csrfToken"`
Message string `json:"message"`
}
if err := json.Unmarshal(loginRes.Body.Bytes(), &loginPayload); err != nil {
return "", "", err
}
if !loginPayload.OK {
return "", "", errors.New(loginPayload.Message)
}
for _, cookie := range loginRes.Result().Cookies() {
if cookie.Name == auth.SessionCookie {
return cookie.Value, loginPayload.CSRFToken, nil
}
}
return "", "", errors.New("session cookie not set")
}
func readTestCaptcha(dataURL string) (string, error) {
const prefix = "data:image/png;base64,"
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix))
if err != nil {
return "", err
}
img, err := png.Decode(bytes.NewReader(raw))
if err != nil {
return "", err
}
var builder strings.Builder
for index := 0; index < 5; index++ {
x := 18 + index*32
y := 13
mask := [7]bool{
isCaptchaInk(img.At(x+11, y+2)),
isCaptchaInk(img.At(x+20, y+12)),
isCaptchaInk(img.At(x+20, y+28)),
isCaptchaInk(img.At(x+11, y+34)),
isCaptchaInk(img.At(x+2, y+28)),
isCaptchaInk(img.At(x+2, y+12)),
isCaptchaInk(img.At(x+11, y+18)),
}
digit := -1
for candidate, segments := range testCaptchaSegments {
if segments == mask {
digit = candidate
break
}
}
if digit < 0 {
return "", errors.New("captcha digit could not be read")
}
builder.WriteByte(byte('0' + digit))
}
return builder.String(), nil
}
func isCaptchaInk(colorValue color.Color) bool {
r, g, b, _ := colorValue.RGBA()
return r>>8 < 80 && g>>8 < 100 && b>>8 < 130
}
var testCaptchaSegments = [10][7]bool{
{true, true, true, true, true, true, false},
{false, true, true, false, false, false, false},
{true, true, false, true, true, false, true},
{true, true, true, true, false, false, true},
{false, true, true, false, false, true, true},
{true, false, true, true, false, true, true},
{true, false, true, true, true, true, true},
{true, true, true, false, false, false, false},
{true, true, true, true, true, true, true},
{true, true, true, true, false, true, true},
}
func testRouter(t *testing.T) (http.Handler, func()) {
t.Helper()
root := t.TempDir()
@@ -181,6 +587,9 @@ func testRouter(t *testing.T) (http.Handler, func()) {
"subcategories": []map[string]any{{"id": "demo", "name": "demo", "api_url": "https://example.com/demo"}},
}},
})
if err := os.WriteFile(filepath.Join(public, "downloads", "fixture.txt"), []byte("download fixture\n"), 0o644); err != nil {
t.Fatal(err)
}
mustWriteJSON(t, filepath.Join(noticeDir, "total.json"), map[string]any{
"schema_version": 1,
"latest_version": "2.0.0",
@@ -190,15 +599,20 @@ func testRouter(t *testing.T) (http.Handler, func()) {
})
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,
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,
ClientSignatureKey: "ymhut-box-feedback-client-v1",
PackageEncryptionKey: "ymhut-box-feedback-package-v1",
TimestampWindowSeconds: 600,
MaxRequestBytes: 12 << 20,
MaxPackageBytes: 10 << 20,
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
@@ -206,6 +620,7 @@ func testRouter(t *testing.T) (http.Handler, func()) {
HotSyncEnabled: true,
HealthIntervalSec: 3600,
},
UploadGuard: config.UploadGuardConfig{MaxZipFiles: 80, MaxDecompressedBytes: 30 << 20, MaxSingleFileBytes: 8 << 20, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 << 10, AllowUnexpectedZipFiles: true},
}
store, err := db.Open(cfg)
if err != nil {
@@ -235,6 +650,50 @@ func testRouter(t *testing.T) (http.Handler, func()) {
return handler, func() { _ = store.Close() }
}
func routeZipBytes(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
writer := zip.NewWriter(&buf)
for name, body := range files {
entry, err := writer.Create(name)
if err != nil {
t.Fatal(err)
}
_, _ = entry.Write([]byte(body))
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func routeEncryptPackage(t *testing.T, plain []byte, keyMaterial string) []byte {
t.Helper()
key := sha256.Sum256([]byte(keyMaterial))
block, err := aes.NewCipher(key[:])
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
nonce := []byte("123456789012")
sealed := gcm.Seal(nil, nonce, plain, []byte(feedback.PackageMagic))
ciphertext := sealed[:len(sealed)-gcm.Overhead()]
tag := sealed[len(sealed)-gcm.Overhead():]
out := []byte(feedback.PackageMagic)
out = append(out, nonce...)
out = append(out, tag...)
out = append(out, ciphertext...)
return out
}
func routeSHA256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func mustWriteJSON(t *testing.T, path string, payload any) {
t.Helper()
data, err := json.Marshal(payload)
@@ -68,8 +68,8 @@ 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,
"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),
@@ -0,0 +1,119 @@
package web
import (
"bytes"
"errors"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"time"
webassets "ymhut-box/server/unified-management/web"
)
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
}
}