package main import ( "context" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "path/filepath" "sort" "strings" "syscall" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/apps" "codeberg.org/VARASYS/ZDDC/zddc/internal/archive" "codeberg.org/VARASYS/ZDDC/zddc/internal/auth" "codeberg.org/VARASYS/ZDDC/zddc/internal/cache" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/convert" appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs" "codeberg.org/VARASYS/ZDDC/zddc/internal/handler" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "github.com/klauspost/compress/gzhttp" "gopkg.in/natefinch/lumberjack.v2" ) // version is the binary's own version, injected at build time via // `-ldflags="-X main.version=..."`. Defaults to "dev" for unreleased // builds; release pipelines pass the result of `git describe --tags`. var version = "dev" func main() { // --print-rego: dump the bundled reference Rego skeleton and exit. // A starting point for operators standing up an external OPA: it models // the read-ACL cascade only and is fail-closed for writes (NOT a mirror // of the internal decider), so it must be extended before granting writes. // // --print-rego → read-ACL skeleton (fail-closed) // --print-rego=standard → same for _, a := range os.Args[1:] { switch a { case "--print-rego", "--print-rego=standard": fmt.Print(policy.ReferenceRego) return case "show-defaults", "--show-defaults": // Emit the embedded baseline as a .zddc.zip (per-depth policy // tree, "*" wildcard members) to stdout. Redirect into a bundle // (e.g. `> $ZDDC_ROOT/.zddc.zip`) to start from the shipped // defaults and edit/add/delete individual members; the bundle // participates in the cascade (child wins). Drop it at any // directory to mount a subtree; add inherit:false + // acl.inherit:false to fully replace the baseline there. b, err := zddc.EmbeddedDefaultsZip() if err != nil { fmt.Fprintln(os.Stderr, "show-defaults:", err) os.Exit(1) } _, _ = os.Stdout.Write(b) return } } cfg, err := config.Load(os.Args[1:]) if errors.Is(err, config.ErrHelpRequested) { config.Usage(os.Stderr) os.Exit(0) } if errors.Is(err, config.ErrVersionRequested) { printVersions(os.Stdout) os.Exit(0) } if err != nil { fmt.Fprintf(os.Stderr, "configuration error: %v\n\nRun with --help for usage.\n", err) os.Exit(1) } logRing := setupLogger(cfg.LogLevel) embedded := apps.EmbeddedVersions() slog.Info("zddc-server starting", "version", version, "root", cfg.Root, "addr", cfg.Addr, "embedded_apps", embeddedVersionsForLog(embedded)) // Probe pandoc + chromium for the MD→{docx,html,pdf} endpoint. // Non-fatal: if either binary isn't on PATH (operator running // zddc-server outside the runtime image), conversion requests // return 503 and everything else keeps working. // // In the production runtime image, "pandoc" and "chromium-browser" // on PATH resolve to wrapper scripts at /usr/local/bin/ // that put the real binary into a cgroup v2 + bwrap sandbox // before exec'ing it. zddc-server is unaware — it just sees // the corresponding tool's behavior. The wrapper reads // ZDDC_CONV_MEM_MAX, ZDDC_CONV_PIDS_MAX, and ZDDC_SCRATCH from // the child env to drive cgroup setup + scratch-dir bind mount. convert.SetBinaries(cfg.ConvertPandocBinary, cfg.ConvertChromiumBinary) convert.SetScratchDir(cfg.ConvertScratchDir) probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second) convert.Probe(probeCtx) probeCancel() convert.ConfigureLimits(cfg.ConvertMemMiB, cfg.ConvertPIDs, cfg.ConvertTimeout) // Client mode short-circuit: when cfg.Upstream is set, this binary // runs as a downstream proxy/cache/mirror rather than a master. // The master-side machinery below (archive index, watcher, apps // server, policy decider, ACL middleware, token store) is all // skipped — every request flows through the cache layer, which // forwards to upstream and (in cache/mirror modes) persists the // response under cfg.Root. if cfg.Upstream != "" { runClient(cfg) return } // Build archive index slog.Info("building archive index...") start := time.Now() idx, err := archive.BuildIndex(cfg.Root) if err != nil { slog.Error("failed to build archive index", "err", err) os.Exit(1) } slog.Info("archive index built", "duration", time.Since(start)) // Apps fetch+cache subsystem. appsServer, err := setupApps(cfg) if err != nil { slog.Error("failed to set up apps subsystem", "err", err) os.Exit(1) } // TLS config tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg) if err != nil { slog.Error("failed to configure TLS", "err", err) os.Exit(1) } // Context for graceful shutdown ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer cancel() // Start file-system watcher (best-effort live updates; misses cross-client // writes on SMB/CIFS mounts since inotify only sees local-kernel events). watcher, err := archive.NewWatcher(cfg.Root, idx) if err != nil { slog.Warn("failed to start filesystem watcher (index will not auto-update)", "err", err) } else { go func() { if err := watcher.Start(ctx); err != nil && ctx.Err() == nil { slog.Error("watcher error", "err", err) } }() } // Periodic full re-scan. Required when the served root is an SMB/CIFS // share (Azure Files, etc.) — fsnotify sees only events the local kernel // generates, so writes from other clients to the share are invisible to // the watcher above. A periodic full walk closes that gap. if cfg.ArchiveRescanInterval > 0 { go runPeriodicRescan(ctx, cfg.Root, idx, cfg.ArchiveRescanInterval) } else { slog.Info("archive periodic rescan disabled (interval=0)") } // HTTP handler mux := http.NewServeMux() // Middleware chain (outermost → innermost): // ACLMiddleware — extract email from cfg.EmailHeader, store in // request context. Outermost so the email is // available to AccessLogMiddleware (Go's context // propagates DOWN the chain via r.WithContext, not // UP — so AccessLog can't read a context value set // by an inner middleware after next.ServeHTTP // returns). // AccessLogMiddleware — structured per-request log; reads email from // the context the outer ACL middleware set. // CORSMiddleware — Origin / preflight handling. // dispatch — the actual request handler. auditLogger := setupAccessAuditLog(cfg.AccessLog) // Construct the policy decider once at startup. ZDDC_OPA_URL=internal // (default) routes decisions through the in-process Go evaluator; // http(s):// or unix:// values send each decision to an external // OPA-compatible server (federal customers, custom Rego policies). deciderCfg := policy.Config{ URL: cfg.OPAURL, FailOpen: cfg.OPAFailOpen, CacheTTL: cfg.OPACacheTTL, } // Translate "0" (operator opt-out) to "disable cache" (negative TTL is // the policy package's sentinel for "skip the wrapper"). if deciderCfg.CacheTTL == 0 { deciderCfg.CacheTTL = -1 } decider, err := policy.New(deciderCfg) if err != nil { slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err) os.Exit(1) } // --no-auth swaps the configured decider for one that allows // everything. Logged at warn level so an operator who set this // inadvertently sees it on every restart. if cfg.NoAuth { decider = policy.AllowAllDecider{} slog.Warn("--no-auth enabled: ACL enforcement is disabled. Every request is permitted regardless of .zddc rules.") } slog.Info("policy decider ready", "mode", policyModeLabel(cfg.OPAURL), "url", cfg.OPAURL, "cache_ttl", cfg.OPACacheTTL, "no_auth", cfg.NoAuth) // Bootstrap sanity: warn loudly (but don't fail) when the root .zddc // grants nobody anything. Embedded internal/zddc/defaults/ ships with empty // role members, so a fresh deployment refuses every request until the // operator populates the file. warnIfNoBootstrap(cfg) // Token store: bearer-token issuance and validation. // Persists under /.zddc.d/tokens/ — already excluded // from public listings (fs.ListDirectory dot-prefix filter) and // direct serving (dispatch's reserved-prefix guard). Failures here // are non-fatal: token-based auth is opt-in per request, and // header-based auth keeps working without it. tokens, err := auth.NewStore(cfg.Root) if err != nil { slog.Warn("could not initialise token store; bearer-token auth disabled", "err", err) tokens = nil } // Innermost handler: dispatch. var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dispatch(cfg, idx, logRing, appsServer, tokens, w, r) }) inner = handler.CORSMiddleware(cfg, inner) // HSTS only when zddc-server itself is the TLS-terminating endpoint. // Behind an upstream proxy terminating TLS (cfg.TLSMode=="none"), the // proxy is responsible for HSTS — adding it here would conflict. if useTLS { inner = handler.HSTSMiddleware(inner) } inner = handler.AccessLogMiddleware(cfg, auditLogger, inner) inner = handler.ACLMiddleware(cfg, decider, tokens, inner) mux.Handle("/", inner) gzWrapper, err := newGzipWrapper() if err != nil { slog.Error("gzhttp wrapper init", "err", err) os.Exit(1) } srv := &http.Server{ Addr: cfg.Addr, Handler: gzWrapper(mux), TLSConfig: tlsCfg, // Conservative timeouts. ReadHeaderTimeout caps how long a slow // client can hold the connection before sending request headers // (the slowloris vector). Read/Write timeouts cap full-request // processing — directory listings + tool HTML serving complete // in milliseconds even with gzip, so 60s is generous. IdleTimeout // is the keep-alive ceiling between requests on the same conn. ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } // Serve in goroutine if useTLS { go func() { slog.Info("listening", "addr", cfg.Addr, "tls", true) if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { slog.Error("server error", "err", err) cancel() } }() } else { go func() { slog.Info("listening", "addr", cfg.Addr, "tls", false) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server error", "err", err) cancel() } }() } <-ctx.Done() slog.Info("shutting down...") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { slog.Error("shutdown error", "err", err) } slog.Info("stopped") } // runClient is the entry point when cfg.Upstream is set — a separate // lifecycle from the master-side main(), with no archive index, no // apps server, no watcher, no policy decider, no ACL middleware, no // token store. The cache layer (zddc/internal/cache) is the entire // request handler; AccessLog + HSTS + gzip wrap it the same way they // wrap dispatch in master mode. func runClient(cfg config.Config) { cacheLayer, err := cache.New(cfg) if err != nil { slog.Error("client mode init failed", "err", err) os.Exit(1) } slog.Info("client mode active", "upstream", cacheLayer.Upstream(), "mode", cacheLayer.Mode(), "no_auth", cfg.NoAuth, "skip_tls_verify", cfg.SkipTLSVerify) if cfg.NoAuth { slog.Warn("--no-auth enabled: incoming requests are not ACL-checked locally; trusting upstream's filtering.") } // Mirror walker: only constructed when --mode=mirror with at least // one subtree (config validation ensures a default of "/" applies // when the operator opted into mirror without specifying). Hooks // itself into cacheLayer.onAccess; no further wiring needed here. if cfg.Mode == "mirror" && len(cfg.MirrorSubtree) > 0 { sched, err := cache.NewMirrorScheduler(cacheLayer, cfg.MirrorSubtree, cfg.MirrorMinInterval, 0) if err != nil { slog.Error("mirror scheduler init failed", "err", err) os.Exit(1) } if sched != nil { slog.Info("mirror walker armed", "subtrees", sched.Subtrees(), "min_interval", sched.MinInterval()) } } // Outbox: persist + replay offline writes. Only enabled in cache // or mirror modes (proxy mode doesn't persist anything by design). // A failure here is non-fatal: writes still flow live, but // transport errors return 503 instead of being queued. ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer cancel() if cfg.Mode != "proxy" { outbox, err := cache.NewOutbox(cacheLayer) if err != nil { slog.Warn("outbox init failed; offline writes will return 503", "err", err) } else { cacheLayer.SetOutbox(outbox) pending, _ := outbox.Pending() slog.Info("outbox ready", "dir", outbox.Dir(), "pending_at_startup", len(pending)) go outbox.RunReplayLoop(ctx) } } tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg) if err != nil { slog.Error("failed to configure TLS", "err", err) os.Exit(1) } auditLogger := setupAccessAuditLog(cfg.AccessLog) var inner http.Handler = cacheLayer inner = handler.CORSMiddleware(cfg, inner) if useTLS { inner = handler.HSTSMiddleware(inner) } inner = handler.AccessLogMiddleware(cfg, auditLogger, inner) mux := http.NewServeMux() mux.Handle("/", inner) gzWrapper, err := newGzipWrapper() if err != nil { slog.Error("gzhttp wrapper init", "err", err) os.Exit(1) } srv := &http.Server{ Addr: cfg.Addr, Handler: gzWrapper(mux), TLSConfig: tlsCfg, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } if useTLS { go func() { slog.Info("listening", "addr", cfg.Addr, "tls", true, "client_mode", true) if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { slog.Error("server error", "err", err) cancel() } }() } else { go func() { slog.Info("listening", "addr", cfg.Addr, "tls", false, "client_mode", true) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server error", "err", err) cancel() } }() } <-ctx.Done() slog.Info("shutting down...") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { slog.Error("shutdown error", "err", err) } // Drain background cache work (revalidation kicked off on hits) // before exiting, so in-flight sidecar writes finish rather than // being abandoned mid-flight. cacheLayer.Wait() slog.Info("stopped") } // setupAccessAuditLog constructs a slog.Logger writing JSON lines to a // size-rotated file at the operator-configured path. Returns nil if no // path is configured (operator opted out via --access-log=) — // AccessLogMiddleware then logs only to stderr. // // Auto-creates the parent directory (mode 0750) if missing, so the // default path of /.zddc.d/logs/access-.log "just // works" on a fresh deployment without operator setup. // // Every record is tagged with `host` (os.Hostname). When multiple // zddc-server replicas serve the same dataset (and write to the same // .zddc.d/logs/ directory via per-host filenames), the host field also // makes downstream-aggregated streams disambiguable. // // Rotation: lumberjack — 100 MB per file, 10 backups, 90-day max age, // gzip compression on rotated files. // // File-permission posture: lumberjack creates new logs with mode 0600 // (running user only). For multi-user audit access, the operator should // use group-readable parent directory permissions and either chmod the // log out-of-band or run a forwarder that has its own read access. // policyModeLabel collapses cfg.OPAURL to a one-word mode label for the // startup log so operators can grep for the active decider quickly. func policyModeLabel(opaURL string) string { switch { case opaURL == "" || strings.EqualFold(opaURL, "internal"): return "internal" case strings.HasPrefix(opaURL, "unix://"): return "external-unix" case strings.HasPrefix(opaURL, "https://"): return "external-https" case strings.HasPrefix(opaURL, "http://"): return "external-http" default: return "unknown" } } func setupAccessAuditLog(path string) *slog.Logger { if path == "" { return nil } if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { slog.Error("could not create access-log directory; falling back to stderr-only", "dir", filepath.Dir(path), "err", err) return nil } rotator := &lumberjack.Logger{ Filename: path, MaxSize: 100, // megabytes per file before rotation MaxBackups: 10, MaxAge: 90, // days Compress: true, } host, _ := os.Hostname() if host == "" { host = "unknown" } // JSON handler — line-delimited JSON is the format every standard // log shipper (Vector, Loki promtail, fluentbit, journalbeat) parses // natively, and stays grep-friendly for ad-hoc inspection. h := slog.NewJSONHandler(rotator, &slog.HandlerOptions{Level: slog.LevelInfo}) slog.Info("access log file enabled", "path", path, "host", host, "max_size_mb", 100, "max_backups", 10, "max_age_days", 90) return slog.New(h).With("host", host) } // newGzipWrapper builds the gzip middleware applied to the entire mux. // MinSize(1024) skips compressing tiny responses where the framing // overhead exceeds the savings (304 Not Modified, error pages, small // JSON listings under ~1 KB). The wrapper honors Accept-Encoding (passes // through unchanged when the client doesn't advertise gzip), appends // Vary: Accept-Encoding automatically, and passes through 304s untouched. // Yields ~75% size reduction on the larger embedded HTML responses // (browse: ~2 MB → a few hundred KB on the wire). // // Extracted so tests can construct an equivalent wrapper without going // through the full main() server boot. func newGzipWrapper() (func(http.Handler) http.HandlerFunc, error) { return gzhttp.NewWrapper(gzhttp.MinSize(1024)) } // setupApps builds the tool-HTML server. Resolution is LOCAL-ONLY: a real // file on disk at the request path (handled upstream by dispatch) → a // ".html" member of the site-root /.zddc.zip bundle → the // embedded default. No fetch, no cache, no signatures. func setupApps(cfg config.Config) (*apps.Server, error) { return apps.NewServer(cfg.Root, version), nil } // warnIfNoBootstrap fires a startup slog.Warn when the root .zddc grants // nobody anything — the embedded defaults ships with empty role // members, so a deployment without operator-populated admins / acl // permissions / role members refuses every request. Skipped under // --no-auth (auth disabled; warning would be redundant). Per-project // .zddc files may legitimately carry all grants, so the warning text // tells the operator they can ignore it in that case. // // Master-mode only — the bootstrap concept doesn't apply in client // (proxy/cache/mirror) mode, where cfg.Root is the cache directory. func warnIfNoBootstrap(cfg config.Config) { if cfg.NoAuth { return } rootPath := filepath.Join(cfg.Root, ".zddc") rootZddc, err := zddc.ParseFile(rootPath) if err != nil { slog.Warn("root .zddc not present or unreadable; ZDDC will refuse every request until you create it. "+ "See README.md '## Deploy: bootstrap config' or AGENTS.md '## zddc-server / ### Bootstrap config'.", "path", rootPath, "err", err) return } hasAdmin := len(rootZddc.Admins) > 0 hasPerm := len(rootZddc.ACL.Permissions) > 0 hasRoleMembers := false for _, role := range rootZddc.Roles { if len(role.Members) > 0 { hasRoleMembers = true break } } if !hasAdmin && !hasPerm && !hasRoleMembers { slog.Warn("root .zddc grants nobody anything (no admins, no acl.permissions, no role members). "+ "ZDDC will refuse every request until you populate it. "+ "If you intentionally grant only at per-project levels, you can ignore this. "+ "See README.md '## Deploy: bootstrap config' or AGENTS.md '## zddc-server / ### Bootstrap config'.", "path", rootPath) } } // printVersions writes the binary version + the build label of every app // embedded into the binary. Called by --version and reused for the // startup log line. func printVersions(w *os.File) { fmt.Fprintf(w, "zddc-server %s\n\n", version) embedded := apps.EmbeddedVersions() if len(embedded) == 0 { fmt.Fprintln(w, "Embedded tools: (none — run `sh build.sh` to populate)") return } fmt.Fprintln(w, "Embedded tools:") keys := make([]string, 0, len(embedded)) for k := range embedded { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { fmt.Fprintf(w, " %-12s %s\n", k, embedded[k]) } } // embeddedVersionsForLog formats the embedded-versions map as a single // short string suitable for the startup `log/slog` line. Sorted by app // name for stable output. func embeddedVersionsForLog(embedded map[string]string) string { if len(embedded) == 0 { return "(none)" } keys := make([]string, 0, len(embedded)) for k := range embedded { keys = append(keys, k) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { // Strip any " · timestamp · sha" suffix so the log line stays compact; // operators who want full detail run `zddc-server --version`. v := embedded[k] if i := strings.Index(v, " "); i > 0 { v = v[:i] } parts = append(parts, k+"="+v) } return strings.Join(parts, " ") } // serveSpecializedNoSlash handles a GET/HEAD request to a directory // URL without a trailing slash by serving the directory's cascade- // declared default_tool — the "specialized app" half of the slash/ // no-slash routing convention. (The slash half is DirTool, resolved // in handler.ServeDirectory; it defaults to "browse".) Works for both // real on-disk directories and purely-virtual ones (default_tool may // come from an ancestor's paths: tree). // // Returns true once it has written a response. Returns false when // there is nothing specialized to serve — no default_tool, or // default_tool=tables with no matching table spec, or the tool isn't // available at this path — so the caller falls through to its own // fallback (landing at a project root, then a 302 to the slash form // where DirTool/browse renders the listing). // // ACL is enforced here against dirAbs's effective policy, so every // default_tool route is gated identically regardless of which call // site reached it. dirAbs is the directory's filesystem path (it need // not exist on disk); urlPath is the request URL path; email is the // authenticated user (may be empty). func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request, dirAbs, urlPath, email string) bool { app := apps.DefaultAppAt(cfg.Root, dirAbs) // An explicit `views.dir` in the cascade overrides the default_tool- // derived app for the no-slash directory URL — the generalization's // dir-shape routing. default_tool remains the sugar fallback (ViewAt // returns it when no views.dir is declared), so existing deployments // are unaffected. if v, ok := zddc.ViewAt(cfg.Root, dirAbs, "dir"); ok && v.Tool != "" { app = v.Tool } if app == "" { return false } chain, _ := zddc.EffectivePolicy(cfg.Root, dirAbs) if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return true } if app == "tables" { // tables isn't an apps-subsystem app — it's the table view, // served by handler.ServeTable from a synthesized // /table.html request (which also resolves the embedded // default-MDL spec for archive//mdl). No spec → caller // falls through. if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+"/table.html"); tr != nil { handler.ServeTable(cfg, tr, w, r) return true } return false } if appsSrv != nil && apps.AppAvailableAt(cfg.Root, dirAbs, app) { appsSrv.Serve(w, r, app, chain, dirAbs) return true } return false } // splitZipPath detects a "<…>.zip/" URL: a path where some // ancestor segment resolves to a regular .zip file on disk and there's // a tail segment after it (or a trailing slash). On a match it returns // the zip's absolute filesystem path and the slash-separated member // path inside the zip ("" when the URL is "<…>.zip/" with nothing // after). ok is false for everything else — including "<…>.zip" with // no trailing slash (that's a plain file download, handled downstream). // // Segments are stat'd one at a time against cfg.Root; case-folding has // already been applied to on-disk segments by appfs.ResolveCanonical // upstream, so the .zip segment matches by exact name here. The // per-segment os.Stat walk is gated by a cheap ".zip/" substring check // at the call site, so it never runs for ordinary requests. func splitZipPath(fsRoot, urlPath string) (zipAbs, member string, ok bool) { trimmed := strings.Trim(urlPath, "/") if trimmed == "" { return "", "", false } segs := strings.Split(trimmed, "/") cur := fsRoot for i, seg := range segs { cur = filepath.Join(cur, seg) if cur != fsRoot && !strings.HasPrefix(cur, fsRoot+string(filepath.Separator)) { return "", "", false } info, err := os.Stat(cur) if err != nil { return "", "", false // a segment doesn't exist on disk — not a zip path } if info.IsDir() { continue } // cur is a non-directory. Only a regular .zip file with a tail // (or trailing slash) is "browse into the zip"; anything else // falls through to the normal file path. if !info.Mode().IsRegular() || !strings.EqualFold(filepath.Ext(seg), ".zip") { return "", "", false } if i < len(segs)-1 { return cur, strings.Join(segs[i+1:], "/"), true } // Last segment is the .zip itself: only a trailing slash means // "browse into it" (member == root); a bare "<…>.zip" is a file. if strings.HasSuffix(urlPath, "/") { return cur, "", true } return "", "", false } return "", "", false } // configEditorForBundle reports whether the request principal holds STANDING // config-edit authority over the directory that holds the .zddc.zip config // bundle referenced by urlPath — a subtree admin (admins: cascade) or `a`-verb // holder, WITHOUT elevation. Both browsing the bundle's members and writing // them are gated by this: config you administer is visible+editable without a // toggle. The bundle is NOT wide-readable, because it packs many subtrees' // policy into one file — exposing it to every reader would leak a tightened // subtree's rules; per-level transparency is served by ServeZddcFile instead. // Elevation isn't required here; it only adds the WORM/destructive overrides // elsewhere. Works for every bundle URL shape (bare, trailing-slash listing, // and /) since it keys off the segment before the bundle name. func configEditorForBundle(cfg config.Config, r *http.Request, urlPath string) bool { p := handler.PrincipalFromContext(r) if p.Email == "" { return false } parent := make([]string, 0) for _, seg := range strings.Split(strings.Trim(urlPath, "/"), "/") { if strings.EqualFold(seg, apps.BundleName) { break } parent = append(parent, seg) } dir := filepath.Join(cfg.Root, filepath.FromSlash(strings.Join(parent, "/"))) chain, _ := zddc.EffectivePolicy(cfg.Root, dir) return zddc.IsConfigEditor(chain, p.Email) } // dispatch routes a request to the appropriate handler. func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) { // URL paths are case-insensitive: resolve each segment against the // on-disk casing under cfg.Root, preferring the all-lowercase // variant when multiple case variants exist (lowercase is the // project-wide canonical case for folders). The walk stops at the // first segment that doesn't exist on disk so virtual prefixes // (.profile, .archive, .tokens) and 404 paths flow through with // their tail preserved verbatim. Downstream handlers see the // canonical r.URL.Path; the access log captures the as-typed path // before this rewrite. if absPath, canonical, ok := appfs.ResolveCanonical(cfg.Root, r.URL.Path); ok { _ = absPath // Restore trailing slash so directory routing (which redirects // no-trailing-slash requests) keeps its existing semantics. if strings.HasSuffix(r.URL.Path, "/") && !strings.HasSuffix(canonical, "/") && canonical != "/" { canonical += "/" } if canonical != r.URL.Path { r.URL.Path = canonical r.URL.RawPath = "" } } urlPath := r.URL.Path email := handler.EmailFromContext(r) // Profile routes — the page itself is reachable to anyone (anonymous // included); admin-only sub-resources (whoami / config / logs / // projects / .zddc editor) keep their existing per-resource 404 // existence-leakage gates inside ServeProfile. if urlPath == handler.ProfilePathPrefix || strings.HasPrefix(urlPath, handler.ProfilePathPrefix+"/") { handler.ServeProfile(cfg, ring, idx, w, r) return } // Token self-service: HTML page at /.tokens, JSON API at // /.api/tokens. Both routes require an authenticated user (the // existing email middleware injects the email from upstream auth). // Both routes refuse to serve when no token store is available // (e.g. NewStore failed at startup) — handled inside the handlers. if urlPath == handler.TokensPathPrefix || urlPath == handler.TokensPathPrefix+"/" { handler.ServeTokensPage(cfg, tokens, w, r) return } if urlPath == handler.TokensAPIPathPrefix || strings.HasPrefix(urlPath, handler.TokensAPIPathPrefix+"/") { handler.ServeTokensAPI(cfg, tokens, w, r) return } // Recognised markdown front-matter fields + editor placeholder (JSON). // The browse markdown editor fetches this to hint the valid keys; it's // static, read-only, and leaks nothing, so no auth gate. if urlPath == handler.FrontMatterTemplatePath { handler.ServeFrontMatterTemplate(w, r) return } // The .zddc JSON Schema (machine grammar) — drives the .zddc form view + // client validation. Static, read-only, no auth. if urlPath == handler.ZddcSchemaPath { handler.ServeZddcSchema(w, r) return } // /_apps/ — virtual, public directory of the standalone tool HTMLs, so // people can download one and run it against their own local filesystem. // Tool UI only, no data, no auth. Before the reserved-prefix ('_'/'.') // guard so it isn't 404'd. if urlPath == "/_apps" { http.Redirect(w, r, handler.AppsVirtualPrefix, http.StatusMovedPermanently) return } if strings.HasPrefix(urlPath, handler.AppsVirtualPrefix) { handler.ServeApps(appsSrv, w, r) return } // Auth check endpoints — machine-only forward_auth targets used by // upstream proxies (e.g. the dev-shell pod's Caddy in front of // code-server) to gate routes on root-admin status. Handled before // the reserved-prefix guard below so the .auth namespace passes // through without being 404'd by the dot-prefix rule. if urlPath == handler.AuthPathPrefix+"/admin" { handler.ServeAuthAdmin(cfg, w, r) return } // (Project list at GET / with Accept: application/json used to be // served by a bespoke handler that returned a custom JSON shape. // Removed in favour of routing /through the generic ServeDirectory: // the directory listing now carries `title` per entry, so the // landing page reads project names from the same shape every other // listing has. Single canonical wire format > exception that // reveals a special perspective.) // Split path into segments segments := strings.Split(strings.Trim(urlPath, "/"), "/") // One reserved namespace: /.zddc.d/ holds all server bookkeeping // (tokens, history, logs, apps + converted caches). It is admin-only at // every depth — a hard rule that overrides operator ACLs so a broad grant // (e.g. `*: rwcd`) can never expose the token store — gated here by segment // name and mirrored on the write path in authorizeAction. 404 (not 403) // keeps the store existence-hidden from non-admins. // // This gate runs BEFORE the raw .zddc view below so a request for the // reserve's own cascade (e.g. //.zddc.d/.zddc) is existence-hidden // too — otherwise IsZddcFileRequest would match the leaf and ServeZddcFile // would leak the reserve's effective cascade to a non-admin. // // Everything else dot-/underscore-prefixed is ordinary ACL-governed // content: the listing pipeline (internal/fs, internal/listing) hides such // entries from directory views unless ?hidden=1, but direct URL access is // governed by the ACL chain like any other file. (.profile/.tokens/.auth // were routed above; non-reserved .zddc GET goes to ServeZddcFile just // below and .zddc writes fall through to ServeFileAPI; .archive follows.) // // Bearer-token validation reads .zddc.d/tokens via the filesystem in // ACLMiddleware, before this gate, so it is unaffected. if handler.HasReservedSidecar(urlPath) && !handler.ActiveAdminForSidecar(cfg, r, urlPath) { http.NotFound(w, r) return } // The site-root config bundle /.zddc.zip is config, not // ordinary content: existence-hidden over HTTP for everyone EXCEPT a // standing config-editor over its directory (a subtree admin or `a`-verb // holder — NO elevation required), who may browse it in the file tree. // It's NOT wide-readable because one file packs many subtrees' policy; // per-level transparency is served by ServeZddcFile. For a config-editor // every bundle URL falls through to normal handling — GET / lists // its members (the zip-as-directory intercept below), GET /member // extracts one, and a bare GET downloads it. Everyone else gets // 404 for every form, which also keeps individual members from being // fetched by name. The server reads members from the filesystem internally // (apps.Bundle) to resolve tool HTML — that path never goes through // dispatch, so this gate doesn't affect resolution. bundlePath := false for _, seg := range segments { if strings.EqualFold(seg, apps.BundleName) { bundlePath = true break } } if bundlePath && !configEditorForBundle(cfg, r, urlPath) { http.NotFound(w, r) return } // Raw .zddc YAML view: /.zddc is reachable at every depth // and returns the on-disk file's bytes (Content-Type: application/yaml) // or — when no file exists — a synthetic placeholder body with a // cascade summary so the user can see what's effective here. The // reserved-sidecar gate above already filtered out .zddc.d/.zddc, so // GET/HEAD land here for ordinary paths and PUT/DELETE/POST fall // through to ServeFileAPI. A .zddc *inside* a zip (".zip/…/.zddc", e.g. // a policy member of the .zddc.zip bundle) is NOT a real on-disk file — // it's served by the zip intercept below, so exclude it here. if handler.IsZddcFileRequest(urlPath) && !strings.Contains(strings.ToLower(urlPath), ".zip/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) { handler.ServeZddcFile(cfg, w, r) return } // Check for .archive segment in the path. .archive is project-scoped // and addressed at exactly one depth — //.archive/... — even // though offline-built HTML files reference siblings via // "../.archive/.html" from arbitrary depths. Any deeper form // (///.../.archive/...) gets a 302 to the project-rooted // canonical so anchored links and bookmarks normalize to a single // stable URL per tracking number. The redirect target preserves the // path tail after .archive/ verbatim and the query string; browsers // preserve the fragment automatically across redirects. // // .archive is read-only: only GET/HEAD reach the handler. Anything // else (PUT/POST/DELETE) returns 405 here, before the file API would // otherwise see the request. This avoids the 302→GET silent-method- // downgrade trap and makes the contract explicit. for i, seg := range segments { if seg != cfg.IndexPath { continue } if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } // segments[0] is the project; segments[i] is .archive. i==0 // means /.archive/... at the very root, with no project to // scope by — 404 (a tracking-number reference must be project- // rooted; cross-project tracking-number collisions otherwise // silently pick a winner). if i == 0 { http.NotFound(w, r) return } project := segments[0] var filename string if i+1 < len(segments) { filename = strings.Join(segments[i+1:], "/") } // Canonicalize anything below //.archive/. Building // the target by hand (rather than re-encoding) keeps any // already-encoded characters in the original URL.RawPath // trailing segments intact for the browser to follow. if i > 1 { target := "/" + project + "/" + cfg.IndexPath + "/" + filename if r.URL.RawQuery != "" { target += "?" + r.URL.RawQuery } http.Redirect(w, r, target, http.StatusFound) return } handler.ServeArchive(cfg, idx, w, r, project, filename) return } // Tables-system intercept: *.table.html is a virtual URL that the // table handler renders inline, reading rows from a directory of // *.yaml files declared in the directory's .zddc tables: map. // Discovery is .zddc-declarative — no auto-mount on file presence — // so RecognizeTableRequest returns nil whenever there's no matching // declaration and the URL falls through to the static-file path // (or to the form intercept below for *.form.html / *.yaml.html). // // One exception: archive//mdl.table.html falls back to the // embedded default MDL spec when no operator declaration exists. // RecognizeTableRequest implements that fallback internally. if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil { handler.ServeTable(cfg, tableReq, w, r) return } // Form-system intercept: *.form.html and *.yaml.html under a sibling form // folder are virtual URLs that the form handler renders inline, reading // the underlying *.form.yaml spec (and, for re-edit, the *.yaml data) from // disk. RecognizeFormRequest returns nil when the spec doesn't exist, so // non-form .html URLs fall through to the static-file path below. if formReq := handler.RecognizeFormRequest(cfg.Root, r.Method, urlPath); formReq != nil { handler.ServeForm(cfg, formReq, w, r) return } // Zip-as-directory intercept: a "<…>.zip/" URL is a virtual // surface over a real .zip file on disk — GET "<…>.zip/" lists the // members, GET "<…>.zip/member.pdf" extracts and streams that one // member, so a client never has to download the whole archive. The // bare "<…>.zip" (no trailing slash) is NOT matched here and falls // through to the normal file path (a plain download). Like .archive, // a zip carries no .zddc of its own — ACL is the chain of the // directory CONTAINING the zip. Read-only: write methods are // rejected before ServeFileAPI could try to create a path under a // file. (The os.Stat walk in splitZipPath is gated by this cheap // substring check, so it doesn't run for ordinary requests.) if strings.Contains(strings.ToLower(urlPath), ".zip/") { if zipAbs, member, ok := splitZipPath(cfg.Root, urlPath); ok { if handler.IsWriteMethod(r.Method) { // In-place editing is allowed ONLY inside the .zddc.zip config // bundle and ONLY for a standing config-editor over its dir // (the bundle gate above already 404s the bundle to everyone // else, so visibility ⇒ edit authority — no elevation). Content // zips — transmittal packages, WORM records — stay read-only. if strings.EqualFold(filepath.Base(zipAbs), apps.BundleName) && configEditorForBundle(cfg, r, urlPath) { handler.ServeZipWrite(cfg, w, r, zipAbs, member) return } w.Header().Set("Allow", "GET, HEAD") http.Error(w, "Zip archives are read-only", http.StatusMethodNotAllowed) return } if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } chain, err := zddc.EffectivePolicy(cfg.Root, filepath.Dir(zipAbs)) if err != nil { slog.Warn("ACL policy error on zip parent", "path", filepath.Dir(zipAbs), "err", err) } if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } handler.ServeZip(cfg, w, r, zipAbs, member) return } } // File API — authenticated CRUD over the served tree. Catches PUT, // DELETE, and POST on any non-reserved path. Read methods (GET/HEAD) // fall through to the static / apps / directory pipeline below. // Forms and .profile/.archive POSTs are already routed above this // point so they take precedence. if handler.IsWriteMethod(r.Method) { handler.ServeFileAPI(cfg, w, r) return } // Apps resolution for the root landing path: GET / or /index.html with // no real index.html on disk → serve via apps.Serve("landing"). The // other four apps are caught by the "stat fails → app HTML?" branch // below, which only triggers when no concrete file is at the URL path. // // Gated by Accept: HTML requests get the landing tool, JSON requests // fall through to ServeDirectory and get the generic listing (with // per-entry titles via listing.FileInfo.Title). That keeps the wire // protocol uniform — a JSON listing is a JSON listing whether you // fetch /Project-1/ or /. Landing itself consumes the same shape. // // The landing page is intentionally public (no ACL gate). It's a // project picker — the per-project ACL filtering done by // fs.ListDirectory still hides projects an anonymous (or unauthorized) // caller can't reach. See also handler.ServeDirectory's matching // root-path bypass. // // (Browsers normalize `https://host` → `https://host/`, so the // no-slash vs slash distinction the user might want — picker on // bare host, browse on trailing slash — can't be expressed: the // HTTP request for both forms is `GET /`. The picker wins because // it's the only meaningful entry point that scopes ACL per-project.) if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") && !strings.Contains(r.Header.Get("Accept"), "application/json") { realIndex := filepath.Join(cfg.Root, "index.html") if _, err := os.Stat(realIndex); os.IsNotExist(err) { chain, _ := zddc.EffectivePolicy(cfg.Root, cfg.Root) if apps.AppAvailableAt(cfg.Root, cfg.Root, "landing") { appsSrv.Serve(w, r, "landing", chain, cfg.Root) return } } } // Resolve the physical path cleanPath := filepath.FromSlash(strings.TrimPrefix(urlPath, "/")) absPath := filepath.Join(cfg.Root, cleanPath) // Guard against path traversal if !strings.HasPrefix(absPath, cfg.Root+string(filepath.Separator)) && absPath != cfg.Root { http.Error(w, "Not Found", http.StatusNotFound) return } // Check filesystem info, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { // Default-spec fallback for the embedded table.yaml / form.yaml // files served when no operator file exists on disk: // // /{ssr,mdl,rsk}/{table,form}.yaml (aggregate/registry) // /{mdl,rsk}//{table,form}.yaml (per-party) // // The table app fetches these client-side; the fallback lets // a fresh project work out of the box. ACL gates against the // chain at the request directory; for project-level virtual // specs that chain is the project's, and for per-party paths // it's the party's archive folder. if r.Method == http.MethodGet || r.Method == http.MethodHead { if bytes, ok := handler.IsDefaultSpec(cfg.Root, urlPath); ok { chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath)) if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } w.Header().Set("Content-Type", "application/yaml; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-ZDDC-Source", "default-spec") if r.Method == http.MethodHead { return } _, _ = w.Write(bytes) return } } // (Register rows are real files now — ssr/.yaml and // mdl|rsk//.yaml — so a GET of one hits the // on-disk serve path below, where $party/name is injected; // it never lands in this not-found branch. working/staging/ // reviewing are real directories navigated normally. The old // virtual-row serve + folder-nav 302 are gone.) // File doesn't exist at this path. Before falling through to // app-HTML routing or 404, check the two virtual-file-extension // shapes that ZDDC exposes through the listing convention: // // .zip — subtree download (replaces `/?zip=1`) // .docx|html|pdf — MD-source conversion of sibling .md // (replaces `.md?convert=`) // // Both fire ONLY when stat failed at the requested URL — a // real file always wins. The path-suffix form lets clients // emit a plain + lets `curl -O` produce the right // filename, no query-string handling required. if r.Method == http.MethodGet || r.Method == http.MethodHead { if absDir, ok := handler.RecognizeVirtualSubtreeZip(cfg.Root, urlPath); ok { chain, _ := zddc.EffectivePolicy(cfg.Root, absDir) if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } handler.ServeSubtreeZip(cfg, w, r, absDir) return } if mdAbs, format, ok := handler.RecognizeVirtualConvert(cfg.Root, urlPath); ok { chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(mdAbs)) if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } handler.ServeConverted(cfg, w, r, mdAbs, format, chain) return } } // If the URL matches one of the canonical app HTML names AND // the request directory is one where that app is available // (working/staging/incoming for classifier, staging for // transmittal, anywhere for archive + browse, root only for // landing), resolve via the apps subsystem. if appsSrv != nil { if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" { requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel)) if apps.AppAvailableAt(cfg.Root, requestDir, app) { chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir) // Root-path tool shells are public, mirroring the // landing page + ServeDirectory's root bypass: the // shell is a static app that carries no data, and the // tool's own per-project/per-dir fetches are // independently ACL-gated (fs.ListDirectory filters // per entry). Gating the shell here would block the // root-level multi-project archive/browse views for // any caller without a root-level read grant — which // no normal (per-project-scoped) user has. Non-root // tool paths (e.g. //archive.html) keep the // read gate so a project you can't read won't serve // its tool there. if requestDir != cfg.Root { if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } } appsSrv.Serve(w, r, app, chain, requestDir) return } } } // (Top-level /{working,staging,reviewing} URLs // resolve as folder-nav virtuals — the per-party redirect // is handled above; the bare top-level URL falls through // to ServeDirectory, where ListDirectory synthesises the // folder-nav listing from ListPartyDirsInSlot.) // // Virtual received/ window. /received/[...] is a // synthetic view onto the canonical received// // declared by the workflow folder's .zddc.received_path. // ResolveVirtualReceived validates the parent .zddc; on a // match, route through the normal directory/file handlers, // which swap the read source to the canonical based on the // URL (ListDirectory and ServeFile via the absolute path). if r.Method == http.MethodGet || r.Method == http.MethodHead { if vr := zddc.ResolveVirtualReceived(cfg.Root, urlPath); vr.Resolved { if strings.HasSuffix(urlPath, "/") { handler.ServeDirectory(cfg, appsSrv, w, r) return } // File read — ACL-check against the canonical // received's chain, then serve the canonical bytes // while keeping the workflow URL in the address bar. chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(vr.ReceivedAbs)) if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } handler.ServeFile(w, r, vr.ReceivedAbs) return } } // Cascade-declared paths: the .zddc cascade (embedded // defaults + on-disk overrides) declares this URL even // if the on-disk directory doesn't exist yet. Land on a // usable view rather than 404, via the same slash/no-slash // routing convention used for real directories: // - slash → ServeDirectory (DirTool; browse by default) // - no-slash → default_tool ("specialized app") if any, // else a 302 to the slash form. // // Guard: only directory-shaped URLs qualify. The bare "*" // project glob matches *any* first-level segment — including // "foo.html", "foo.txt", etc. — so without the extension // check a non-existent top-level file would 302-to-slash // instead of 404. A trailing slash, or no file extension on // the last segment, means "asking for a directory". if (r.Method == http.MethodGet || r.Method == http.MethodHead) && (strings.HasSuffix(urlPath, "/") || filepath.Ext(urlPath) == "") && zddc.IsDeclaredPath(cfg.Root, absPath) { // (Empty-subtree zip for cascade-declared paths is now // handled by RecognizeVirtualSubtreeZip at the top of // this branch — same handler, path-suffix grammar.) if strings.HasSuffix(urlPath, "/") { handler.ServeDirectory(cfg, appsSrv, w, r) return } if serveSpecializedNoSlash(cfg, appsSrv, w, r, absPath, urlPath, email) { return } http.Redirect(w, r, urlPath+"/", http.StatusFound) return } http.Error(w, "Not Found", http.StatusNotFound) } else { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } return } if info.IsDir() { // ACL check — bypassed at the root path so the landing page (the // project picker) is reachable by anyone, including anonymous. // Per-project filtering happens inside ServeDirectory → // fs.ListDirectory, which hides directories the caller can't // reach. Subdirectory requests still hit this gate. isRoot := urlPath == "/" if !isRoot { chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } } // (Subtree downloads use the virtual `GET /dir.zip` URL — // see RecognizeVirtualSubtreeZip handling at the top of the // stat-fails branch above. Real directories stat-succeed // here, so the virtual zip URL stat-fails at /dir.zip and // matches there.) // Slash/no-slash routing convention: trailing slash → the // directory view (handler.ServeDirectory → DirTool, which // resolves to browse by default; JSON requests always get the // raw listing regardless). No trailing slash → the directory's // default_tool ("specialized app") — browse under working/+ // reviewing/ (hosts the markdown editor), transmittal under // staging/, archive under archive/, tables under // archive//mdl/ — if one is declared; otherwise // (after the project-root landing case below) a 302 to the // slash form. if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot { if serveSpecializedNoSlash(cfg, appsSrv, w, r, absPath, urlPath, email) { return } } // Project root (depth-1 dir, no trailing slash) serves the // landing tool, which detects mode='project' from // location.pathname and renders the lifecycle-stage cards + // MDL section. Same single-file SPA as the deployment-root // project picker — one tool, two URL shapes. With trailing // slash, the project falls through to the regular browse // listing. if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && handler.IsProjectRootURL(urlPath) { if appsSrv != nil { chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) appsSrv.Serve(w, r, "landing", chain, absPath) return } } if !strings.HasSuffix(urlPath, "/") { http.Redirect(w, r, urlPath+"/", http.StatusFound) return } handler.ServeDirectory(cfg, appsSrv, w, r) return } // Regular file: ACL on parent directory chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath)) if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return } // views.file → form editor. A browser NAVIGATION (Accept: text/html) to a // no-slash data file whose cascade declares views.file = {tool: form} // serves the form editor bound to that file. Programmatic reads — the // tables client fetches rows with Accept: */* — and an explicit ?raw fall // through to the raw bytes (the injected-row / ServeFile path below), so // this never breaks row fetching. The POST goes to the canonical // .yaml.html update URL (the existing form-update handler). if r.Method == http.MethodGet && !r.URL.Query().Has("raw") && strings.Contains(r.Header.Get("Accept"), "text/html") { if v, ok := zddc.ViewAt(cfg.Root, filepath.Dir(absPath), "file"); ok && v.Tool == "form" { fr := &handler.FormRequest{ Kind: "render-edit", SpecPath: filepath.Join(filepath.Dir(absPath), "form.yaml"), DataPath: absPath, SubmitURL: urlPath + ".html", } handler.ServeForm(cfg, fr, w, r) return } } // (MD→{docx,html,pdf} on-demand conversion now lives at // `GET //.{docx,html,pdf}` (virtual file URL, // see RecognizeVirtualConvert). The .md source serves // normally here.) // Edit-history: ACL already passed (parent-dir chain). // - Records (.yaml rows): GET .yaml?history=1 lists prior // revisions stored under /.history// (audit in-body). // - Text (markdown) under a history: true subtree: // ?history=1 lists versions; ?history= returns that version's // bytes. Audit lives in /.history//log.jsonl. // Non-history paths fall through to the normal file serve. if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") { version := r.URL.Query().Get("history") if handler.IsTextHistoryCandidate(cfg.Root, absPath) { // Reading recorded history does NOT require history to be // currently enabled — snapshots already on disk stay readable // (empty list when there are none) even if the `history:` flag // was later turned off. The file's read ACL was already checked // above; WRITES remain gated by EffectiveHistory in serveFilePut. handler.ServeTextHistory(w, r, cfg.Root, absPath, version) return } handler.ServeHistoryList(w, r, absPath) return } // Register rows are real files; inject the path-derived source column // ($party for mdl/rsk rows, name for ssr rows) on read so the tables // tool renders it as a read-only column. The client strips it on save. if r.Method == http.MethodGet || r.Method == http.MethodHead { if field, value, ok := registerRowField(urlPath); ok { handler.ServeInjectedRow(w, r, absPath, field, value) return } } handler.ServeFile(w, r, absPath) } // registerRowField returns the path-derived column to inject when urlPath // names an aggregate register row: ($party, ) for // //{mdl,rsk}//.yaml, or (name, ) for // //ssr/.yaml. ok=false otherwise (incl. spec files). func registerRowField(urlPath string) (field, value string, ok bool) { parts := strings.Split(strings.Trim(urlPath, "/"), "/") switch len(parts) { case 3: if parts[1] == "ssr" && strings.HasSuffix(parts[2], ".yaml") && parts[2] != "table.yaml" && parts[2] != "form.yaml" { return "name", strings.TrimSuffix(parts[2], ".yaml"), true } case 4: if (parts[1] == "mdl" || parts[1] == "rsk") && strings.HasSuffix(parts[3], ".yaml") && parts[3] != "table.yaml" && parts[3] != "form.yaml" { return "$party", parts[2], true } } return "", "", false } // runPeriodicRescan calls idx.Rebuild on `interval` until ctx is cancelled. // Each tick walks fsRoot from scratch and atomically replaces the live index; // concurrent reads are safe via the index's RWMutex. Errors are logged but do // not stop the loop — a transient walk failure shouldn't disable rescans. func runPeriodicRescan(ctx context.Context, fsRoot string, idx *archive.Index, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() slog.Info("archive periodic rescan started", "interval", interval) for { select { case <-ctx.Done(): return case <-ticker.C: dur, projects, tracking, err := idx.Rebuild(fsRoot) if err != nil { slog.Warn("archive rescan failed", "err", err, "duration", dur) continue } slog.Debug("archive rescan ok", "duration", dur, "projects", projects, "tracking", tracking) } } } // setupLogger installs a slog default that fans every record out to stderr // (the existing TextHandler — user-visible logging is unchanged) AND to an // in-memory ring buffer that backs the /.profile/logs endpoint. Returns // the ring so handlers can read it. func setupLogger(level string) *handler.LogRing { var l slog.Level switch strings.ToLower(level) { case "debug": l = slog.LevelDebug case "warn": l = slog.LevelWarn case "error": l = slog.LevelError default: l = slog.LevelInfo } ring := handler.NewLogRing(500) text := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l}) rh := handler.NewRingHandler(ring, l) slog.SetDefault(slog.New(handler.NewMultiHandler(text, rh))) return ring }