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