Three coordinated changes that share the same files. Common theme:
convention beats exception. Where the codebase had a bespoke wire shape
or a special-case route, replace it with the generic shape every other
client already speaks.
== Listing protocol ==
GET / Accept: application/json used to dispatch to a bespoke
ServeProjectList handler returning {name, url, title} per project — a
shape that diverged from every other directory's listing.FileInfo
response. Now:
- listing.FileInfo gains an optional `title` field (read from each
directory's own .zddc title:). Generic clients (landing, browse)
read the same shape from every URL.
- appfs.ListDirectory emits a virtual `.zddc` entry (is_dir:false,
virtual:true) when no on-disk file exists at that path and the
caller asked for ?hidden=1. Opens an editable view of the cascade
defaults; PUT-saving its bytes materialises a real file.
- The bespoke GET / JSON branch in cmd/zddc-server/main.go is gone.
The bare-root landing serve is Accept-gated: HTML requests get the
landing tool (project picker), JSON requests fall through to
ServeDirectory and get the generic listing.
- landing's fetchProjects filters the new generic shape (is_dir,
strip trailing slash) — same pattern fetchParties already used at
/<project>/archive/.
== Form editor retirement ==
`<dir>/.zddc.html` was a server-rendered form for editing per-directory
.zddc files (~900 LOC across zddceditor.go, zddchandler.go, zddc_assets.go).
Browse's YAML/CodeMirror editor (with .zddc-schema lint) already edits
the same files via the generic file-API. Two ways to edit the same data
is exception, not convention.
- Delete zddceditor.go, zddchandler.go, zddc_assets.go and tests.
- `/<dir>/.zddc.html` → 302 redirect to `/<dir>/?file=.zddc` (browse
opens the .zddc in its editor pane).
- /.profile/zddc/* namespace deleted (REST API + assets sub-route).
- Profile page's "Editable .zddc files" list links to browse.
- ServeZddcFile's 405 message + virtual-body comment point at the
browse URL instead of the dead form.
== Admin elevation (Principal model) ==
Sudo-style: admins are treated as normal users by default; opting into
admin powers is per-request and gated by a `zddc-elevate=1` cookie.
- zddc.Principal{Email, Elevated} replaces bare-email arguments on
IsAdmin / IsSubtreeAdmin / CanEditZddc. The signature change makes
the elevation gate compiler-enforced at every admin call site —
audit-fragility is gone. The empty-email short-circuit is no longer
load-bearing for elevation; Principal.gate() is the explicit check.
- handler.ACLMiddleware derives Elevated per request: bearer tokens
are implicitly elevated (CLI clients can't toggle a cookie); browser
sessions elevate only when zddc-elevate=1 is set. PrincipalFromContext(r)
is the one-call-per-site bundling helper.
- Every admin-check call site updated to pass a Principal.
- /.auth/admin (forward_auth target for the dev-shell IDE) explicitly
bypasses elevation with a synthetic-elevated Principal — different
cookie scope than zddc-server origin, documented inline.
- AccessView gains CanElevate (elevation-independent "does this email
have admin authority anywhere?") so the header toggle can render
itself for an un-elevated admin who hasn't opted in yet.
- ServeProjectList is removed; ProjectInfo + EnumerateProjects stay
for the profile page's server-rendered project list.
- MatchAppHTML stays — still used by main.go to route <dir>/<tool>.html
URLs to the apps subsystem when no real file exists.
- Test helpers carry Elevated=true by default (matches the
pre-elevation default; tests for the un-elevated gate use the
explicit form).
Go tests pass across all 14 internal packages. Browse + every other
tool rebuilds clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.3 KiB
Go
214 lines
7.3 KiB
Go
package apps
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"errors"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||
)
|
||
|
||
// Server orchestrates app HTML resolution: subdir cascade override → fetch
|
||
// or path read → embedded fallback. It does NOT check whether the app is
|
||
// available at the request directory — that's AppAvailableAt's job, called
|
||
// from dispatch before invoking Serve.
|
||
type Server struct {
|
||
Root string
|
||
Cache *Cache
|
||
Fetcher *Fetcher
|
||
BuildVer string // baked into X-ZDDC-Source for embedded responses
|
||
}
|
||
|
||
// NewServer constructs a Server.
|
||
func NewServer(root string, cache *Cache, fetcher *Fetcher, buildVer string) *Server {
|
||
return &Server{
|
||
Root: filepath.Clean(root),
|
||
Cache: cache,
|
||
Fetcher: fetcher,
|
||
BuildVer: buildVer,
|
||
}
|
||
}
|
||
|
||
// MatchAppHTML returns the canonical app name if requestPath matches a
|
||
// "<dir>/<app>.html" pattern for one of the canonical apps, plus the
|
||
// directory (relative to root) the request is rooted at. The cmd/zddc-
|
||
// server dispatcher calls this when stat fails on a URL: a missing file
|
||
// that happens to look like `<dir>/archive.html` (or browse.html, etc.)
|
||
// resolves to the embedded app HTML for that directory — operators
|
||
// don't have to copy app HTML into every project.
|
||
//
|
||
// Special case: GET / and GET /index.html both resolve to landing — the
|
||
// only entry point that scopes ACL per-project, and the conventional
|
||
// place for a static-site index when an operator wants one.
|
||
func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
|
||
if requestPath == "" || requestPath == "/" {
|
||
return "landing", ""
|
||
}
|
||
clean := strings.TrimPrefix(requestPath, "/")
|
||
clean = strings.TrimSuffix(clean, "/")
|
||
if clean == "" {
|
||
return "landing", ""
|
||
}
|
||
dir := filepath.Dir(clean)
|
||
if dir == "." {
|
||
dir = ""
|
||
}
|
||
switch filepath.Base(clean) {
|
||
case "index.html":
|
||
return "landing", dir
|
||
case "archive.html":
|
||
return "archive", dir
|
||
case "transmittal.html":
|
||
return "transmittal", dir
|
||
case "classifier.html":
|
||
return "classifier", dir
|
||
case "browse.html":
|
||
return "browse", dir
|
||
}
|
||
return "", ""
|
||
}
|
||
|
||
// Serve resolves and writes the response. Caller has already verified:
|
||
// - no real file exists at the request path
|
||
// - AppAvailableAt(root, requestDir, app) is true
|
||
// - ACL passes for requestDir
|
||
//
|
||
// Honors a `?v=<spec>` query parameter as a per-request override on top of
|
||
// the cascade. With `?v=` set, the resolved URL must already exist in the
|
||
// cache — otherwise the response is 404. This prevents users from
|
||
// triggering arbitrary upstream fetches via URL-crafted requests; only
|
||
// versions the operator's `.zddc apps:` entries have already pulled in
|
||
// (or that the user has manually placed in `_app/`) are reachable.
|
||
func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain zddc.PolicyChain, requestDir string) {
|
||
vSpec := strings.TrimSpace(r.URL.Query().Get("v"))
|
||
|
||
src, hasOverride, err := ResolveWithOverride(chain, app, s.Root, requestDir, vSpec)
|
||
if err != nil {
|
||
// `?v=` parsing/validation errors are user input → 400.
|
||
if vSpec != "" {
|
||
http.Error(w, "400 Bad Request — invalid ?v= value: "+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
// Malformed `.zddc` spec — operator's fault. Log and serve embedded.
|
||
s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded",
|
||
"app", app, "request_dir", requestDir, "err", err)
|
||
s.serveEmbedded(w, r, app, err)
|
||
return
|
||
}
|
||
|
||
if !hasOverride {
|
||
// No `.zddc apps:` entry anywhere up the chain and no `?v=` either →
|
||
// embedded is the authoritative default.
|
||
s.serveEmbedded(w, r, app, nil)
|
||
return
|
||
}
|
||
|
||
// Per-request `?v=` is restricted to cache-backed URL sources.
|
||
if vSpec != "" {
|
||
if !src.IsURL() {
|
||
http.Error(w, "400 Bad Request — ?v= requires a URL-form spec", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if s.Cache == nil || !s.Cache.Has(src.URL) {
|
||
http.Error(w,
|
||
"404 Not Found — version requested via ?v= is not in the local cache.\n"+
|
||
"Only versions the deployment has already fetched (via .zddc apps: entries) are servable.\n"+
|
||
"Asked for: "+src.URL+"\n",
|
||
http.StatusNotFound)
|
||
return
|
||
}
|
||
body, err := s.Cache.Read(src.URL)
|
||
if err != nil {
|
||
s.Fetcher.Logger.Warn("?v= cache read failed", "url", src.URL, "err", err)
|
||
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
s.serveBody(w, r, body, "cache:"+src.URL)
|
||
return
|
||
}
|
||
|
||
if !src.IsURL() {
|
||
// Path source: read directly, no cache.
|
||
body, err := os.ReadFile(src.Path)
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
s.Fetcher.Logger.Warn("path source missing; serving embedded",
|
||
"app", app, "path", src.Path)
|
||
} else {
|
||
s.Fetcher.Logger.Warn("path source unreadable; serving embedded",
|
||
"app", app, "path", src.Path, "err", err)
|
||
}
|
||
s.serveEmbedded(w, r, app, err)
|
||
return
|
||
}
|
||
s.serveBody(w, r, body, "path:"+src.Path)
|
||
return
|
||
}
|
||
|
||
// URL source: cache hit serves immediately; cache miss fetches once.
|
||
body, err := s.Fetcher.Fetch(r.Context(), src.URL)
|
||
if err != nil {
|
||
s.Fetcher.LogEmbeddedFallback(app, src.URL, err)
|
||
s.serveEmbedded(w, r, app, err)
|
||
return
|
||
}
|
||
sourceTag := "fetch:" + src.URL
|
||
if s.Cache != nil && s.Cache.Has(src.URL) {
|
||
// Likely served from cache (Has was true when the read started).
|
||
// Distinguishing cache-hit from just-fetched is best-effort here.
|
||
sourceTag = "cache:" + src.URL
|
||
}
|
||
s.serveBody(w, r, body, sourceTag)
|
||
}
|
||
|
||
// writeWithETag writes body with a strong ETag derived from `etag`, the
|
||
// cache-friendly headers, and short-circuits to 304 Not Modified when the
|
||
// client's `If-None-Match` matches. `max-age=0, must-revalidate` means the
|
||
// browser revalidates on every load — and the matching ETag returns 304
|
||
// with empty body, so the steady-state cost of a reload is ~200 bytes
|
||
// instead of the full HTML payload (50–920 KB depending on the tool).
|
||
func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, contentType, sourceHeader string) {
|
||
quotedTag := `"` + etag + `"`
|
||
w.Header().Set("ETag", quotedTag)
|
||
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
|
||
w.Header().Set("Content-Type", contentType)
|
||
w.Header().Set("X-ZDDC-Source", sourceHeader)
|
||
|
||
if match := r.Header.Get("If-None-Match"); match != "" && match == quotedTag {
|
||
w.WriteHeader(http.StatusNotModified)
|
||
return
|
||
}
|
||
_, _ = w.Write(body)
|
||
}
|
||
|
||
// bodyETag computes a stable 32-hex-char ETag for an arbitrary body. Used
|
||
// for the URL/path-sourced response path (the bytes vary per cache-fetch
|
||
// or per file read, so memoizing per-app would be wrong).
|
||
func bodyETag(body []byte) string {
|
||
sum := sha256.Sum256(body)
|
||
return hex.EncodeToString(sum[:])[:32]
|
||
}
|
||
|
||
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) {
|
||
writeWithETag(w, r, body, bodyETag(body), "text/html; charset=utf-8", sourceHeader)
|
||
}
|
||
|
||
func (s *Server) serveEmbedded(w http.ResponseWriter, r *http.Request, app string, _ error) {
|
||
body := EmbeddedBytes(app)
|
||
if len(body) == 0 {
|
||
w.Header().Set("Retry-After", "60")
|
||
http.Error(w,
|
||
"503 Service Unavailable\n\n"+
|
||
"This zddc-server has no embedded fallback for "+app+".\n"+
|
||
"Rebuild the binary against the latest tool HTMLs.\n",
|
||
http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
writeWithETag(w, r, body, EmbeddedETag(app),
|
||
"text/html; charset=utf-8",
|
||
"embedded:"+app+"@"+s.BuildVer)
|
||
}
|