1009 lines
36 KiB
Go
1009 lines
36 KiB
Go
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(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Box</title></head><body><main><h1>YMhut Box</h1><p>Unified management service is running.</p><p><a href="/api/client/bootstrap">Client bootstrap</a> | <a href="/admin/login">Admin</a></p></main></body></html>`))
|
|
}
|
|
|
|
func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
|
|
index := filepath.Join(r.cfg.AdminWebDir, "index.html")
|
|
if _, err := os.Stat(index); err == nil {
|
|
http.ServeFile(w, req, index)
|
|
return
|
|
}
|
|
if serveEmbeddedFile(w, req, "admin/dist/index.html") {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Admin</title></head><body><main><h1>YMhut Admin</h1><p>Build web/admin to enable the Vue console.</p></main></body></html>`))
|
|
}
|
|
|
|
func isPortalRoute(path string) bool {
|
|
switch path {
|
|
case "/", "/releases", "/sources", "/feedback", "/compatibility":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func withSecurity(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Referrer-Policy", "same-origin")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, code string, err error) {
|
|
message := ""
|
|
if err != nil {
|
|
message = err.Error()
|
|
}
|
|
writeJSON(w, status, map[string]any{"ok": false, "error": code, "message": message})
|
|
}
|
|
|
|
func cleanPath(path string) string {
|
|
if path == "" {
|
|
return "/"
|
|
}
|
|
if path != "/" {
|
|
path = strings.TrimRight(path, "/")
|
|
}
|
|
if path == "" {
|
|
return "/"
|
|
}
|
|
return path
|
|
}
|
|
|
|
func requestBaseURL(r *http.Request, fallback string) string {
|
|
scheme := r.Header.Get("X-Forwarded-Proto")
|
|
if scheme == "" {
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
} else {
|
|
scheme = "http"
|
|
}
|
|
}
|
|
if r.Host != "" {
|
|
return scheme + "://" + r.Host
|
|
}
|
|
return strings.TrimRight(fallback, "/")
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|