Adds internal/apps/ package serving the five tool HTMLs at virtual paths based on the surrounding folder name convention: archive every directory (multi-project, project, archive, vendor) classifier any Incoming/Working/Staging directory and subtree mdedit any Working directory and subtree transmittal any Staging directory and subtree landing only at deployment root The current-stable build of every tool is //go:embed'd into the binary at compile time — that's the default with zero config. Operators override per-directory via .zddc apps: entries; closer-to-leaf wins. Spec syntax (in any apps: value): stable / beta / alpha / :stable channel v0.0.4 / v0.0 / v0 / :v0.0.4 version https://my-mirror/releases URL prefix only https://my-mirror/releases:beta URL prefix + channel https://my-fork/archive.html terminal full URL ./local.html / /abs/path.html terminal local path The special apps.default key provides a baseline URL prefix and channel inherited by any app not overridden per-name. Per-axis cascade: a deeper .zddc can override the URL, the channel, or both. Cascade walks root→leaf; default applies first at each level, then the per-app entry. Terminal sources (paths and full .html URLs) short-circuit composition; deeper non-terminal entries override parent terminals. URL sources fetch once on first request and cache forever in <ZDDC_ROOT>/_app/<host>/<path> — different upstreams with the same filename stay distinct. No background refresh, no SHA-256 verification: operators delete the cache file to force a refetch. Concurrent misses for the same source dedupe via a 30-line hand-rolled singleflight. Per-request override: any user can append ?v=<spec> to a tool URL (e.g. ?v=beta, ?v=v0.0.4, ?v=:alpha, ?v=https://mirror/releases:beta) to ask for a different build for one request. Security: ?v= serves ONLY versions already in the cache (cache miss returns 404; path sources are rejected outright with 400). Users cannot trigger arbitrary upstream fetches via crafted URLs. Failed URL fetches (network down, 5xx) fall back to embedded with a one-time WARN log. The X-ZDDC-Source response header reports what served: fetch:URL / cache:URL / path:/abs / embedded:<app>@<build>. Wire-in (cmd/zddc-server/main.go): dispatch routes <dir>/<app>.html through apps.MatchAppHTML + AppAvailableAt + apps.Server.Serve when no real file exists. Direct URL access to /_app/... is blocked at the dispatch layer — cached files must go through the apps resolver so they get correct Content-Type and ACL gating. Schema (internal/zddc/file.go): ZddcFile gains Apps map[string]string for cascade overrides. Validator (internal/zddc/validate.go) accepts the special "default" key alongside the five canonical app names and all spec forms. Removes ZDDC_APPS_* env vars (no admin UI, no refresh interval, no upstream allow-list — the simpler model has fewer knobs). 40+ unit tests across the new package: parser shapes, cascade resolution with default+per-app interactions, terminal short-circuit semantics, ?v= cache-only enforcement, embedded fallback, atomic cache writes, singleflight dedup. Plus end-to-end dispatch tests in cmd/zddc-server/main_test.go. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
184 lines
5.9 KiB
Go
184 lines
5.9 KiB
Go
package apps
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"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
|
|
// "<dir>/<app>.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
|
|
}
|
|
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=<spec>` 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, 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, 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, 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, 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)
|
|
}
|
|
|
|
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("X-ZDDC-Source", sourceHeader)
|
|
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
|
|
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(body))
|
|
}
|
|
|
|
func (s *Server) serveEmbedded(w http.ResponseWriter, 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
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("X-ZDDC-Source", "embedded:"+app+"@"+s.BuildVer)
|
|
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
|
|
_, _ = w.Write(body)
|
|
}
|