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 // "/.html" pattern for one of the five canonical apps, plus the // directory (relative to root) the request is rooted at. // // Special case: GET / and GET /index.html both resolve to landing. 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 "mdedit.html": return "mdedit", 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=` 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) }