[feat](web): serve the SPA and web actions on the single listener
Add the browser edge (contract rev 8.2/9): a Preact/Vite SPA under web/, an embedded placeholder bundle, the /web/* auth and action handlers, and the explicit browser-action allowlist. The SPA and the API share the one plaintext :8080 listener; there is no second port and no peer edge, so main.go also drops the mTLS listener and the identity mounts in the debug compose fall away. /_manifest announces the web block only while the SPA is mounted (honesty rule), always with the single service port. The DSNs move to the shared database's scram-sha-256 credentials, matching rev 9's removal of the PKI layer. Committed web/bun.lock because web/package.json and vite.config.ts are new in this commit. src/web/dist/index.html is a hand-written placeholder, not a generated bundle: it keeps bare `go build`/`go vet` working from a clean checkout, while /web/dist (the real build output) stays gitignored.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user