// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package web import ( "io/fs" "net/http" "net/url" "os" "path" "strings" ) var reserved = []string{"/web/", "/auth/", "/action/", "/config", "/_manifest", "/api/"} // isReserved reports whether p is, or is below, a reserved prefix. Matching is // exact or path-boundary only (RC18): /configx, /configuration and /_manifestx // are NOT reserved (they may be real SPA routes), while /config, /config/..., // /_manifest, /_manifest/..., /web, /web/..., /auth/..., /action/... and // /api/... are. func isReserved(p string) bool { for _, pre := range reserved { base := strings.TrimSuffix(pre, "/") if p == base || strings.HasPrefix(p, base+"/") { return true } } return false } // reservedPath decides whether the request must be answered with the reserved // JSON 404 instead of the SPA shell. Go decodes the request path once, so a // double-encoded traversal such as /%252e%252e/config arrives as // /%2e%2e/config and only becomes /config after a second decode; checking up to // two extra decodes keeps that (and any undecodable encoding) fail-closed. func reservedPath(r *http.Request) bool { if isReserved(path.Clean("/" + r.URL.Path)) { return true } esc := r.URL.EscapedPath() if esc == "" { return false } for i := 0; i < 2; i++ { dec, err := url.PathUnescape(esc) if err != nil { return true // undecodable encoding: never serve the shell } if isReserved(path.Clean("/" + dec)) { return true } esc = dec } return false } // SPA serves the embedded bundle at web/dist, or the live directory named by // DCNTRLIZED_WEB_DIR when it exists. The disk overlay is a DEV-ONLY hot-reload // affordance (F4): outside devMode() it is ignored entirely, so the variable can // never make the browser listener serve arbitrary container files. type SPA struct{ fs http.FileSystem } func NewSPA(embedded fs.FS) (*SPA, error) { if devMode() { if dir := strings.TrimSpace(os.Getenv(EnvDir)); dir != "" { if st, err := os.Stat(dir); err == nil && st.IsDir() { return &SPA{fs: http.Dir(dir)}, nil } } } sub, err := fs.Sub(embedded, "web/dist") if err != nil { return nil, err } return &SPA{fs: http.FS(sub)}, nil } func securityHeaders(w http.ResponseWriter) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+ "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; "+ "base-uri 'none'; form-action 'self'") } func (s *SPA) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") writeCode(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED") return } if reservedPath(r) { writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND") return } upath := path.Clean("/" + r.URL.Path) securityHeaders(w) if f, err := s.fs.Open(upath); err == nil { st, serr := f.Stat() if serr == nil && !st.IsDir() { if strings.HasPrefix(upath, "/assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } http.ServeContent(w, r, path.Base(upath), st.ModTime(), f) _ = f.Close() return } _ = f.Close() } s.serveIndex(w, r) } func (s *SPA) serveIndex(w http.ResponseWriter, r *http.Request) { f, err := s.fs.Open("/index.html") if err != nil { writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND") return } defer f.Close() st, err := f.Stat() if err != nil || st.IsDir() { writeCode(w, http.StatusNotFound, "ERR_NOT_FOUND") return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") http.ServeContent(w, r, "index.html", st.ModTime(), f) }