Files
KeepNote/src/handlers/web/spa.go
T
Admin a791232f18 [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.
2026-10-19 09:28:59 +01:00

145 lines
4.5 KiB
Go

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