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")) }