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]
}