mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 16:35:27 +08:00
Merge pull request #418 from lwz762/fix/admin-css-mime-windows
fix(webui): 修复 Windows 注册表 MIME 错误导致 /admin 样式失效
This commit is contained in:
@@ -55,6 +55,45 @@ func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "WebUI not built. Run `cd webui && npm run build` first.", http.StatusNotFound)
|
||||
}
|
||||
|
||||
// staticContentTypes pins the Content-Type of common WebUI assets so we do not
|
||||
// rely on mime.TypeByExtension, which on Windows consults the registry and can
|
||||
// return the wrong type (e.g. application/xml for .css) when third-party
|
||||
// software has overwritten HKEY_CLASSES_ROOT entries. Browsers strictly enforce
|
||||
// stylesheet/script MIME types and will refuse to apply a misidentified asset,
|
||||
// breaking the /admin page on affected machines.
|
||||
var staticContentTypes = map[string]string{
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".wasm": "application/wasm",
|
||||
}
|
||||
|
||||
// setStaticContentType pins the response Content-Type by file extension so that
|
||||
// http.ServeFile does not fall back to mime.TypeByExtension (which on Windows
|
||||
// reads the registry and may return an incorrect type).
|
||||
func setStaticContentType(w http.ResponseWriter, fullPath string) {
|
||||
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||
if ct, ok := staticContentTypes[ext]; ok {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
@@ -70,6 +109,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||
}
|
||||
setStaticContentType(w, full)
|
||||
http.ServeFile(w, r, full)
|
||||
return
|
||||
}
|
||||
@@ -82,6 +122,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||
setStaticContentType(w, index)
|
||||
http.ServeFile(w, r, index)
|
||||
}
|
||||
|
||||
|
||||
102
internal/webui/handler_test.go
Normal file
102
internal/webui/handler_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestServeFromDiskPinsContentType ensures static admin assets are returned
|
||||
// with an explicit, RFC-compliant Content-Type that does not depend on
|
||||
// mime.TypeByExtension. On Windows mime.TypeByExtension consults the registry
|
||||
// (HKEY_CLASSES_ROOT) which third-party software can corrupt — for example
|
||||
// installing certain editors rewrites .css to application/xml — and Chrome
|
||||
// then refuses to apply a stylesheet whose Content-Type is not text/css,
|
||||
// breaking the /admin page entirely. Pinning the type by file extension makes
|
||||
// the response deterministic across operating systems and machine state.
|
||||
func TestServeFromDiskPinsContentType(t *testing.T) {
|
||||
staticDir := t.TempDir()
|
||||
assetsDir := filepath.Join(staticDir, "assets")
|
||||
if err := os.MkdirAll(assetsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir assets: %v", err)
|
||||
}
|
||||
|
||||
files := map[string]string{
|
||||
"index.html": "<!doctype html><html></html>",
|
||||
"assets/index.css": "body{}",
|
||||
"assets/index.js": "console.log(1)",
|
||||
"assets/icon.svg": `<svg xmlns="http://www.w3.org/2000/svg"></svg>`,
|
||||
"assets/source.js.map": `{"version":3}`,
|
||||
}
|
||||
for rel, body := range files {
|
||||
full := filepath.Join(staticDir, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", rel, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
h := &Handler{StaticDir: staticDir}
|
||||
|
||||
cases := []struct {
|
||||
urlPath string
|
||||
wantPrefix string
|
||||
wantCacheCtl string
|
||||
}{
|
||||
{"/admin/assets/index.css", "text/css", "public, max-age=31536000, immutable"},
|
||||
{"/admin/assets/index.js", "text/javascript", "public, max-age=31536000, immutable"},
|
||||
{"/admin/assets/icon.svg", "image/svg+xml", "public, max-age=31536000, immutable"},
|
||||
{"/admin/assets/source.js.map", "application/json", "public, max-age=31536000, immutable"},
|
||||
// "/admin/index.html" is intentionally omitted: http.ServeFile redirects
|
||||
// requests for index.html to "./", matching Go's net/http behavior. The
|
||||
// route the SPA actually lands on is "/admin/" below.
|
||||
{"/admin/", "text/html", "no-store, must-revalidate"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.urlPath, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.urlPath, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.serveFromDisk(rec, req, staticDir)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
ct := rec.Header().Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, tc.wantPrefix) {
|
||||
t.Fatalf("Content-Type = %q, want prefix %q", ct, tc.wantPrefix)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != tc.wantCacheCtl {
|
||||
t.Fatalf("Cache-Control = %q, want %q", got, tc.wantCacheCtl)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetStaticContentTypeUnknownExtensionFallsThrough verifies that unknown
|
||||
// extensions leave the Content-Type header unset, so http.ServeFile can apply
|
||||
// its own detection (sniffing or mime.TypeByExtension) for cases the pinned
|
||||
// table does not cover.
|
||||
func TestSetStaticContentTypeUnknownExtensionFallsThrough(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
setStaticContentType(rec, "/tmp/data.unknownext")
|
||||
if got := rec.Header().Get("Content-Type"); got != "" {
|
||||
t.Fatalf("Content-Type = %q, want empty for unknown extension", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetStaticContentTypeIsCaseInsensitive guards against a regression where
|
||||
// uppercase extensions (e.g. STYLE.CSS shipped from some build pipelines)
|
||||
// would bypass the pinned table and fall back to the registry on Windows.
|
||||
func TestSetStaticContentTypeIsCaseInsensitive(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
setStaticContentType(rec, "/tmp/STYLE.CSS")
|
||||
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/css") {
|
||||
t.Fatalf("Content-Type = %q, want text/css prefix", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user