NextMyDay/internal/core/server/static.go
Claus Lohmar d11e14295b feat(core): add the store, identity plumbing and the door (SPEC-003/004)
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
  migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
  headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
  (RegisterModule), the role gates, /setup · enroll/login · recovery flows,
  the reserved `static` workflow, and the loopback-only bootstrap (D-025)
2026-10-14 10:53:51 +00:00

127 lines
4.4 KiB
Go

package server
import (
"io/fs"
"net/http"
"path"
"strings"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
)
// StaticWorkflow is the reserved level-1 workflow that serves the embedded
// assets (D-004: `static` is reserved from the very first commit). It is core
// owned — it carries no module and no identity — and it is served in setup mode
// too, so the bootstrap wizard has a stylesheet (SPEC-005 §3).
const StaticWorkflow = "static"
// StaticCacheControl is the cache lifetime for an asset. The assets are
// versionless static files, so the window is deliberately short.
const StaticCacheControl = "public, max-age=300"
// StaticWorkerName is the one asset that needs a wider service-worker scope.
// A worker script served from /static/sw.js may only control /static/ unless
// the response says otherwise, and MyDay registers it with scope "/" (the
// manifest's start_url is "/", D-011). Service-Worker-Allowed grants that, and
// the worker's own cache policy still refuses anything that is not a static
// asset (A5).
const StaticWorkerName = "sw.js"
// StaticWorkerAllowed is the scope granted to the worker script.
const StaticWorkerAllowed = "/"
// StaticHandler serves the assets embedded by internal/core/web under
// /static/{asset} (SPEC-009 §3). The door has already classified the reserved
// `static` workflow and handed off the path remainder; the handler never parses
// a raw path (the rule of the door — rc.Page is the joined remainder).
func StaticHandler() Handler {
return func(w http.ResponseWriter, r *http.Request, rc *RequestContext) error {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
writeStaticError(w, http.StatusMethodNotAllowed, "method not allowed")
return nil
}
name := strings.TrimSpace(rc.Page)
if !validStaticName(name) {
writeStaticError(w, http.StatusNotFound, "not found")
return nil
}
data, err := fs.ReadFile(web.StaticFS, web.StaticDir+"/"+name)
if err != nil {
// Absent asset, or a name the embedded FS refuses (it rejects any
// path containing "..") — a 404 either way, never a directory list.
writeStaticError(w, http.StatusNotFound, "not found")
return nil
}
h := w.Header()
h.Set("Content-Type", staticContentType(name))
h.Set("Cache-Control", StaticCacheControl)
if name == StaticWorkerName {
// Without this the browser refuses `scope: "/"` on a worker served
// from a sub-directory (SecurityError) and the registration fails.
h.Set("Service-Worker-Allowed", StaticWorkerAllowed)
}
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return nil
}
_, err = w.Write(data)
return err
}
}
// validStaticName accepts a clean, relative asset path only: no absolute path,
// no traversal, no empty segment. The embedded FS is already traversal-safe;
// this is defence in depth and keeps the 404 shape honest.
func validStaticName(name string) bool {
if name == "" || strings.HasPrefix(name, "/") || strings.ContainsAny(name, "\\\x00") {
return false
}
if name != path.Clean(name) {
return false // catches "..", "./x", "a//b", "a/"
}
for _, segment := range strings.Split(name, "/") {
if segment == "" || segment == "." || segment == ".." {
return false
}
}
return true
}
// staticContentType maps an asset's extension to its media type. The door
// always sends X-Content-Type-Options: nosniff, so an unknown extension is
// served as opaque bytes rather than something a browser might sniff.
func staticContentType(name string) string {
switch strings.ToLower(path.Ext(name)) {
case ".css":
return "text/css; charset=utf-8"
case ".js", ".mjs":
return "text/javascript; charset=utf-8"
case ".json":
return "application/json; charset=utf-8"
case ".webmanifest":
return "application/manifest+json"
case ".png":
return "image/png"
case ".svg":
return "image/svg+xml"
case ".ico":
return "image/vnd.microsoft.icon"
case ".txt":
return "text/plain; charset=utf-8"
case ".woff2":
return "font/woff2"
default:
return "application/octet-stream"
}
}
// writeStaticError writes a terse plain-text error response (matching the
// door's).
func writeStaticError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_, _ = w.Write([]byte(msg + "\n"))
}