ZDDC/zddc/internal/apps/handler.go
ZDDC 4eeb25c0ef feat(server): local-only tool-HTML override; remove apps URL/version fetching
Replaces the URL/channel/version-fetching tool-HTML system with a
local-only override model. No network fetch, no Ed25519 signatures, no
channels/versions, no `apps:` .zddc key.

Tool HTML resolves, in precedence:
1. a real file on disk at the path (operator drops browse.html / archive.html
   / a new mytool.html) — served by the existing static handler;
2. an `<app>.html` member of the site-root <ZDDC_ROOT>/.zddc.zip bundle, read
   server-side via internal/zipfs (local file, no fetch, no signature;
   re-stat'd each request for free hot-reload);
3. the embedded //go:embed default.

Remove (complete unwire):
- internal/apps/{fetch,verify,cache,singleflight}.go and their tests; the
  spec-parsing/cascade machinery in apps.go (ParseSpec/Resolve/PreviewLine/
  SpecComponents/appsState, DefaultUpstream*/DefaultChannel/CacheDirName).
- --apps-pubkey / ZDDC_APPS_PUBKEY flag+env+Config field; the setupApps
  cache/fetcher/pubkey wiring (now just apps.NewServer(root, version)).
- the `apps:` / `apps_pubkey:` .zddc keys: ZddcFile.Apps/AppsPubKey, the
  walker merges, cascade-summary adds, validate.go apps validation
  (ValidateAppSourceSpec/validateURLSpec/validateChannelOrVersion/
  AppsDefaultKey/IsValidAppsKey), and the isZero/is-empty refs. A stale
  apps:/apps_pubkey: in an existing .zddc is now silently ignored
  (back-compat), not a parse error. Client .zddc validator (preview-yaml.js)
  drops the apps/apps_pubkey keys + appsmap case.

Add:
- internal/apps/bundle.go — nil-safe Bundle over <root>/.zddc.zip with
  stat-based hot-reload, size caps, corrupt-zip tolerance.
- handler.go: Server{Bundle}, resolveBytes (bundle→embedded), simplified
  Serve; X-ZDDC-Source = bundle:<m> / embedded:<app>@<ver>.
- dispatch: GET /.zddc.zip is 404 for everyone (config, not content); the
  server reads members from the filesystem internally.

Tests: new bundle_test.go (member hit/absent/no-file/hot-reload/corrupt);
handler_test.go rewritten for bundle-overrides-embedded, absent-member→
embedded, unknown-tool 503, conditional-GET for both sources; dispatch test
covers bundle override + /.zddc.zip 404 + availability rules. go build/vet/
test ./... all green; gofmt clean. Docs (AGENTS.md, ARCHITECTURE.md) updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:59:28 -05:00

141 lines
4.7 KiB
Go

package apps
import (
"crypto/sha256"
"encoding/hex"
"log/slog"
"net/http"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// Server resolves tool HTML for a request: bundle member → embedded. The
// on-disk-at-path tier (operator override) is handled UPSTREAM by the
// dispatcher's stat-first static handler, so by the time Serve runs no real
// file exists at the path. Server does NOT decide whether the app is
// available at the directory — that's AppAvailableAt's job, called from
// dispatch before Serve.
type Server struct {
Root string
BuildVer string // baked into X-ZDDC-Source for embedded responses
Bundle *Bundle
Logger *slog.Logger
}
// NewServer constructs a Server bound to the site-root config bundle.
func NewServer(root, buildVer string) *Server {
root = filepath.Clean(root)
logger := slog.Default()
return &Server{
Root: root,
BuildVer: buildVer,
Bundle: NewBundle(root, logger),
Logger: logger,
}
}
// 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 (or bundle) 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 "", ""
}
// resolveBytes applies the local override precedence (tiers 2 then 3; tier 1
// is handled upstream). Returns the HTML body, the X-ZDDC-Source tag, and
// whether to use the memoized embedded ETag (vs a body-hash ETag).
func (s *Server) resolveBytes(app string) (body []byte, sourceTag string, embedded, ok bool) {
if s.Bundle != nil {
if b, found := s.Bundle.Member(app + ".html"); found {
return b, "bundle:" + app + ".html", false, true
}
}
if b := EmbeddedBytes(app); len(b) > 0 {
return b, "embedded:" + app + "@" + s.BuildVer, true, true
}
return nil, "", false, false
}
// Serve resolves and writes the response. Caller has already verified:
// - no real file exists at the request path (so tier 1 didn't apply)
// - AppAvailableAt(root, requestDir, app) is true
// - ACL passes for requestDir
//
// chain and requestDir are retained in the signature for call-site stability
// and future per-directory resolution; the current local model is path-
// independent (a bundle member or the embedded default).
func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, _ zddc.PolicyChain, _ string) {
body, tag, embedded, ok := s.resolveBytes(app)
if !ok {
w.Header().Set("Retry-After", "60")
http.Error(w,
"503 Service Unavailable\n\n"+
"This zddc-server has no embedded fallback for "+app+" and no\n"+
"\""+app+".html\" in the site .zddc.zip bundle.\n"+
"Rebuild the binary against the latest tool HTMLs, or add the\n"+
"file to .zddc.zip.\n",
http.StatusServiceUnavailable)
return
}
etag := bodyETag(body)
if embedded {
etag = EmbeddedETag(app)
}
writeWithETag(w, r, body, etag, "text/html; charset=utf-8", tag)
}
// writeWithETag writes body with a strong ETag, cache-friendly headers, and
// short-circuits to 304 Not Modified when the client's If-None-Match matches.
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.
func bodyETag(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])[:32]
}