ZDDC/zddc/internal/handler/fileapi.go
ZDDC 70d49ba111 fix(client): three bugs found by live smoke testing
Phase 3 + 4 live two-instance smoke tests against the synthetic
~/zddc-test-data fixture surfaced three real bugs that the unit
tests missed. All three are fixed in this commit.

1. walker: filenames with spaces/parens land on disk percent-encoded

   walkSubtree was passing the URL-encoded child URL (built via
   url.PathEscape) to fetchFileIfNeeded → cachePathFor, so a file
   named "Foo (IFI) - Bar.md" landed at <root>/.../Foo%20%28IFI%29
   %20-%20Bar.md on disk. Then purgeOrphans iterated os.ReadDir
   (which sees the encoded names) and compared against upstreamNames
   (decoded names from the listing JSON). Every fetched file was
   classified as an orphan and immediately deleted: a 180-file walk
   produced "fetched=180 purged=111" with only 70 files remaining.

   Fix: walker now maintains two parallel path strings — dirURL
   (URL-encoded for HTTP requests) and dirPath (decoded for disk
   keys). fetchFileIfNeeded, fetchListing, persistOnly, and
   purgeOrphans all take the decoded path. listingCachePathFor
   gets dirPath too. Smoke confirmed: dirs=29 files=180 fetched=179
   purged=0 (one file already cached from the user's GET that
   triggered the walk).

2. outbox: replay loop sleeps 5min after eager startup pass

   RunReplayLoop's idle-poll interval is 5min. After the eager
   startup pass with 0 entries, the loop sleeps 5min — even if a
   PUT-while-offline arrives 1 second later, replay won't fire for
   ~5 min. The cache returned 202 promptly but the queued write sat
   on disk until either a 5min nap elapsed or another PUT happened.

   Fix: Outbox gains a wake chan (buffered=1, drop-on-full).
   Enqueue posts to it after writing meta.json. RunReplayLoop selects
   on wake alongside the timer, so a new offline write triggers an
   immediate replay attempt. Smoke confirmed: PUT queued at T+0,
   master back at T+3, replay completes at T+3 (was previously a
   30s wait through the timer-based poll).

3. master: PUT/DELETE didn't honor If-Unmodified-Since

   The cache's outbox sends If-Unmodified-Since: <cached-mtime> on
   replay so the master can reject conflicting writes with 412. The
   master's checkIfMatch only evaluated If-Match (ETag-based), so
   the cache's mtime-based precondition was silently ignored. Result:
   an offline PUT staged before an external mod would clobber the
   newer external content on replay — silent data loss in the exact
   scenario the outbox is designed to detect.

   Fix: checkIfMatch now also evaluates If-Unmodified-Since per
   RFC 7232 §3.4, returning 412 when the file's current mtime is
   strictly later than the header value (1-second resolution to
   match HTTP-Date precision). Smoke confirmed: cache GET → external
   mod via direct file write → cache offline PUT → master back →
   replay sends IUS → master 412 → outbox entry renamed to
   <id>.conflict-<RFC3339>/ → master content preserved (the
   external mod, not the stale offline write).

Also added an info-level "outbox: replay attempt" log to tryReplay
so an operator watching the cache logs sees the replay loop is
alive even when every entry defers (transport error). Previously
the loop was silent unless a replay actually completed (200) or
conflicted (412).

go vet + go test ./... + go test -race ./internal/{cache,auth,handler}/...
all green. Synthetic ~/zddc-test-data fixture (553 files, 144 PDFs)
exercises the walker against realistic ZDDC filenames including
spaces, parens, and accented characters that the unit tests'
"a.txt" / "b.txt" inputs never hit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:34:07 -05:00

