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 / 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 / remove a file. Optional If-Match. Refuses to // delete directories or hidden paths. // POST / 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 //. 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 += "/" } // 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. // Carve-out: `.zddc` as a leaf segment is writable (admin-gated) // via the file API. Other dot/underscore segments stay reserved. segs := strings.Split(strings.Trim(cleanURL, "/"), "/") for i, seg := range segs { if seg == "" { continue } if seg == ZddcFileBasename && i == len(segs)-1 { 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. // // 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 { 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) } p := PrincipalFromContext(r) 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.AllowActionFromChainP(r.Context(), decider, chain, p, 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 } // Virtual project-level table views — SSR / MDL rollup / RSK // rollup. The PUT URL lives in /{ssr,mdl,rsk}/...; the // underlying bytes belong inside /archive//. We // rewrite abs + cleanURL to the canonical path so the rest of // this function (ACL gate, ETag, audit, conversion-cache purge) // operates on the real file location. // // SSR row PUTs land at archive//ssr.yaml; MDL/RSK rollup // row PUTs land at archive///.yaml. Same // shape as the virtual-received rewrite below. if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() { abs = vv.CanonicalAbs cleanURL = vv.CanonicalURL w.Header().Set("X-ZDDC-Resolved-Path", cleanURL) } // Virtual received/ rewrite. When the PUT targets a file under the // synthetic /received/ 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 // +C 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 ". 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) } } 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(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 } // Virtual project-level table views. SSR row deletes are refused // (would orphan the party folder and its mdl/rsk contents) — use // the archive view to delete a party. MDL/RSK rollup row deletes // pass through to the canonical archive///.yaml // path with the normal ACL gate. if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() { if vv.Kind == zddc.VirtualViewSSRRow { http.Error(w, "Method Not Allowed — delete the party folder via the archive view, not the SSR table", http.StatusMethodNotAllowed) return } abs = vv.CanonicalAbs cleanURL = vv.CanonicalURL w.Header().Set("X-ZDDC-Resolved-Path", cleanURL) } 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) 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 } 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//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) // 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//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//, // archive//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)) { var werr error if zddc.AutoOwnFencedAt(cfg.Root, abs) { werr = zddc.WriteAutoOwnZddcFenced(abs, email) } else { werr = zddc.WriteAutoOwnZddc(abs, email) } if werr != nil { slog.Warn("auto-own .zddc write failed", "path", abs, "err", werr) } } } // 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 /staging/. Best // effort — failures are logged but do not affect the staging mkdir result. // // Eligibility: // - newAbs's parent is exactly /staging/ (case-fold) // - filepath.Base(newAbs) parses as a transmittal folder // (YYYY-MM-DD_ () - ) // - 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...) }