mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-05 00:45:29 +08:00
http.ServeFile relies on mime.TypeByExtension, which on Windows reads HKEY_CLASSES_ROOT to resolve the MIME type. Third-party software (some editors and registry-cleaning tools) can rewrite ".css" to "application/xml", causing Chrome to refuse the stylesheet and breaking the /admin panel with a fully unstyled page. The same class of bug affects ".js" -> "text/plain" in some setups. Pin the Content-Type by file extension before delegating to ServeFile, covering the WebUI asset surface (css, js, mjs, html, json, map, svg, common image and font formats, wasm). Unknown extensions still fall through to ServeFile's default detection. Tests cover the pinned types, case-insensitive extension matching, and the unknown-extension passthrough.
103 lines
3.9 KiB
Go
103 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|