authorizeAction walked `probe` up from the target's parent to the nearest EXISTING directory before computing the ACL chain. For a create deep under a not-yet-materialised canonical path — e.g. mkdir working/<party>/<name> when working/ and working/<party>/ don't exist on disk yet — that walk skipped the virtual working/ level and landed on the project root, where the embedded grant is only `document_controller: rw` (no `c`). Result: a bona-fide document_controller got 403 missing_verb=c creating in working/ (and party registration would fail the same way on a fresh project where ssr/ doesn't exist yet). EffectivePolicy is virtual-path-aware — the paths: cascade resolves per-folder behaviour for directories that don't exist on disk — so the chain must be evaluated at filepath.Dir(absPath) directly. This applies the correct per-peer grant (working/ → document_controller rwcda, project_team cr; ssr/ → document_controller rwc) regardless of what's been physically created. Ancestor restrictions (WORM zones, inherit:false fences) still apply because they cascade through EffectivePolicy, so this is strictly more correct, never more permissive than the cascade intends. Regression test: a document_controller (role member, not admin, un-elevated) registers a party and mkdirs under working/<party>/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
953 lines
36 KiB
Go
953 lines
36 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// File API — authenticated CRUD over the served tree.
|
|
//
|
|
// PUT /<path> write or overwrite. Body = file bytes (capped
|
|
// by cfg.MaxWriteBytes). Auto-creates parent
|
|
// directories. Optional If-Match for optimistic
|
|
// concurrency. Returns 201 Created (new) or 200
|
|
// OK (overwrite) with ETag.
|
|
// DELETE /<path> remove a file. Optional If-Match. Refuses to
|
|
// delete directories or hidden paths.
|
|
// POST /<path> control verb dispatched by X-ZDDC-Op header:
|
|
// move: X-ZDDC-Destination is the new path.
|
|
// Atomic os.Rename. Optional If-Match.
|
|
// mkdir: create directory at /<path>/. Idempotent.
|
|
//
|
|
// All operations route through the same ACL chain as GET — but with
|
|
// policy action="write" so external Rego can split read from write.
|
|
// The internal decider treats both identically.
|
|
//
|
|
// Path posture matches the rest of the dispatch:
|
|
// - hidden segments (./_-prefixed) are 404'd
|
|
// - the apps cache directory _app is 404'd
|
|
// - traversal that escapes Root is 404'd
|
|
//
|
|
// Audit: every successful write logs a structured `file_write` event
|
|
// (op, path, email, status, bytes) at INFO. Failed writes log at WARN.
|
|
const (
|
|
headerOp = "X-ZDDC-Op"
|
|
headerDestination = "X-ZDDC-Destination"
|
|
|
|
opMove = "move"
|
|
opMkdir = "mkdir"
|
|
// opSSRRename / opPlanReview / opAcceptTransmittal are declared
|
|
// alongside their handler files. Listed in the dispatch switch
|
|
// below so they're discoverable from a single place.
|
|
)
|
|
|
|
// IsWriteMethod reports whether this method is handled by the file API.
|
|
// Used by the dispatcher to gate writes through ServeFileAPI before the
|
|
// read-path tree of static / app / directory handling.
|
|
func IsWriteMethod(method string) bool {
|
|
switch method {
|
|
case http.MethodPut, http.MethodDelete, http.MethodPost:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ServeFileAPI is the entry point for write methods. The dispatcher
|
|
// has already verified the path doesn't contain reserved segments.
|
|
// Caller must have already enforced the dot-prefix / _app guards
|
|
// (these match dispatch's existing ones, but we re-check defensively).
|
|
func ServeFileAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodPut:
|
|
serveFilePut(cfg, w, r)
|
|
case http.MethodDelete:
|
|
serveFileDelete(cfg, w, r)
|
|
case http.MethodPost:
|
|
serveFilePost(cfg, w, r)
|
|
default:
|
|
w.Header().Set("Allow", "GET, HEAD, PUT, DELETE, POST, OPTIONS")
|
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// resolveTargetPath validates urlPath, joins it onto cfg.Root, and
|
|
// rejects traversal/hidden segments. Returns absolute path + the
|
|
// cleaned URL path (with one leading "/").
|
|
func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL string, ok bool, status int, msg string) {
|
|
if urlPath == "" || urlPath == "/" {
|
|
return "", "", false, http.StatusBadRequest, "empty path"
|
|
}
|
|
cleanURL = "/" + strings.Trim(urlPath, "/")
|
|
if strings.HasSuffix(urlPath, "/") {
|
|
cleanURL += "/"
|
|
}
|
|
|
|
// Dot-/underscore-prefixed paths are ordinary ACL-governed content now;
|
|
// the one reserved namespace, .zddc.d/, is admin-gated in authorizeAction
|
|
// (which all write verbs funnel through) rather than blocked here, so an
|
|
// admin can read/write the sidecar like normal files. See sidecar.go.
|
|
rel := filepath.FromSlash(strings.TrimPrefix(strings.TrimSuffix(cleanURL, "/"), "/"))
|
|
abs := filepath.Join(cfg.Root, rel)
|
|
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root {
|
|
return "", "", false, http.StatusNotFound, "path traversal"
|
|
}
|
|
return abs, cleanURL, true, 0, ""
|
|
}
|
|
|
|
// authorizeAction runs the ACL chain for a verb-tagged write to absPath.
|
|
// The chain is computed from the closest existing ancestor (so writes
|
|
// that create a brand-new file inherit the parent directory's chain).
|
|
// Returns allowed=false with the response status already written on deny.
|
|
//
|
|
// All admin / WORM / ACL logic lives downstream in the decider's single
|
|
// bypass site (policy.InternalDecider.Allow). AllowActionFromChainP
|
|
// computes IsActiveAdmin from the chain and Principal.Elevated, with
|
|
// the strict-ancestor rule applied when action == ActionAdmin (the
|
|
// caller tags .zddc writes that way). The handler does NOT make
|
|
// admin/elevation decisions of its own — one bypass site, one helper.
|
|
func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool {
|
|
// Evaluate the cascade at the target's LOGICAL parent — NOT the nearest
|
|
// on-disk ancestor. EffectivePolicy is virtual-path-aware: the embedded
|
|
// paths: cascade resolves per-folder behaviour for directories that don't
|
|
// exist on disk yet. A create deep under a not-yet-materialised canonical
|
|
// path — e.g. mkdir working/<party>/<name> when working/<party>/ has never
|
|
// been created — must see the working/ grant (document_controller rwcda,
|
|
// project_team cr). Walking up to the nearest existing dir would instead
|
|
// land on the shallower project-level grant (document_controller rw, no c)
|
|
// and wrongly deny create.
|
|
dir := filepath.Dir(absPath)
|
|
if dir != cfg.Root && !strings.HasPrefix(dir, cfg.Root+string(filepath.Separator)) {
|
|
dir = cfg.Root
|
|
}
|
|
|
|
p := PrincipalFromContext(r)
|
|
chain, err := zddc.EffectivePolicy(cfg.Root, dir)
|
|
if err != nil {
|
|
slog.Warn("file API ACL chain error", "path", absPath, "err", err)
|
|
}
|
|
|
|
// Hard reserve: writes anywhere under a .zddc.d/ segment are admin-only,
|
|
// and this overrides operator ACLs — a broad grant (e.g. `*: rwcd`) must
|
|
// never let a non-admin write the token store. Denying here (before the
|
|
// decider) leaves the admin path to proceed normally below. See sidecar.go.
|
|
if HasReservedSidecar(urlPath) && !ActiveAdminForSidecar(cfg, r, urlPath) {
|
|
writeForbidden(w, action)
|
|
return false
|
|
}
|
|
|
|
decider := DeciderFromContext(r)
|
|
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
|
|
if !allowed {
|
|
writeForbidden(w, action)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// readBodyCapped consumes r.Body up to cfg.MaxWriteBytes. Returns 413
|
|
// on overflow. The body is read fully into memory — small / medium files
|
|
// are the dominant traffic and atomic write needs the whole payload before
|
|
// the rename. Streaming PUTs (chunked uploads, multi-part resumable)
|
|
// are out of scope for this iteration.
|
|
func readBodyCapped(cfg config.Config, w http.ResponseWriter, r *http.Request) ([]byte, bool) {
|
|
limit := cfg.MaxWriteBytes
|
|
if limit <= 0 {
|
|
limit = 256 * 1024 * 1024
|
|
}
|
|
// http.MaxBytesReader writes a 413 itself when the limit is hit
|
|
// during read, but its error message is not always recognizable —
|
|
// we wrap it to surface a clean status code from the wrapped error.
|
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
var maxErr *http.MaxBytesError
|
|
if errors.As(err, &maxErr) {
|
|
http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
|
|
return nil, false
|
|
}
|
|
http.Error(w, "Bad Request — could not read body: "+err.Error(), http.StatusBadRequest)
|
|
return nil, false
|
|
}
|
|
return body, true
|
|
}
|
|
|
|
// fileETag returns the SHA-256 first-32-hex of bytes — the same scheme
|
|
// the static file serve handler uses, so PUT response ETags match what
|
|
// a subsequent GET would compute.
|
|
func fileETag(body []byte) string {
|
|
sum := sha256.Sum256(body)
|
|
return hex.EncodeToString(sum[:])[:32]
|
|
}
|
|
|
|
// fileETagOnDisk returns the ETag of the file at absPath (or "" if it
|
|
// doesn't exist). Used to evaluate If-Match on PUT/DELETE/MOVE.
|
|
func fileETagOnDisk(absPath string) (string, error) {
|
|
body, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
return fileETag(body), nil
|
|
}
|
|
|
|
// checkIfMatch returns true if the request's If-Match header (when
|
|
// present) matches the current ETag for absPath. Empty header always
|
|
// passes. Wildcard ("*") passes iff the file exists. On precondition
|
|
// failure the response is written as 412 and false is returned.
|
|
//
|
|
// Special case for PUT: when allowMissing is true and the file doesn't
|
|
// exist, the wildcard "*" form fails (per RFC) but a specific ETag is
|
|
// treated as a no-current-file hit (412). This distinguishes
|
|
// create-new from update-existing semantically.
|
|
//
|
|
// Also honors If-Unmodified-Since (RFC 7232 §3.4): the request fails
|
|
// with 412 if the current file's mtime is strictly later than the
|
|
// header value. Used by the cache layer's offline-write outbox to
|
|
// detect concurrent modifications without ETag round-trips — the
|
|
// cached file's mtime (set from upstream's Last-Modified) becomes the
|
|
// base for the precondition. Either header (or both) can be present;
|
|
// both must pass.
|
|
func checkIfMatch(w http.ResponseWriter, r *http.Request, absPath string) bool {
|
|
if !checkIfUnmodifiedSince(w, r, absPath) {
|
|
return false
|
|
}
|
|
header := strings.TrimSpace(r.Header.Get("If-Match"))
|
|
if header == "" {
|
|
return true
|
|
}
|
|
current, err := fileETagOnDisk(absPath)
|
|
if err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return false
|
|
}
|
|
if header == "*" {
|
|
if current == "" {
|
|
http.Error(w, "Precondition Failed — target does not exist", http.StatusPreconditionFailed)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
want := strings.Trim(header, `"`)
|
|
if want != current {
|
|
http.Error(w, "Precondition Failed — ETag mismatch", http.StatusPreconditionFailed)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// checkIfUnmodifiedSince evaluates the RFC 7232 §3.4 precondition.
|
|
// Returns true (pass) when the header is absent or unparseable, when
|
|
// the target file does not exist, or when the file's current mtime
|
|
// is at or before the header value. Returns false (fail) and writes a
|
|
// 412 response when the file has been modified after the header time.
|
|
//
|
|
// mtime comparison uses the file's mod time truncated to whole
|
|
// seconds — HTTP-Date format has 1-second resolution, so a finer
|
|
// comparison would spuriously fail on filesystems that retain ns
|
|
// precision. "After" therefore means strictly greater than the
|
|
// header value at second resolution.
|
|
func checkIfUnmodifiedSince(w http.ResponseWriter, r *http.Request, absPath string) bool {
|
|
header := strings.TrimSpace(r.Header.Get("If-Unmodified-Since"))
|
|
if header == "" {
|
|
return true
|
|
}
|
|
since, err := http.ParseTime(header)
|
|
if err != nil {
|
|
// Per RFC 7232: if the header value is unparseable, ignore.
|
|
return true
|
|
}
|
|
info, err := os.Stat(absPath)
|
|
if err != nil {
|
|
// Missing file → no resource to compare against. Pass.
|
|
return true
|
|
}
|
|
current := info.ModTime().Truncate(time.Second)
|
|
if current.After(since.Truncate(time.Second)) {
|
|
http.Error(w, "Precondition Failed — If-Unmodified-Since: file modified at "+current.UTC().Format(http.TimeFormat), http.StatusPreconditionFailed)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|
abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
|
|
if !ok {
|
|
http.Error(w, msg, status)
|
|
return
|
|
}
|
|
if strings.HasSuffix(cleanURL, "/") {
|
|
http.Error(w, "PUT must target a file, not a directory", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// A PUT that would introduce a new party folder under a party_source
|
|
// peer (e.g. working/<newparty>/file, or filing into
|
|
// archive/<newparty>/received/) requires the party to be registered.
|
|
if rejected, why, _ := partySourceGate(cfg.Root, abs); rejected {
|
|
http.Error(w, why, http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Register rows (ssr/<party>.yaml, mdl|rsk/<party>/<file>.yaml) are
|
|
// real files in the flat-peer layout — a PUT targets them directly,
|
|
// no virtual→canonical rewrite. The path-derived $party/name column
|
|
// is injected only on read (ServeInjectedRow) and stripped by the
|
|
// client before submit.
|
|
|
|
// Virtual received/ rewrite. When the PUT targets a file under the
|
|
// synthetic <workflow>/received/<file> URL, the canonical record is
|
|
// WORM — we can't write there. Convention: treat the drop as a
|
|
// review comment, write it into the workflow folder as
|
|
// <base>+C<n><suffix> where n increments past any existing comments
|
|
// on the same target. The target filename comes from the URL's
|
|
// final segment.
|
|
if vr := zddc.ResolveVirtualReceived(cfg.Root, cleanURL); vr.Resolved && !vr.IsRoot {
|
|
targetName := filepath.Base(vr.SuffixURL)
|
|
commentName, cerr := zddc.CommentResolvedName(vr.WorkflowAbs, targetName)
|
|
if cerr != nil {
|
|
http.Error(w, "Bad Request — comment upload requires a ZDDC-parseable target filename: "+cerr.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Race-fix: if the computed filename already exists (concurrent
|
|
// upload), step the counter forward until we find a free slot.
|
|
abs = filepath.Join(vr.WorkflowAbs, commentName)
|
|
for i := 0; i < 32; i++ {
|
|
if _, err := os.Stat(abs); errors.Is(err, os.ErrNotExist) {
|
|
break
|
|
} else if err != nil {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Bump: recompute with one more existing sibling.
|
|
commentName, cerr = zddc.CommentResolvedName(vr.WorkflowAbs, targetName)
|
|
if cerr != nil {
|
|
http.Error(w, "Internal Server Error — comment counter: "+cerr.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
abs = filepath.Join(vr.WorkflowAbs, commentName)
|
|
}
|
|
// Rewrite cleanURL so audit logs + response headers reflect
|
|
// the actual destination, not the virtual one. Surface to the
|
|
// client via X-ZDDC-Resolved-Path so the status line can show
|
|
// "Saved as <resolved name>".
|
|
cleanURL = vr.WorkflowURL + commentName
|
|
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
|
|
// Continue with normal write flow — ACL on the workflow folder
|
|
// gates the write, and existed=false (new file) selects
|
|
// ActionCreate.
|
|
}
|
|
|
|
// Resolve canonical-folder casing on the way in (no side effects): a
|
|
// request for /Project/working/foo.md when the on-disk folder is
|
|
// Working/ should land in Working/, not create a duplicate sibling.
|
|
// The actual MkdirAll for missing canonical ancestors and the
|
|
// auto-own .zddc seeding happen after authorisation, below.
|
|
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
|
|
abs = r2
|
|
}
|
|
|
|
// Stat first so we can choose action=create vs action=write before the
|
|
// ACL gate runs — this matters because role grants may include `c` but
|
|
// not `w` (or vice versa), and the gate must check the right verb.
|
|
existed := false
|
|
if info, err := os.Stat(abs); err == nil {
|
|
if info.IsDir() {
|
|
http.Error(w, "Conflict — a directory exists at this path", http.StatusConflict)
|
|
return
|
|
}
|
|
existed = true
|
|
}
|
|
|
|
action := policy.ActionCreate
|
|
if existed {
|
|
action = policy.ActionWrite
|
|
}
|
|
// .zddc writes always require `a` (admin) regardless of create/overwrite.
|
|
if filepath.Base(abs) == ".zddc" {
|
|
action = policy.ActionAdmin
|
|
}
|
|
|
|
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
|
return
|
|
}
|
|
if !checkIfMatch(w, r, abs) {
|
|
return
|
|
}
|
|
|
|
body, ok := readBodyCapped(cfg, w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Now that the write is authorized, materialise any missing canonical
|
|
// ancestors and seed auto-own .zddc files for them.
|
|
if email := EmailFromContext(r); email != "" {
|
|
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil {
|
|
slog.Warn("ensure canonical ancestors", "path", abs, "err", err)
|
|
}
|
|
}
|
|
|
|
// Record files (mdl rows, rsk rows, ssr.yaml) route through
|
|
// WriteWithHistory which strips client-supplied audit fields,
|
|
// stamps server-managed ones, archives the prior version to
|
|
// <dir>/.history/<base>/, validates body fields against
|
|
// cascade-resolved field_codes, and enforces filename_format
|
|
// composition. Non-record YAML files (table.yaml, form.yaml,
|
|
// .zddc) and binary files take the plain write path below.
|
|
finalBody := body
|
|
stamped := false
|
|
if isRecordPath(abs) {
|
|
res, verrs, herr := WriteWithHistory(cfg, abs, cleanURL, body, EmailFromContext(r))
|
|
if herr != nil {
|
|
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), herr)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if len(verrs) > 0 {
|
|
writeValidationErrors(w, verrs)
|
|
auditFile(r, "put", cleanURL, http.StatusUnprocessableEntity, len(body), fmt.Errorf("validation: %d errors", len(verrs)))
|
|
return
|
|
}
|
|
finalBody = res.FinalBody
|
|
stamped = true
|
|
} else if IsTextHistoryCandidate(cfg.Root, abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) {
|
|
// History-enabled text (markdown) files: snapshot every save
|
|
// into <dir>/.history/<stem>/ with a server-stamped audit line,
|
|
// then write the live file. The live file at its natural path
|
|
// remains the source of truth.
|
|
if err := WriteTextWithHistory(abs, body, EmailFromContext(r)); err != nil {
|
|
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
if err := zddc.WriteAtomic(abs, body); err != nil {
|
|
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
// Invalidate ETag cache (static.go memoizes by mtime; rename produces
|
|
// a fresh mtime so a stale entry is harmless, but clearing is cheap).
|
|
etagCacheM.Delete(abs)
|
|
// Invalidate any cached MD→{docx,html,pdf} conversions sitting in
|
|
// the sibling .converted/ dir for this source.
|
|
purgeConverted(abs)
|
|
|
|
etag := fileETag(finalBody)
|
|
w.Header().Set("ETag", `"`+etag+`"`)
|
|
w.Header().Set("X-ZDDC-Source", "fileapi:put")
|
|
respStatus := http.StatusCreated
|
|
if existed {
|
|
respStatus = http.StatusOK
|
|
}
|
|
// For record-stamped writes, echo the server-truth body so the
|
|
// tables save flow can update row.data without a re-GET. Other
|
|
// writes return no body (historical contract preserved).
|
|
if stamped {
|
|
w.Header().Set("Content-Type", "application/yaml")
|
|
w.WriteHeader(respStatus)
|
|
_, _ = w.Write(finalBody)
|
|
} else {
|
|
w.WriteHeader(respStatus)
|
|
}
|
|
auditFile(r, "put", cleanURL, respStatus, len(finalBody), nil)
|
|
}
|
|
|
|
// isRecordPath returns true if abs is a candidate for record-style
|
|
// handling (audit stamping + history). Excludes the well-known
|
|
// configuration filenames that share record directories: table.yaml
|
|
// (table spec), form.yaml (form schema), and .zddc (cascade
|
|
// configuration). Non-YAML extensions also fall through to the plain
|
|
// write path.
|
|
func isRecordPath(abs string) bool {
|
|
base := filepath.Base(abs)
|
|
switch base {
|
|
case "table.yaml", "form.yaml", ".zddc":
|
|
return false
|
|
}
|
|
ext := filepath.Ext(base)
|
|
if ext != ".yaml" && ext != ".yml" {
|
|
return false
|
|
}
|
|
// Exclude *.table.yaml and *.form.yaml (alternate spec naming).
|
|
if strings.HasSuffix(base, ".table.yaml") || strings.HasSuffix(base, ".form.yaml") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|
abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
|
|
if !ok {
|
|
http.Error(w, msg, status)
|
|
return
|
|
}
|
|
// (Directory vs file is decided by stat below; the client sends a folder
|
|
// DELETE with a trailing slash. A directory delete is admin-gated.)
|
|
|
|
// Register rows are real files — a DELETE targets them directly with
|
|
// the normal ACL gate. (Deleting an ssr/<party>.yaml de-registers the
|
|
// party; the ssr/ ACL grants delete only to admins by default so a
|
|
// party with archived records can't be orphaned.)
|
|
|
|
info, err := os.Stat(abs)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if info.IsDir() {
|
|
// Directory delete is recursive (os.RemoveAll), which bypasses the
|
|
// per-file WORM/delete gates protecting the contents — so it's
|
|
// admin-only: an active admin over this subtree (a root admin, or a
|
|
// subtree admin within scope). This is the "admin mode exists for
|
|
// restructuring" capability.
|
|
p := PrincipalFromContext(r)
|
|
if !zddc.IsSubtreeAdmin(cfg.Root, abs, p) {
|
|
http.Error(w, "Forbidden — deleting a directory requires admin authority over it", http.StatusForbidden)
|
|
return
|
|
}
|
|
if err := os.RemoveAll(abs); err != nil {
|
|
auditFile(r, "delete", cleanURL+" (recursive)", http.StatusInternalServerError, 0, err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
etagCacheM.Delete(abs)
|
|
purgeConverted(abs)
|
|
w.Header().Set("X-ZDDC-Source", "fileapi:delete-dir")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
auditFile(r, "delete", cleanURL+" (recursive)", http.StatusNoContent, 0, nil)
|
|
return
|
|
}
|
|
|
|
// File delete: a trailing slash is a directory URL — reject the mismatch.
|
|
if strings.HasSuffix(cleanURL, "/") {
|
|
http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
action := policy.ActionDelete
|
|
if filepath.Base(abs) == ".zddc" {
|
|
action = policy.ActionAdmin
|
|
}
|
|
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
|
return
|
|
}
|
|
if !checkIfMatch(w, r, abs) {
|
|
return
|
|
}
|
|
|
|
if err := os.Remove(abs); err != nil {
|
|
auditFile(r, "delete", cleanURL, http.StatusInternalServerError, 0, err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
etagCacheM.Delete(abs)
|
|
purgeConverted(abs)
|
|
|
|
w.Header().Set("X-ZDDC-Source", "fileapi:delete")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
auditFile(r, "delete", cleanURL, http.StatusNoContent, 0, nil)
|
|
}
|
|
|
|
func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|
op := strings.ToLower(strings.TrimSpace(r.Header.Get(headerOp)))
|
|
switch op {
|
|
case opMove:
|
|
serveFileMove(cfg, w, r)
|
|
case opMkdir:
|
|
serveFileMkdir(cfg, w, r)
|
|
case opPlanReview:
|
|
servePlanReview(cfg, w, r)
|
|
case opAcceptTransmittal:
|
|
serveAcceptTransmittal(cfg, w, r)
|
|
case opSSRRename:
|
|
serveSSRRename(cfg, w, r)
|
|
case "":
|
|
http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest)
|
|
default:
|
|
http.Error(w, "Bad Request — unknown "+headerOp+" value: "+op, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|
srcAbs, srcURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
|
|
if !ok {
|
|
http.Error(w, msg, status)
|
|
return
|
|
}
|
|
// (A trailing slash on src/dst signals a directory target; we no longer
|
|
// reject it here — file-vs-directory is decided by stat below, and a
|
|
// directory move is admin-gated.)
|
|
|
|
dstHeader := r.Header.Get(headerDestination)
|
|
if dstHeader == "" {
|
|
http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Destination is sent as a URL path; decode percent-encoding.
|
|
if dec, err := url.PathUnescape(dstHeader); err == nil {
|
|
dstHeader = dec
|
|
}
|
|
dstAbs, dstURL, ok, status, msg := resolveTargetPath(cfg, dstHeader)
|
|
if !ok {
|
|
http.Error(w, "destination: "+msg, status)
|
|
return
|
|
}
|
|
// A move whose destination introduces a new party folder under a
|
|
// party_source peer requires the party to be registered.
|
|
if rejected, why, _ := partySourceGate(cfg.Root, dstAbs); rejected {
|
|
http.Error(w, "destination: "+why, http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Resolve canonical-folder casing on src + dst (no side effects).
|
|
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, srcAbs); err == nil {
|
|
srcAbs = r2
|
|
}
|
|
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, dstAbs); err == nil {
|
|
dstAbs = r2
|
|
}
|
|
|
|
// Source must exist as a regular file.
|
|
srcInfo, err := os.Stat(srcAbs)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
} else {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
isDir := srcInfo.IsDir()
|
|
if isDir {
|
|
// Directory moves relocate the whole subtree with one os.Rename,
|
|
// which sidesteps the per-file WORM/ACL gates protecting the
|
|
// descendants — so they're admin-only: an active admin over BOTH the
|
|
// source subtree and the destination's parent (a root admin covers
|
|
// all; a subtree admin within their own scope). This is the "admin
|
|
// mode exists for restructuring" capability.
|
|
p := PrincipalFromContext(r)
|
|
if !zddc.IsSubtreeAdmin(cfg.Root, srcAbs, p) ||
|
|
!zddc.IsSubtreeAdmin(cfg.Root, filepath.Dir(dstAbs), p) {
|
|
http.Error(w, "Forbidden — moving a directory requires admin authority over the source and destination", http.StatusForbidden)
|
|
return
|
|
}
|
|
// Refuse moving a directory into itself or one of its descendants.
|
|
if dstAbs == srcAbs || strings.HasPrefix(dstAbs, srcAbs+string(filepath.Separator)) {
|
|
http.Error(w, "Conflict — cannot move a directory into itself", http.StatusConflict)
|
|
return
|
|
}
|
|
} else if strings.HasSuffix(dstURL, "/") {
|
|
// A file move must target a file path, not a directory URL.
|
|
http.Error(w, "destination: MOVE of a file must target a file path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Destination must not exist (no implicit overwrite). If-Match on the
|
|
// SOURCE is still respected for concurrency on the source bytes.
|
|
if _, err := os.Stat(dstAbs); err == nil {
|
|
http.Error(w, "Conflict — destination already exists", http.StatusConflict)
|
|
return
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// ACL: source side requires `w` (rename mutates the source); dest
|
|
// side requires `c` (creates a new path). Cross-folder moves run
|
|
// both gates against potentially different chains.
|
|
if !authorizeAction(cfg, w, r, srcAbs, srcURL, policy.ActionWrite) {
|
|
return
|
|
}
|
|
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
|
|
return
|
|
}
|
|
// If-Match concurrency applies to the source bytes — only meaningful for
|
|
// a file. A directory carries no ETag, so skip the precondition.
|
|
if !isDir {
|
|
if !checkIfMatch(w, r, srcAbs) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Ensure destination's canonical ancestors are created (with auto-own
|
|
// .zddc seeding) before the rename. This lets a MOVE from working/foo
|
|
// → archive/<party>/issued/foo materialise the per-party folders on
|
|
// the way in.
|
|
if email := EmailFromContext(r); email != "" {
|
|
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, dstAbs, email, 0o755); err != nil {
|
|
slog.Warn("ensure canonical ancestors (move dst)", "path", dstAbs, "err", err)
|
|
}
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
|
|
auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
|
// Cross-device or permission errors: report 500 — the client
|
|
// will retry or surface the failure to the user.
|
|
auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err)
|
|
http.Error(w, "Internal Server Error — rename failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
etagCacheM.Delete(srcAbs)
|
|
etagCacheM.Delete(dstAbs)
|
|
purgeConverted(srcAbs)
|
|
purgeConverted(dstAbs)
|
|
|
|
// Carry edit-history across an in-place rename: if a markdown file was
|
|
// renamed within the same directory, move its .history/<stem>/ folder to
|
|
// match the new name. A cross-directory move deliberately leaves history
|
|
// behind (it lives forever in the dir where the edits happened).
|
|
if IsTextHistoryCandidate(cfg.Root, srcAbs) && filepath.Dir(srcAbs) == filepath.Dir(dstAbs) {
|
|
oldHist := mdHistoryDir(srcAbs)
|
|
newHist := mdHistoryDir(dstAbs)
|
|
if oldHist != newHist {
|
|
if _, err := os.Stat(oldHist); err == nil {
|
|
if _, derr := os.Stat(newHist); errors.Is(derr, os.ErrNotExist) {
|
|
if rerr := os.Rename(oldHist, newHist); rerr != nil {
|
|
slog.Warn("rename history dir", "from", oldHist, "to", newHist, "err", rerr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute new ETag from the moved bytes for the response — clients
|
|
// that want to keep tracking should pin to this ETag.
|
|
if etag, err := fileETagOnDisk(dstAbs); err == nil && etag != "" {
|
|
w.Header().Set("ETag", `"`+etag+`"`)
|
|
}
|
|
w.Header().Set("X-ZDDC-Source", "fileapi:move")
|
|
w.Header().Set("X-ZDDC-Destination", dstURL)
|
|
w.WriteHeader(http.StatusOK)
|
|
auditFile(r, "move", srcURL+" -> "+dstURL, http.StatusOK, 0, nil)
|
|
}
|
|
|
|
func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|
abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
|
|
if !ok {
|
|
http.Error(w, msg, status)
|
|
return
|
|
}
|
|
|
|
// Project-root mkdir policy: the only physical child allowed
|
|
// directly under <project>/ is `archive` (plus _/.-prefixed
|
|
// system names). Mkdir of any other name — including the six
|
|
// non-peer name — is rejected with 409.
|
|
if rejected, why := rejectProjectRootMkdir(cfg.Root, abs); rejected {
|
|
http.Error(w, why, http.StatusConflict)
|
|
return
|
|
}
|
|
// A new party folder under a party_source peer requires the party to
|
|
// be registered (ssr/<party>.yaml exists); else 409.
|
|
if rejected, why := rejectUnregisteredPartyMkdir(cfg.Root, abs); rejected {
|
|
http.Error(w, why, http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Resolve canonical-folder casing on the way in (no side effects).
|
|
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
|
|
abs = r2
|
|
}
|
|
|
|
if !authorizeAction(cfg, w, r, abs, cleanURL, policy.ActionCreate) {
|
|
return
|
|
}
|
|
|
|
// Idempotent: if the dir already exists, treat it as success;
|
|
// if a file is at the path, conflict.
|
|
if info, err := os.Stat(abs); err == nil {
|
|
if info.IsDir() {
|
|
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
|
|
w.WriteHeader(http.StatusOK)
|
|
auditFile(r, "mkdir", cleanURL, http.StatusOK, 0, nil)
|
|
return
|
|
}
|
|
http.Error(w, "Conflict — a file exists at this path", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
// Materialise any missing canonical ancestors (working/, staging/,
|
|
// archive/<party>/incoming/) before creating the target itself. This
|
|
// also seeds auto-own .zddc on each newly-created canonical ancestor.
|
|
email := EmailFromContext(r)
|
|
if email != "" {
|
|
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil {
|
|
slog.Warn("ensure canonical ancestors", "path", abs, "err", err)
|
|
}
|
|
}
|
|
|
|
if err := os.MkdirAll(abs, 0o755); err != nil {
|
|
auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Auto-ownership for the newly-created directory. The .zddc
|
|
// cascade's `auto_own:` flag (see defaults.zddc.yaml) drives this,
|
|
// same as EnsureCanonicalAncestors. A creator-owned .zddc lands
|
|
// inside abs when:
|
|
// - abs itself is declared auto_own (e.g. an explicit mkdir of
|
|
// /Project/working), or
|
|
// - abs's parent is declared auto_own — every child mkdir under
|
|
// an auto-own folder (working/, staging/, archive/<party>/,
|
|
// archive/<party>/incoming/, …) gets the creator's grant.
|
|
// The fence (inherit:false) follows abs's own cascade level:
|
|
// per-user homes under working/ declare auto_own_fenced, so the
|
|
// generated .zddc is private; other auto-own positions are
|
|
// unfenced so ancestor grants still cascade through.
|
|
if email != "" {
|
|
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
|
|
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)
|
|
var werr error
|
|
if zddc.AutoOwnFencedAt(cfg.Root, abs) {
|
|
werr = zddc.WriteAutoOwnZddcFenced(abs, email, roles)
|
|
} else {
|
|
werr = zddc.WriteAutoOwnZddc(abs, email, roles)
|
|
}
|
|
if werr != nil {
|
|
slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr)
|
|
}
|
|
}
|
|
}
|
|
|
|
// (The pre-reshape staging↔working mirror was retired: with
|
|
// staging at archive/<party>/staging/<batch>/ and working at
|
|
// archive/<party>/working/<email>/, the project-level pairing
|
|
// no longer maps cleanly. Operators who want a per-batch drafting
|
|
// space create it inside their own working/<email>/ home.)
|
|
|
|
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
|
|
w.WriteHeader(http.StatusCreated)
|
|
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
|
|
}
|
|
|
|
// rejectProjectRootMkdir reports whether a mkdir at abs lands at
|
|
// <project>/<name>/ where <name> is forbidden as a direct project-
|
|
// root physical child. Under the canonical layout:
|
|
//
|
|
// - `archive` is the only physical project-root canonical folder
|
|
// - `_`-/`.`-prefixed names are system-reserved and allowed
|
|
// - the six virtual aggregator names (ssr/mdl/rsk/working/staging/
|
|
// reviewing) are explicitly rejected — the virtual resolver
|
|
// would shadow any physical folder created at those URLs
|
|
// - any other name is rejected: project-root mkdir of an ad-hoc
|
|
// name was an artefact of the pre-reshape layout where doc
|
|
// controllers could create freeform top-level folders, but the
|
|
// new model treats the project root as exclusively system + the
|
|
// archive/ party-holder.
|
|
//
|
|
// Returns (true, reason) when the request should be 409'd. Returns
|
|
// (false, "") when the target is at any other depth or carries an
|
|
// allowed name.
|
|
func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
|
|
rel, err := filepath.Rel(fsRoot, abs)
|
|
if err != nil {
|
|
return false, ""
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if rel == "." || strings.HasPrefix(rel, "../") {
|
|
return false, ""
|
|
}
|
|
parts := strings.Split(rel, "/")
|
|
if len(parts) != 2 {
|
|
// Not a direct project-root child — depth-2 = <project>/<name>.
|
|
return false, ""
|
|
}
|
|
name := parts[1]
|
|
if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") {
|
|
// System-reserved namespace; allowed.
|
|
return false, ""
|
|
}
|
|
if zddc.IsProjectPeer(name) {
|
|
return false, ""
|
|
}
|
|
return true, "Conflict — only the canonical peers (archive, incoming, working, staging, reviewing, mdl, rsk, ssr) and system-reserved (_/. prefix) folders may be created directly under a project."
|
|
}
|
|
|
|
// rejectUnregisteredPartyMkdir enforces the party_source cascade key: a
|
|
// new <party> folder under a peer that declares party_source (every peer
|
|
// except ssr/) may be created only if the party is registered — i.e. the
|
|
// registry entry exists (ssr/<party>.yaml). Applies at the party-segment
|
|
// depth and below (<project>/<peer>/<party>[/...]). Registration itself
|
|
// (creating ssr/<party>.yaml) is not gated — ssr/ sets no party_source.
|
|
func rejectUnregisteredPartyMkdir(fsRoot, abs string) (bool, string) {
|
|
reject, msg, _ := partySourceGate(fsRoot, abs)
|
|
return reject, msg
|
|
}
|
|
|
|
// partySourceGate is the shared party_source check used by mkdir, PUT
|
|
// (create), and move (dst). It returns reject=true (+ a 409 message)
|
|
// when abs would introduce a <party> segment under a party_source peer
|
|
// for a party that isn't registered. The third return is the resolved
|
|
// party name (for callers that want to log it).
|
|
func partySourceGate(fsRoot, abs string) (reject bool, msg, party string) {
|
|
rel, err := filepath.Rel(fsRoot, abs)
|
|
if err != nil {
|
|
return false, "", ""
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if rel == "." || strings.HasPrefix(rel, "../") {
|
|
return false, "", ""
|
|
}
|
|
parts := strings.Split(rel, "/")
|
|
if len(parts) < 3 {
|
|
return false, "", "" // <project>/<peer> — no party segment yet
|
|
}
|
|
project, peer, p := parts[0], parts[1], parts[2]
|
|
source := zddc.PartySourceAt(fsRoot, filepath.Join(fsRoot, project, peer))
|
|
if source == "" {
|
|
return false, "", "" // peer does no party gating (e.g. ssr/)
|
|
}
|
|
if zddc.PartyRegistered(filepath.Join(fsRoot, project), source, p) {
|
|
return false, "", p
|
|
}
|
|
return true, "Conflict — unknown party \"" + p + "\". Register it first by creating " + source + "/" + p + ".yaml (the SSR form).", p
|
|
}
|
|
|
|
// auditFile emits a structured log line for each file API operation.
|
|
// AccessLogMiddleware already logs every request — this adds an
|
|
// op-tagged line so audit consumers can filter by operation without
|
|
// pattern-matching on method + path.
|
|
func auditFile(r *http.Request, op, path string, status any, bytes int, err error) {
|
|
email := EmailFromContext(r)
|
|
if email == "" {
|
|
email = "anonymous"
|
|
}
|
|
args := []any{
|
|
"op", op,
|
|
"path", path,
|
|
"email", email,
|
|
"status", fmt.Sprint(status),
|
|
"bytes", bytes,
|
|
}
|
|
if err != nil {
|
|
args = append(args, "err", err.Error())
|
|
slog.Warn("file_write", args...)
|
|
return
|
|
}
|
|
slog.Info("file_write", args...)
|
|
}
|