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 // "/.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 `/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] }