725 lines
25 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"
)
// 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 += "/"
}
// Reject hidden / reserved segments. Mirrors dispatch's guard,
// applied here too because external callers reach ServeFileAPI
// only via dispatch — but defense in depth costs nothing.
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
if seg == "" {
continue
}
if seg == "_app" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
return "", "", false, http.StatusNotFound, "reserved path segment"
}
}
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.
//
// Admin escape hatches: root admins (IsAdmin) and subtree admins
// (IsSubtreeAdmin) get unconditional access — the cascade evaluator
// and the WORM mask do not see their requests at all. This matches
// the existing admin-bypass semantics in /.profile/zddc and is the
// only way to mutate filed documents in Issued/Received.
//
// .zddc writes use the stricter CanEditZddc rule (strict-ancestor
// admin authority) regardless of the action verb, since the file
// being written is itself the source of the authority decision and
// the strict-ancestor rule is the existing defense against
// self-elevation.
func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool {
probe := filepath.Dir(absPath)
for {
info, err := os.Stat(probe)
if err == nil && info.IsDir() {
break
}
if probe == cfg.Root || !strings.HasPrefix(probe, cfg.Root+string(filepath.Separator)) {
probe = cfg.Root
break
}
probe = filepath.Dir(probe)
}
email := EmailFromContext(r)
// Admin bypass — root and subtree.
if zddc.IsAdmin(cfg.Root, email) {
return true
}
if zddc.IsSubtreeAdmin(cfg.Root, probe, email) {
return true
}
// .zddc writes: CanEditZddc enforces the strict-ancestor rule that
// prevents a subtree admin from elevating themselves by editing the
// .zddc that grants their authority. Non-admins fall through to the
// regular decider — they will be denied unless an explicit `a` verb
// is granted to a non-admin role at this path, which is unusual.
if filepath.Base(absPath) == ".zddc" {
zddcDir := filepath.Dir(absPath)
if zddc.CanEditZddc(cfg.Root, zddcDir, email) {
return true
}
// Non-admin .zddc writes go through the normal cascade with
// action=admin. Most deployments will have no acl.permissions
// entry granting `a`, so this denies; operators who want
// non-admin .zddc edits can grant `a` explicitly.
}
chain, err := zddc.EffectivePolicy(cfg.Root, probe)
if err != nil {
slog.Warn("file API ACL chain error", "path", absPath, "err", err)
}
decider := DeciderFromContext(r)
allowed, _ := policy.AllowActionFromChain(r.Context(), decider, chain, email, urlPath, action)
if !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
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
}
// 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)
}
}
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)
etag := fileETag(body)
w.Header().Set("ETag", `"`+etag+`"`)
w.Header().Set("X-ZDDC-Source", "fileapi:put")
respStatus := http.StatusCreated
if existed {
respStatus = http.StatusOK
}
w.WriteHeader(respStatus)
auditFile(r, "put", cleanURL, respStatus, len(body), nil)
}
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
}
if strings.HasSuffix(cleanURL, "/") {
http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest)
return
}
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() {
http.Error(w, "Conflict — DELETE of directories is not supported", http.StatusConflict)
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)
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 "":
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
}
if strings.HasSuffix(srcURL, "/") {
http.Error(w, "MOVE source must be a file path", http.StatusBadRequest)
return
}
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
}
if strings.HasSuffix(dstURL, "/") {
http.Error(w, "MOVE destination must be a file path", http.StatusBadRequest)
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
}
if srcInfo.IsDir() {
http.Error(w, "Conflict — MOVE of directories is not supported", http.StatusConflict)
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 !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)
// 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
}
// 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 itself.
//
// Two cases yield an auto-own .zddc inside abs:
// - The new directory is itself a canonical auto-own position
// (e.g. an explicit MKCOL of /Project/working). In this case
// IsAutoOwnPath(abs, cfg.Root) is true.
// - The new directory's parent is canonical auto-own — every child
// mkdir under working/, staging/, or archive/<party>/incoming/
// gets the creator's grant.
if email != "" {
if zddc.IsAutoOwnPath(abs, cfg.Root) || zddc.IsAutoOwnPath(filepath.Dir(abs), cfg.Root) {
if err := zddc.WriteAutoOwnZddc(abs, email); err != nil {
slog.Warn("auto-own .zddc write failed", "path", abs, "err", err)
}
}
}
// Staging↔working mirror: when a folder created under staging/ matches
// the ZDDC transmittal-folder grammar AND its tracking number contains
// -SUB- or -TRN-, also create the same-named folder under working/ as
// a drafting space for staff. The mirror is one-way and one-shot —
// renames or deletions of either side are not propagated.
if email != "" {
mirrorStagingToWorking(cfg, abs, email)
}
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
w.WriteHeader(http.StatusCreated)
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
}
// mirrorStagingToWorking creates a paired drafting folder under working/
// when newAbs is a transmittal-named folder under <project>/staging/. Best
// effort — failures are logged but do not affect the staging mkdir result.
//
// Eligibility:
// - newAbs's parent is exactly <project>/staging/ (case-fold)
// - filepath.Base(newAbs) parses as a transmittal folder
// (YYYY-MM-DD_<tracking> (<status>) - <title>)
// - tracking contains -SUB- or -TRN- (case-fold)
//
// Side effects on success:
// - <project>/working/ created if missing, with auto-own .zddc seeded
// (via EnsureCanonicalAncestors)
// - <project>/working/<sameName>/ created if missing, with its own
// auto-own .zddc (it's a child of the working/ canonical folder)
func mirrorStagingToWorking(cfg config.Config, newAbs, email string) {
rel, err := filepath.Rel(cfg.Root, newAbs)
if err != nil {
return
}
rel = filepath.ToSlash(rel)
parts := strings.Split(rel, "/")
if len(parts) != 3 {
// Mirror only fires for direct children of staging/. Deeper paths
// (staging/<name>/sub/) are user-managed.
return
}
if !strings.EqualFold(parts[1], "staging") {
return
}
name := parts[2]
_, tracking, _, _, ok := zddc.ParseTransmittalFolder(name)
if !ok || !zddc.IsTrnOrSubTracking(tracking) {
return
}
mirrorPath := filepath.Join(cfg.Root, parts[0], "working", name)
// Idempotent: skip if the working sibling already exists.
if info, err := os.Stat(mirrorPath); err == nil && info.IsDir() {
return
}
// EnsureCanonicalAncestors creates working/ (with its own auto-own .zddc)
// if missing; we then MkdirAll the mirror folder itself and seed its
// auto-own grant.
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, mirrorPath, email, 0o755); err != nil {
slog.Warn("mirror: ensure ancestors", "path", mirrorPath, "err", err)
return
}
if err := os.MkdirAll(mirrorPath, 0o755); err != nil {
slog.Warn("mirror: mkdir", "path", mirrorPath, "err", err)
return
}
if err := zddc.WriteAutoOwnZddc(mirrorPath, email); err != nil {
slog.Warn("mirror: auto-own .zddc", "path", mirrorPath, "err", err)
}
}
// 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...)
}