141 lines
4.7 KiB
Go
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]
|
|
}
|