ZDDC/zddc/internal/apps/handler.go
ZDDC e7f6334daa chore: retire mdedit tool — markdown editor lives in browse now
mdedit/ is gone. Its functionality moved into browse's preview plugin
(browse/js/preview-markdown.js) — YAML front matter editing, outline,
and on-demand DOCX/HTML/PDF download all happen there. Browse is the
default_tool for working/ + reviewing/ as of the previous commit, so
existing URLs of the form /<project>/working land on browse without
operator action.

Removed:

  • mdedit/ source tree (Toast UI app, CSS, JS, template, build.sh)
  • zddc/internal/apps/embedded/mdedit.html (//go:embed blob)
  • tests/mdedit.spec.js + the "mdedit" project in playwright.config.js
  • mdedit entries in zddc/internal/apps/embed.go (//go:embed, var,
    switch case in EmbeddedBytes)
  • "mdedit" in zddc/internal/zddc/validate.go AppNames + the matching
    error-message app list
  • "mdedit.html" branch in zddc/internal/apps/handler.go MatchAppHTML
  • mdedit case in tests (handler_test.go, validate_test.go,
    zddchandler_test.go) — test fixtures now use browse/classifier
  • mdedit from build (per-tool build.sh loop, tool-list literals,
    composer cards) and shared/build-lib.sh ZDDC_RELEASE_TOOLS
  • mdedit from freshen-channel's tool list and usage banner
  • mdedit-specific paragraphs in AGENTS.md and ARCHITECTURE.md;
    Markdown Editor section in ARCHITECTURE.md rewritten to point at
    browse/js/preview-markdown.js
  • mdedit from CLAUDE.md, README.md, zddc/README.md tool lists

Historical mdedit_v*.html / mdedit_v*.html.sig files in
/srv/zddc/releases/ on the deploy host are immutable history — they
stay where they are. The next ./build release cut will simply not
produce new mdedit_v* artifacts.
2026-05-13 10:34:31 -05:00

208 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package apps
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"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 "browse.html":
return "browse", 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, r, 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, r, 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, r, 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, r, 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)
}
// writeWithETag writes body with a strong ETag derived from `etag`, the
// cache-friendly headers, and short-circuits to 304 Not Modified when the
// client's `If-None-Match` matches. `max-age=0, must-revalidate` means the
// browser revalidates on every load — and the matching ETag returns 304
// with empty body, so the steady-state cost of a reload is ~200 bytes
// instead of the full HTML payload (50920 KB depending on the tool).
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. Used
// for the URL/path-sourced response path (the bytes vary per cache-fetch
// or per file read, so memoizing per-app would be wrong).
func bodyETag(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])[:32]
}
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) {
writeWithETag(w, r, body, bodyETag(body), "text/html; charset=utf-8", sourceHeader)
}
func (s *Server) serveEmbedded(w http.ResponseWriter, r *http.Request, 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
}
writeWithETag(w, r, body, EmbeddedETag(app),
"text/html; charset=utf-8",
"embedded:"+app+"@"+s.BuildVer)
}