Files
YMhut-box-C-/server/unified-management/internal/web/static_routes.go
T

167 lines
6.4 KiB
Go

package web
import (
"bytes"
"errors"
"html"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"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
}
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=3600")
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
}
setStaticCacheHeaders(w, assetPath)
if tryServeDiskFile(w, req, root, assetPath) {
return
}
if serveEmbeddedFile(w, req, embedRoot+"/"+filepath.ToSlash(assetPath)) {
return
}
w.Header().Set("Cache-Control", "no-store")
http.NotFound(w, req)
}
func (r *router) serveServerAsset(w http.ResponseWriter, req *http.Request, assetPath string) {
serveStaticAsset(w, req, filepath.Join(r.cfg.BaseDir, "assets"), "", assetPath)
}
func (r *router) serveAdminAsset(w http.ResponseWriter, req *http.Request, assetPath string) {
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid asset path"))
return
}
setStaticCacheHeaders(w, assetPath)
data, err := r.adminAssets.ReadFile(filepath.ToSlash(assetPath))
if err == nil {
serveAssetContent(w, req, assetPath, data)
return
}
w.Header().Set("Cache-Control", "no-store")
http.NotFound(w, req)
}
func serveSetupServerAsset(w http.ResponseWriter, req *http.Request, cfgRoot, assetPath string) {
serveStaticAsset(w, req, filepath.Join(cfgRoot, "assets"), "", assetPath)
}
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) {
w.Header().Set("Cache-Control", "no-cache")
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) {
w.Header().Set("Cache-Control", "no-store, must-revalidate")
data, err := r.adminAssets.ReadFile("index.html")
if err == nil {
serveAssetContent(w, req, "index.html", data)
return
}
status := r.adminAssets.Diagnostics()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>管理后台资源不可用</title><style>body{margin:0;background:#f5f7f7;color:#253131;font:14px/1.6 "Segoe UI",sans-serif}main{max-width:680px;margin:12vh auto;padding:28px;background:#fff;border:1px solid #d9e0df;border-radius:8px}h1{font-size:22px;margin:0 0 12px}code{display:block;margin-top:16px;padding:12px;background:#f1f4f3;border-radius:6px;overflow-wrap:anywhere}</style></head><body><main><h1>管理后台资源不可用</h1><p>服务拒绝混用不完整或版本不一致的后台资源。请重新发布同一构建生成的服务二进制。</p><code>` + html.EscapeString(status.Mode+" / "+status.BuildID+" / "+status.ValidationError) + `</code></main></body></html>`))
}
func serveAssetContent(w http.ResponseWriter, req *http.Request, name string, data []byte) {
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))
}
var hashedAssetPattern = regexp.MustCompile(`-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$`)
func setStaticCacheHeaders(w http.ResponseWriter, assetPath string) {
extension := strings.ToLower(filepath.Ext(assetPath))
if strings.HasPrefix(filepath.ToSlash(assetPath), "assets/") && extension != ".ico" && hashedAssetPattern.MatchString(filepath.Base(assetPath)) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=3600")
}
func isPortalRoute(path string) bool {
switch path {
case "/", "/releases", "/sources", "/feedback", "/compatibility":
return true
default:
return false
}
}