feat(10.1-01): serve controller JS/CSS and run registered toolbar actions

- boardwalk exports ContentType and SetSecurityHeaders
- pact.AdminClientAssets files are read and hashed at boot and served by exact
  key under {prefix}/assets/{vendor}/{plugin}/ with nosniff, CSP, CORP,
  no-cache and an ETag; a miss falls through to the SPA
- list and form schemas carry assets URLs with a ?v= hash
- toolbar.buttons resolves create, delete and registered actions after decode;
  toolbarActions is permission-filtered per admin
- POST .../toolbar/{action} behind requireAjax and action permissions
This commit is contained in:
Jakub Zych
2026-09-28 23:41:17 +02:00
parent f9281949a6
commit 8b1cb244de
23 changed files with 772 additions and 64 deletions

View File

@@ -42,6 +42,8 @@ mux.Handle("/backend/", spa)
| `boardwalk.Dist` | Returns the embedded build as an `fs.FS` rooted at `dist/`. |
| `boardwalk.RewriteIndex` | Rewrites raw `index.html` bytes for a prefix; errors when the placeholder token is missing. |
| `boardwalk.BaseToken` | The placeholder in `dist/index.html` that is replaced by the prefix. |
| `boardwalk.ContentType` | The Content-Type served for a file name, with explicit UTF-8 JavaScript and CSS types. Shared with the plugin asset route in cabana. |
| `boardwalk.SetSecurityHeaders` | Sets the admin security headers (nosniff, referrer policy, no framing, the `script-src 'self'` CSP, noindex) on a response. |
## Dependencies

View File

@@ -98,7 +98,7 @@ type handler struct {
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
setSecurityHeaders(w.Header())
SetSecurityHeaders(w.Header())
rel := strings.TrimPrefix(r.URL.Path, h.prefix)
rel = strings.TrimPrefix(rel, "/")
if rel == "api" || strings.HasPrefix(rel, "api/") {
@@ -138,7 +138,7 @@ func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string)
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType(name))
w.Header().Set("Content-Type", ContentType(name))
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
@@ -147,7 +147,10 @@ func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string)
http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body))
}
func contentType(name string) string {
// ContentType is the Content-Type the admin serves for a file name: explicit
// UTF-8 JavaScript and CSS types (module scripts and nosniff'd stylesheets
// need them), then the platform MIME table, then application/octet-stream.
func ContentType(name string) string {
ext := strings.ToLower(path.Ext(name))
if ct, ok := contentTypes[ext]; ok {
return ct
@@ -158,7 +161,10 @@ func contentType(name string) string {
return "application/octet-stream"
}
func setSecurityHeaders(h http.Header) {
// SetSecurityHeaders sets the admin response headers: nosniff, a same-origin
// referrer policy, no framing, the admin Content-Security-Policy
// (script-src 'self') and noindex.
func SetSecurityHeaders(h http.Header) {
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")

View File

@@ -273,13 +273,13 @@ func TestContentTypesAndCaching(t *testing.T) {
if index.Header().Get("Cache-Control") != "no-store" || !strings.HasPrefix(index.Header().Get("Content-Type"), "text/html") {
t.Fatalf("index headers = %v", index.Header())
}
if got := contentType("x.svg"); got != "image/svg+xml" {
if got := ContentType("x.svg"); got != "image/svg+xml" {
t.Fatalf("svg type %q", got)
}
if got := contentType("x.json"); got != "application/json" {
if got := ContentType("x.json"); got != "application/json" {
t.Fatalf("json type %q", got)
}
if got := contentType("x.unknown-ext"); got != "application/octet-stream" {
if got := ContentType("x.unknown-ext"); got != "application/octet-stream" {
t.Fatalf("unknown type %q", got)
}
}
@@ -432,7 +432,7 @@ func TestPhase10BoardwalkServing(t *testing.T) {
if cc := get(mapped, testPrefix+"/robots.txt").Header().Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("non-asset file Cache-Control=%q, want no-cache", cc)
}
if got := contentType("UPPER.JS"); got != "text/javascript; charset=utf-8" {
if got := ContentType("UPPER.JS"); got != "text/javascript; charset=utf-8" {
t.Fatalf("extension case: %q", got)
}
})