Three pieces wire the per-party Master Deliverables List as the default
view at archive/<party>/mdl/:
1. **Dispatcher redirect.** GET (and HEAD) on
<project>/archive/<party>/mdl/ (case-fold on archive and mdl) now
302 → <project>/archive/<party>/mdl.table.html. Non-archive paths
and deeper mdl/ paths fall through unchanged.
2. **Default-spec fallback in RecognizeTableRequest.** When a request
matches archive/<party>/mdl.table.html and no operator-supplied
tables: { mdl: ... } declaration covers it, the handler returns a
recognised request anyway. Operator declarations still win — and a
typo'd declaration pointing at a missing file yields 404 (not a
silent fallback).
3. **Static-file fallback for the spec yaml.** GET archive/<party>/
mdl.table.yaml and archive/<party>/mdl.form.yaml return embedded
default bytes (default-mdl.{table,form}.yaml in the handler package)
when no operator file exists at that path. Operator files always
win because the dispatcher's os.Stat finds them before reaching the
IsDefaultMdlSpec branch.
The defaults use ZDDC vocabulary: tracking, title, discipline, type,
plannedRevision, plannedDate, status (DFT/IFR/IFA/IFC/AFC/AB), owner,
notes. Operators override per-party by writing
archive/<party>/{mdl.table.yaml,mdl.form.yaml} and a tables: { mdl: ... }
entry in the party's .zddc.
Tests:
- 4 dispatcher redirect cases (success, case-fold mdl, case-fold archive,
deeper-path skip, non-archive skip)
- 6 tablehandler cases (default fires at archive/<party>/, operator
override wins, scope check, embedded yaml served, operator yaml wins,
scope check on yaml fallback)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
744 lines
27 KiB
Go
744 lines
27 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/config"
|
|
"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
|
|
}
|
|
}
|
|
|
|
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))
|
|
|
|
// 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,
|
|
CascadeMode: cfg.CascadeMode,
|
|
}
|
|
// 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)
|
|
}
|
|
slog.Info("policy decider ready",
|
|
"mode", policyModeLabel(cfg.OPAURL),
|
|
"url", cfg.OPAURL,
|
|
"cache_ttl", cfg.OPACacheTTL,
|
|
"cascade_mode", cfg.CascadeMode)
|
|
|
|
// Innermost handler: dispatch.
|
|
var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
dispatch(cfg, idx, logRing, appsServer, 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(auditLogger, inner)
|
|
inner = handler.ACLMiddleware(cfg, decider, 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")
|
|
}
|
|
|
|
// 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
|
|
// (mdedit: 920 KB → ~250 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
|
|
}
|
|
|
|
// 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, " ")
|
|
}
|
|
|
|
// dispatch routes a request to the appropriate handler.
|
|
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
// 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 API: GET / with Accept: application/json
|
|
if urlPath == "/" {
|
|
accept := r.Header.Get("Accept")
|
|
if strings.Contains(accept, "application/json") {
|
|
handler.ServeProjectList(cfg, w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Split path into segments
|
|
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
|
|
|
|
// Reserve dot-prefixed path segments. The listing pipeline already hides
|
|
// hidden entries (internal/listing/listing.go:17, 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.
|
|
for _, seg := range segments {
|
|
if seg == "" {
|
|
continue
|
|
}
|
|
if seg == apps.CacheDirName {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !strings.HasPrefix(seg, ".") {
|
|
continue
|
|
}
|
|
if seg == cfg.IndexPath {
|
|
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 301 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.StatusMovedPermanently)
|
|
return
|
|
}
|
|
handler.ServeArchive(cfg, idx, w, r, project, filename)
|
|
return
|
|
}
|
|
|
|
// MDL convenience redirect: GET archive/<party>/mdl/ → mdl.table.html.
|
|
// The table app is the canonical view for the per-party Master
|
|
// Deliverables List. Direct navigation to the data folder lands on
|
|
// the grid editor; clients that want the raw row listing can still
|
|
// hit archive/<party>/mdl/ via the file API or with an explicit
|
|
// trailing-segment beyond mdl/.
|
|
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && len(segments) == 4 {
|
|
if strings.EqualFold(segments[1], "archive") && strings.EqualFold(segments[3], "mdl") {
|
|
target := "/" + segments[0] + "/" + segments[1] + "/" + segments[2] + "/mdl.table.html"
|
|
if r.URL.RawQuery != "" {
|
|
target += "?" + r.URL.RawQuery
|
|
}
|
|
http.Redirect(w, r, target, http.StatusFound)
|
|
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
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// 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.
|
|
if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") {
|
|
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 MDL spec fallback: archive/<party>/mdl.table.yaml
|
|
// and archive/<party>/mdl.form.yaml are served from embedded
|
|
// bytes when no operator file exists on disk. The table app
|
|
// fetches these client-side; the fallback lets a fresh
|
|
// project work out of the box.
|
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
|
if bytes, ok := handler.IsDefaultMdlSpec(cfg.Root, urlPath); ok {
|
|
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
|
|
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, 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-mdl-spec")
|
|
if r.Method == http.MethodHead {
|
|
return
|
|
}
|
|
_, _ = w.Write(bytes)
|
|
return
|
|
}
|
|
}
|
|
// File doesn't exist at this path. 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, working for mdedit, staging for
|
|
// transmittal, anywhere for archive, 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)
|
|
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
appsSrv.Serve(w, r, app, chain, requestDir)
|
|
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.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
if !strings.HasSuffix(urlPath, "/") {
|
|
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
handler.ServeDirectory(cfg, w, r)
|
|
return
|
|
}
|
|
|
|
// Regular file: ACL on parent directory
|
|
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
|
|
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
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
|
|
}
|