The site-root .zddc.zip bundle was existence-hidden (404) over HTTP for
everyone. Now an active (elevated) admin over its directory can browse it
in the file tree like any other zip: GET /.zddc.zip/ lists members, GET
/.zddc.zip/<member> extracts one, and a bare GET downloads it. Everyone
else — including the same admin un-elevated — still gets 404 for every URL
shape, which additionally closes a prior by-name member read (the old gate
only 404'd the bundle base, so /.zddc.zip/<member> leaked to any reader of
the root).
The dispatch gate now keys off the bundle segment anywhere in the path and
requires activeAdminForBundle (mirrors ActiveAdminForSidecar). The listing
(fs.ListDirectory) surfaces the .zddc.d reserve and .zddc.zip bundle only to
an active admin, so non-admins don't even see the names under ?hidden=1.
Client needs no change: splitExtension('.zddc.zip').extension == 'zip', so
browse already renders it as a navigable archive (tree.js isZip). Internal
apps.Bundle FS resolution never goes through dispatch, so it's unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1403 lines
56 KiB
Go
1403 lines
56 KiB
Go
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 a bundled reference Rego policy and exit.
|
|
// Cheap escape hatch for operators standing up an external OPA who want
|
|
// a parity-tested baseline as a starting point for customization.
|
|
//
|
|
// --print-rego → standard cascade (commercial default)
|
|
// --print-rego=standard → same
|
|
// --print-rego=federal → parent-deny-is-absolute (NIST AC-6)
|
|
for _, a := range os.Args[1:] {
|
|
switch a {
|
|
case "--print-rego", "--print-rego=standard":
|
|
fmt.Print(policy.ReferenceRego)
|
|
return
|
|
case "--print-rego=federal":
|
|
fmt.Print(policy.FederalRego)
|
|
return
|
|
case "show-defaults", "--show-defaults":
|
|
// Dump the embedded baseline .zddc to stdout. Pipe into a
|
|
// real file (e.g. $ZDDC_ROOT/.zddc) to start from the
|
|
// shipped defaults and edit; the on-disk copy then
|
|
// participates in the cascade alongside the embedded
|
|
// layer (both contribute; child wins). To ignore the
|
|
// embedded layer entirely after exporting, set
|
|
// `inherit: false` at the top of the exported file.
|
|
_, _ = os.Stdout.Write(zddc.EmbeddedDefaultsBytes())
|
|
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/<name>
|
|
// 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 defaults.zddc.yaml 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_ROOT>/.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_ROOT>/.zddc.d/logs/access-<host>.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
|
|
// "<app>.html" member of the site-root <ZDDC_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.zddc.yaml 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
|
|
// <dir>/table.html request (which also resolves the embedded
|
|
// default-MDL spec for archive/<party>/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/<member>" 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
|
|
}
|
|
|
|
// activeAdminForBundle reports whether the request principal is an active
|
|
// (elevated) admin over the directory that holds the .zddc.zip config bundle
|
|
// referenced by urlPath. Mirrors handler.ActiveAdminForSidecar: the bundle is
|
|
// existence-hidden config for everyone else, but an elevated admin over its
|
|
// directory may browse its members and download it. Works for every bundle URL
|
|
// shape (bare, trailing-slash listing, and <bundle>/<member>) since it keys off
|
|
// the path segment that precedes the bundle name.
|
|
func activeAdminForBundle(cfg config.Config, r *http.Request, urlPath string) bool {
|
|
p := handler.PrincipalFromContext(r)
|
|
if !p.Elevated || 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.IsAdminForChain(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
|
|
}
|
|
|
|
// 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: <dir>/.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. /<dir>/.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_ROOT>/.zddc.zip is config, not
|
|
// ordinary content: existence-hidden over HTTP for everyone EXCEPT an
|
|
// active (elevated) admin over its directory, who may browse it in the
|
|
// file tree. For an admin every bundle URL falls through to normal
|
|
// handling — GET <bundle>/ lists its members (the zip-as-directory
|
|
// intercept below), GET <bundle>/member extracts one, and a bare
|
|
// GET <bundle> 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 && !activeAdminForBundle(cfg, r, urlPath) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Raw .zddc YAML view: <dir>/.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.
|
|
if handler.IsZddcFileRequest(urlPath) && (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 — /<project>/.archive/... — even
|
|
// though offline-built HTML files reference siblings via
|
|
// "../.archive/<tracking>.html" from arbitrary depths. Any deeper form
|
|
// (/<project>/<sub>/.../.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 /<project>/.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/<party>/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/<member>" 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) {
|
|
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:
|
|
//
|
|
// <project>/{ssr,mdl,rsk}/{table,form}.yaml (aggregate/registry)
|
|
// <project>/{mdl,rsk}/<party>/{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/<party>.yaml and
|
|
// mdl|rsk/<party>/<file>.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:
|
|
//
|
|
// <dir>.zip — subtree download (replaces `<dir>/?zip=1`)
|
|
// <file>.docx|html|pdf — MD-source conversion of sibling <file>.md
|
|
// (replaces `<file>.md?convert=<fmt>`)
|
|
//
|
|
// Both fire ONLY when stat failed at the requested URL — a
|
|
// real file always wins. The path-suffix form lets clients
|
|
// emit a plain <a href> + 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. /<project>/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 <project>/{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. <workflow>/received/[...] is a
|
|
// synthetic view onto the canonical received/<tracking>/
|
|
// 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/<party>/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
|
|
// <file>.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 /<dir>/<file>.{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 <record>.yaml?history=1 lists prior
|
|
// revisions stored under <dir>/.history/<base>/ (audit in-body).
|
|
// - Text (markdown) under a history: true subtree:
|
|
// ?history=1 lists versions; ?history=<sha> returns that version's
|
|
// bytes. Audit lives in <dir>/.history/<stem>/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, <party>) for
|
|
// /<project>/{mdl,rsk}/<party>/<file>.yaml, or (name, <party>) for
|
|
// /<project>/ssr/<party>.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
|
|
}
|