- 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)
127 lines
4.4 KiB
Go
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"))
|
|
}
|