Update application UI and functionality

This commit is contained in:
2026-07-26 16:20:36 +08:00
parent b9aff58f32
commit 97ea6fb7aa
48 changed files with 2790 additions and 628 deletions
@@ -1,9 +1,15 @@
package web
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"strings"
"ymhut-box/server/unified-management/internal/notices"
@@ -22,27 +28,15 @@ func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
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)
uploaded, opts, cleanup, err := r.readReleasePackageUpload(w, req)
if err != nil {
writeReleaseUploadError(w, err)
return
}
file, header, err := req.FormFile("file")
defer cleanup()
pkg, err := r.releases.SavePreparedPackage(req, uploaded, opts, "admin")
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)
writeReleaseUploadError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg})
@@ -55,6 +49,176 @@ func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
}
}
const releaseUploadFieldMaxBytes = 1 << 20
type releaseUploadError struct {
status int
code string
err error
}
func (e releaseUploadError) Error() string { return e.err.Error() }
func newReleaseUploadError(status int, code string, err error) error {
return releaseUploadError{status: status, code: code, err: err}
}
func (r *router) readReleasePackageUpload(w http.ResponseWriter, req *http.Request) (releases.UploadedPackageFile, releases.UploadOptions, func(), error) {
limit := r.releaseUploadLimit()
req.Body = http.MaxBytesReader(w, req.Body, limit+(8<<20))
reader, err := req.MultipartReader()
if err != nil {
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
}
if err := os.MkdirAll(r.cfg.DownloadsDir, 0o750); err != nil {
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err)
}
fields := map[string]string{}
uploadedName := ""
uploaded := releases.UploadedPackageFile{}
cleanup := func() {}
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
cleanup()
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, classifyMultipartReadError(err)
}
name := part.FormName()
if name == "" {
_ = part.Close()
continue
}
if name != "file" {
value, err := readReleaseUploadField(part)
_ = part.Close()
if err != nil {
cleanup()
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, err
}
fields[name] = value
continue
}
if uploaded.TempPath != "" {
_ = part.Close()
cleanup()
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", errors.New("multiple file fields are not supported"))
}
uploadedName = part.FileName()
prepared, err := r.streamReleaseUploadPart(part, limit)
_ = part.Close()
if err != nil {
cleanup()
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, err
}
uploaded = prepared
cleanup = func() { _ = os.Remove(prepared.TempPath) }
}
if uploaded.TempPath == "" {
return releases.UploadedPackageFile{}, releases.UploadOptions{}, cleanup, newReleaseUploadError(http.StatusBadRequest, "FILE_REQUIRED", errors.New("file is required"))
}
opts := releases.UploadOptions{
FileName: firstNonEmpty(fields["fileName"], uploadedName),
Version: fields["version"],
Platform: fields["platform"],
Arch: fields["arch"],
Channel: fields["channel"],
Notes: fields["notes"],
UpdateManifest: fields["updateManifest"] == "true" || fields["updateManifest"] == "1",
}
return uploaded, opts, cleanup, nil
}
func (r *router) streamReleaseUploadPart(part *multipart.Part, limit int64) (releases.UploadedPackageFile, error) {
tmp, err := os.CreateTemp(r.cfg.DownloadsDir, ".release-package-*.upload")
if err != nil {
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err)
}
tmpName := tmp.Name()
removeOnError := true
defer func() {
if removeOnError {
_ = os.Remove(tmpName)
}
}()
hash := sha256.New()
limited := &io.LimitedReader{R: part, N: limit + 1}
written, err := io.Copy(tmp, io.TeeReader(limited, hash))
if closeErr := tmp.Close(); err == nil {
err = closeErr
}
if err != nil {
return releases.UploadedPackageFile{}, classifyMultipartReadError(err)
}
if written > limit {
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", fmt.Errorf("发布包超过上传上限 %s", formatReleaseUploadBytes(limit)))
}
if written <= 0 {
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusBadRequest, "PACKAGE_EMPTY", releases.ErrUploadedPackageEmpty)
}
removeOnError = false
return releases.UploadedPackageFile{TempPath: tmpName, Size: written, SHA256: hex.EncodeToString(hash.Sum(nil))}, nil
}
func readReleaseUploadField(part *multipart.Part) (string, error) {
data, err := io.ReadAll(io.LimitReader(part, releaseUploadFieldMaxBytes+1))
if err != nil {
return "", classifyMultipartReadError(err)
}
if len(data) > releaseUploadFieldMaxBytes {
return "", newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", errors.New("form field is too large"))
}
return strings.TrimSpace(string(data)), nil
}
func classifyMultipartReadError(err error) error {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("发布包或表单数据超过上传上限"))
}
return newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
}
func (r *router) releaseUploadLimit() int64 {
if r.cfg != nil && r.cfg.ReleaseUploadMaxBytes > 0 {
return r.cfg.ReleaseUploadMaxBytes
}
return 1024 * 1024 * 1024
}
func formatReleaseUploadBytes(value int64) string {
const megabyte = 1024 * 1024
if value < megabyte {
return fmt.Sprintf("%d KB", (value+1023)/1024)
}
return fmt.Sprintf("%.1f MB", float64(value)/megabyte)
}
func writeReleaseUploadError(w http.ResponseWriter, err error) {
var uploadErr releaseUploadError
if errors.As(err, &uploadErr) {
writeError(w, uploadErr.status, uploadErr.code, uploadErr.err)
return
}
status := http.StatusBadRequest
code := "PACKAGE_UPLOAD_FAILED"
if errors.Is(err, releases.ErrUploadedPackageManifestFailed) {
status = http.StatusInternalServerError
code = "MANIFEST_UPDATE_FAILED"
} else if errors.Is(err, releases.ErrUploadedPackageStorageFailed) {
status = http.StatusInternalServerError
code = "UPLOAD_STORAGE_FAILED"
} else if errors.Is(err, releases.ErrUploadedPackageEmpty) {
code = "PACKAGE_EMPTY"
} else if errors.Is(err, releases.ErrUploadedPackageMissing) {
code = "FILE_REQUIRED"
}
writeError(w, status, code, err)
}
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"))
@@ -79,6 +79,7 @@ func localizedErrorMessage(code, message string) string {
byCode := map[string]string{
"UNAUTHORIZED": "需要登录后继续操作",
"LOGIN_FAILED": "登录失败,请检查密码和验证码",
"LOGIN_TIMEOUT": "登录校验超时,请稍后重试",
"PASSWORD_CHANGE_FAILED": "密码修改失败",
"INVALID_PAYLOAD": "提交内容格式不正确",
"DATABASE_TEST_FAILED": "数据库连接测试失败",
@@ -1,6 +1,7 @@
package web
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -31,6 +32,8 @@ type router struct {
publicSnapshots *publicSnapshotService
}
const loginRequestTimeout = 5 * time.Second
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,
@@ -55,7 +58,10 @@ func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, f
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
if strings.HasPrefix(path, "/api/admin/") && req.Method != http.MethodGet && req.Method != http.MethodHead {
if strings.HasPrefix(path, "/api/admin/") &&
path != "/api/admin/auth/login" &&
path != "/api/admin/auth/logout" &&
req.Method != http.MethodGet && req.Method != http.MethodHead {
captured := &mutationResponseWriter{ResponseWriter: w, status: http.StatusOK}
w = captured
defer func() {
@@ -116,7 +122,7 @@ func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
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/"))
r.serveAdminAsset(w, req, 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"):
@@ -196,6 +202,8 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
return
}
w.Header().Set("Cache-Control", "no-store")
req.Body = http.MaxBytesReader(w, req.Body, 64<<10)
var body struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -209,8 +217,14 @@ 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, req.RemoteAddr)
ctx, cancel := context.WithTimeout(req.Context(), loginRequestTimeout)
defer cancel()
sessionID, csrf, ok, err := r.auth.Login(ctx, body.Username, body.Password, body.CaptchaID, body.Captcha, req.RemoteAddr)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
writeError(w, http.StatusGatewayTimeout, "LOGIN_TIMEOUT", errors.New("login verification timed out"))
return
}
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
return
}
@@ -219,8 +233,16 @@ func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
return
}
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}})
go r.recordLoginAudit(body.Username, req.RemoteAddr, req.UserAgent())
}
func (r *router) recordLoginAudit(username, remoteAddr, userAgent string) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = r.store.InsertAuditContext(ctx, db.AuditLog{
Actor: username, Type: "auth.login", Target: "admin", Message: "管理员登录", IP: remoteAddr, UserAgent: userAgent,
})
}
func (r *router) handleLogout(w http.ResponseWriter, req *http.Request) {
@@ -598,6 +598,148 @@ func TestAdminSystemAndLegacyAdminPagesServeSPA(t *testing.T) {
if !strings.Contains(res.Body.String(), "/admin/assets/admin.js") {
t.Fatalf("%s did not serve admin SPA shell: %s", path, res.Body.String())
}
if got := res.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Fatalf("%s cache control = %q, want no-store", path, got)
}
}
}
func TestMissingAdminAssetIsNotCached(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/admin/assets/missing.js", nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusNotFound {
t.Fatalf("missing asset returned %d: %s", res.Code, res.Body.String())
}
if got := res.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("missing asset cache control = %q, want no-store", got)
}
}
func TestAdminReleasePackageUploadStreamsMultipart(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
session, csrf, err := loginForTest(handler)
if err != nil {
t.Fatal(err)
}
req := newReleaseUploadRequest(t, "YMhut_Box_WinUI_Setup_2.0.8_x64.exe", []byte("package bytes"), true)
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
req.Header.Set("X-CSRF-Token", csrf)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("upload 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["ok"] != true {
t.Fatalf("unexpected upload payload: %#v", payload)
}
}
func TestAdminReleasePackageUploadRejectsMissingAndOversizedFile(t *testing.T) {
for _, tc := range []struct {
name string
data []byte
include bool
wantStatus int
wantCode string
}{
{name: "missing", include: false, wantStatus: http.StatusBadRequest, wantCode: "FILE_REQUIRED"},
{name: "empty", include: true, data: []byte{}, wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_EMPTY"},
{name: "oversized", include: true, data: bytes.Repeat([]byte{'x'}, (1<<20)+1), wantStatus: http.StatusRequestEntityTooLarge, wantCode: "PACKAGE_TOO_LARGE"},
{name: "unsafe-name", include: true, data: []byte("package"), wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_UPLOAD_FAILED"},
} {
t.Run(tc.name, func(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
session, csrf, err := loginForTest(handler)
if err != nil {
t.Fatal(err)
}
fileName := "package.exe"
if tc.name == "unsafe-name" {
fileName = "../package.exe"
}
req := newReleaseUploadRequest(t, fileName, tc.data, tc.include)
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
req.Header.Set("X-CSRF-Token", csrf)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != tc.wantStatus || !strings.Contains(res.Body.String(), tc.wantCode) {
t.Fatalf("upload returned %d %s, want %d containing %s", res.Code, res.Body.String(), tc.wantStatus, tc.wantCode)
}
})
}
}
func newReleaseUploadRequest(t *testing.T, name string, data []byte, includeFile bool) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if includeFile {
part, err := writer.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
}
if err := writer.WriteField("version", "2.0.8"); err != nil {
t.Fatal(err)
}
if strings.Contains(name, "..") {
if err := writer.WriteField("fileName", name); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/admin/releases/packages", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
func TestValidateAdminDiskBuildRejectsMissingReferencedAsset(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(`<script type="module" src="/admin/assets/missing.js"></script>`), 0o644); err != nil {
t.Fatal(err)
}
if err := validateAdminDiskBuild(dir); err == nil || !strings.Contains(err.Error(), "assets/missing.js") {
t.Fatalf("validateAdminDiskBuild returned %v, want missing asset error", err)
}
}
func TestIncompleteAdminDiskBuildDoesNotServeDiskAssets(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(`<script type="module" src="/admin/assets/missing.js"></script>`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "assets", "stale.js"), []byte(`stale disk asset`), 0o644); err != nil {
t.Fatal(err)
}
r := &router{cfg: &config.Config{AdminWebDir: dir}}
req := httptest.NewRequest(http.MethodGet, "/admin/assets/stale.js", nil)
res := httptest.NewRecorder()
r.serveAdminAsset(res, req, "assets/stale.js")
if res.Code != http.StatusNotFound {
t.Fatalf("incomplete disk asset returned %d: %s", res.Code, res.Body.String())
}
if strings.Contains(res.Body.String(), "stale disk asset") {
t.Fatal("incomplete disk build served a stale disk asset")
}
if got := res.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("incomplete asset cache control = %q, want no-store", got)
}
}
@@ -796,6 +938,26 @@ func loginForTest(handler http.Handler) (string, string, error) {
return "", "", errors.New("session cookie not set")
}
func TestAdminLoginFailureReturnsImmediatelyAndIsNotCached(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
body := bytes.NewBufferString(`{"username":"admin","password":"wrong","captchaId":"missing","captcha":"00000"}`)
req := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login", body)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
started := time.Now()
handler.ServeHTTP(res, req)
if time.Since(started) > time.Second {
t.Fatal("invalid login request did not return promptly")
}
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "LOGIN_FAILED") {
t.Fatalf("invalid login returned %d: %s", res.Code, res.Body.String())
}
if got := res.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("login cache control = %q, want no-store", got)
}
}
func readTestCaptcha(dataURL string) (string, error) {
const prefix = "data:image/png;base64,"
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(dataURL, prefix))
@@ -936,6 +1098,7 @@ func testRouter(t *testing.T) (http.Handler, func()) {
TimestampWindowSeconds: 600,
MaxRequestBytes: 12 << 20,
MaxPackageBytes: 10 << 20,
ReleaseUploadMaxBytes: 1 << 20,
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(root, "storage", "unified.sqlite"),
@@ -3,10 +3,13 @@ package web
import (
"bytes"
"errors"
"fmt"
"log"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
@@ -46,6 +49,7 @@ func serveStaticAsset(w http.ResponseWriter, req *http.Request, root, embedRoot,
if serveEmbeddedFile(w, req, embedRoot+"/"+filepath.ToSlash(assetPath)) {
return
}
w.Header().Set("Cache-Control", "no-store")
http.NotFound(w, req)
}
@@ -53,6 +57,29 @@ func (r *router) serveServerAsset(w http.ResponseWriter, req *http.Request, asse
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)
if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil {
if tryServeDiskFile(w, req, r.cfg.AdminWebDir, assetPath) {
return
}
log.Printf("admin web disk build is missing requested asset: %s", assetPath)
} else {
if !errors.Is(err, os.ErrNotExist) {
log.Printf("admin web disk build is incomplete; serving embedded assets: %v", err)
}
if serveEmbeddedFile(w, req, "admin/dist/"+filepath.ToSlash(assetPath)) {
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)
}
@@ -108,11 +135,13 @@ func (r *router) servePortal(w http.ResponseWriter, req *http.Request) {
}
func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Cache-Control", "no-store, must-revalidate")
index := filepath.Join(r.cfg.AdminWebDir, "index.html")
if _, err := os.Stat(index); err == nil {
if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil {
http.ServeFile(w, req, index)
return
} else if !errors.Is(err, os.ErrNotExist) {
log.Printf("admin web disk build is incomplete: %v", err)
}
if serveEmbeddedFile(w, req, "admin/dist/index.html") {
return
@@ -121,6 +150,35 @@ func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
_, _ = 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>`))
}
var adminAssetReferencePattern = regexp.MustCompile(`(?:src|href)=["'](/admin/assets/[^"'?#]+)`)
func validateAdminDiskBuild(root string) error {
index := filepath.Join(root, "index.html")
data, err := os.ReadFile(index)
if err != nil {
return err
}
matches := adminAssetReferencePattern.FindAllSubmatch(data, -1)
if len(matches) == 0 {
return errors.New("index.html does not reference any admin assets")
}
for _, match := range matches {
assetPath := strings.TrimPrefix(string(match[1]), "/admin/")
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
return fmt.Errorf("invalid admin asset reference %s", assetPath)
}
path := filepath.Join(root, filepath.FromSlash(assetPath))
info, statErr := os.Stat(path)
if statErr != nil {
return fmt.Errorf("missing %s: %w", assetPath, statErr)
}
if info.IsDir() {
return fmt.Errorf("%s is not a file", assetPath)
}
}
return nil
}
func setStaticCacheHeaders(w http.ResponseWriter, assetPath string) {
extension := strings.ToLower(filepath.Ext(assetPath))
if strings.HasPrefix(filepath.ToSlash(assetPath), "assets/") && extension != ".ico" {