Redesign the markdown edit-history store from content-hashed blobs + log.jsonl to one self-describing file per save: .history/<stem>/<ts>-<email>.<ext> The filename IS the audit (colon-free UTC timestamp valid on SMB/Azure Files + the authoring email); listing the directory is the history. No sidecar log, no hashing. A byte-identical save is a no-op; a pre-existing file lazy-seeds its current bytes (author "unknown", stamped at mtime). Reverting copies an old snapshot back (records as a fresh save). Snapshots are kept forever. Fixes the 404 reading history: reads no longer require history to be *currently* enabled — ServeTextHistory serves whatever .history/<stem>/ exists (empty list when none); the dispatch drops the EffectiveHistory gate for reads. WRITES stay gated by the history: flag. (The 404 came from the aggregator refactor turning history off on project-level working/, which made already-recorded snapshots unreadable.) Renames: an in-place rename carries .history/<stem>/ to the new name (serveFileMove); a cross-dir move leaves it behind. Defaults: history: true now ships on the three live-editing slots — working, mdl, rsk — at both the project-level nodes and the per-party folders. It's a .zddc cascade key, so operators override per project. Records (.yaml in mdl/rsk) keep their separate record-history path. Browse history viewer updated to the filename-based version id (id ← sha). Tests rewritten for the per-file scheme + rename behavior + SMB-safe names; HistoryAt defaults test updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1394 lines
56 KiB
Go
1394 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 creates the cache + fetcher + server. No seeding, no refresh,
|
|
// no admin UI — the server fetches once on first request, caches forever
|
|
// in <ZDDC_ROOT>/_app/, and falls back to the embedded HTML on any failure.
|
|
func setupApps(cfg config.Config) (*apps.Server, error) {
|
|
cache, err := apps.NewCache(filepath.Join(cfg.Root, apps.CacheDirName))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create cache: %w", err)
|
|
}
|
|
fetcher := apps.NewFetcher(cache, slog.Default())
|
|
|
|
// Apps signing pubkey. Resolution order, highest priority first:
|
|
// 1. --apps-pubkey / ZDDC_APPS_PUBKEY (path to PEM file)
|
|
// 2. apps_pubkey: inline PEM in the root <ZDDC_ROOT>/.zddc file
|
|
// (root-only — same trust-anchor treatment as admins:)
|
|
// 3. nothing → URL-fetched apps refuse-by-default; only embedded
|
|
// + local-path apps work
|
|
//
|
|
// Same posture as TLS certificates: zddc-server bakes nothing in.
|
|
// Operators using zddc.varasys.io's canonical channels download
|
|
// pubkey.pem from there and either configure the path via env/flag
|
|
// or paste the PEM contents inline into root .zddc.
|
|
switch {
|
|
case cfg.AppsPubKey != "":
|
|
pub, err := apps.LoadPubKey(cfg.AppsPubKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("apps-pubkey: %w", err)
|
|
}
|
|
fetcher.VerifyKey = pub
|
|
slog.Info("apps signing pubkey loaded", "source", "env/flag", "path", cfg.AppsPubKey)
|
|
default:
|
|
// Fall back to apps_pubkey: in root .zddc.
|
|
rootZddc, err := zddc.ParseFile(filepath.Join(cfg.Root, ".zddc"))
|
|
if err == nil && rootZddc.AppsPubKey != "" {
|
|
pub, err := apps.ParsePubKeyPEM([]byte(rootZddc.AppsPubKey))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("root .zddc apps_pubkey: %w", err)
|
|
}
|
|
fetcher.VerifyKey = pub
|
|
slog.Info("apps signing pubkey loaded", "source", "root .zddc apps_pubkey")
|
|
} else {
|
|
slog.Warn("apps-pubkey not configured; URL-fetched apps will be refused (only embedded + local-path apps will work). " +
|
|
"Set --apps-pubkey, ZDDC_APPS_PUBKEY, or apps_pubkey: in the root .zddc file to a PEM Ed25519 public key you trust.")
|
|
}
|
|
}
|
|
|
|
return apps.NewServer(cfg.Root, cache, fetcher, 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)
|
|
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
|
|
}
|
|
|
|
// 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, "/"), "/")
|
|
|
|
// 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
|
|
// leaf is carved out of the dot-prefix guard below so GET/HEAD
|
|
// land here 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
|
|
}
|
|
|
|
// Reserve dot-prefixed path segments. The listing pipeline already hides
|
|
// hidden entries (internal/fs/tree.go:90, projectshandler.go:40),
|
|
// but direct URL access would still serve them. 404 here so hidden trees
|
|
// like /srv/.devshell (the in-image dev-shell's persistent home dir on
|
|
// the same Azure Files PVC as served data) cannot be fetched. The
|
|
// recognized virtual prefixes (.profile handled above, cfg.IndexPath
|
|
// handled below) are explicitly allowed through.
|
|
//
|
|
// Also reserve the apps cache directory (`_app`): the cached HTML files
|
|
// there must be served via the apps resolver (with proper headers and
|
|
// ACL), never raw at /_app/...html. The apps cache stays reserved
|
|
// even with ?hidden=1 — its files must go through the resolver for
|
|
// proper ETag/MIME/X-ZDDC-Source headers.
|
|
//
|
|
// ?hidden=1 on a GET/HEAD relaxes the dot-prefix guard for everything
|
|
// EXCEPT _app. The ACL chain on the resolved path is still the gate;
|
|
// anyone who couldn't list this hidden file via fs.ListDirectory
|
|
// can't reach it via direct URL either. Write methods stay blocked
|
|
// from hidden paths (the file API has its own segment check that
|
|
// the ?hidden flag does NOT relax).
|
|
hiddenOK := r.URL.Query().Has("hidden") &&
|
|
(r.Method == http.MethodGet || r.Method == http.MethodHead)
|
|
for i, seg := range segments {
|
|
if seg == "" {
|
|
continue
|
|
}
|
|
if seg == apps.CacheDirName {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !strings.HasPrefix(seg, ".") && !strings.HasPrefix(seg, "_") {
|
|
continue
|
|
}
|
|
if seg == cfg.IndexPath {
|
|
continue
|
|
}
|
|
// `.zddc` is the only writable dot-prefixed file: GET/HEAD was
|
|
// handled by ServeZddcFile above; PUT/DELETE/POST fall through
|
|
// to ServeFileAPI. Only the LEAF segment carves through —
|
|
// `.zddc.d` and other intermediate dot dirs stay reserved.
|
|
if seg == handler.ZddcFileBasename && i == len(segments)-1 {
|
|
continue
|
|
}
|
|
if hiddenOK {
|
|
continue
|
|
}
|
|
http.NotFound(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>/archive/<party>/{mdl,rsk}/{table,form}.yaml
|
|
// <project>/archive/<party>/ssr.form.yaml
|
|
// <project>/{ssr,mdl,rsk}/{table,form}.yaml
|
|
//
|
|
// 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
|
|
}
|
|
}
|
|
// Virtual project-level table views (SSR / MDL rollup / RSK
|
|
// rollup). The virtual row URL doesn't exist on disk; the
|
|
// underlying canonical file lives in <project>/archive/<party>/.
|
|
// ACL evaluates against the canonical party-archive path so
|
|
// non-owners see the row read-only and party owners can edit.
|
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
|
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind.IsRowKind() {
|
|
chain, _ := zddc.EffectivePolicy(cfg.Root, vv.PartyArchive)
|
|
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
handler.ServeVirtualViewRow(w, r, vv)
|
|
return
|
|
}
|
|
}
|
|
// Virtual folder-nav redirect. URLs of the shape
|
|
// /<project>/{working,staging,reviewing}/<party>[/...]
|
|
// 302 to /<project>/archive/<party>/<slot>[/...] — the
|
|
// canonical physical path. The per-party folder-nav
|
|
// virtual itself has no on-disk presence; the redirect
|
|
// hands the client off to the real address so subsequent
|
|
// navigation, sharing, and bookmarks stay canonical.
|
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
|
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind == zddc.VirtualViewFolderNavRedir {
|
|
target := vv.CanonicalURL
|
|
// Preserve trailing slash from the request, since
|
|
// the canonical URL is a directory.
|
|
if strings.HasSuffix(urlPath, "/") && !strings.HasSuffix(target, "/") {
|
|
target += "/"
|
|
}
|
|
// Preserve query string verbatim — clients
|
|
// passing ?hidden=1 etc. should land at the same
|
|
// query on the canonical URL.
|
|
if q := r.URL.RawQuery; q != "" {
|
|
target += "?" + q
|
|
}
|
|
http.Redirect(w, r, target, http.StatusFound)
|
|
return
|
|
}
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// (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(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, absPath, version)
|
|
return
|
|
}
|
|
handler.ServeHistoryList(w, r, absPath)
|
|
return
|
|
}
|
|
|
|
handler.ServeFile(w, r, absPath)
|
|
}
|
|
|
|
// 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
|
|
}
|