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 += "/" } // 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// when working// 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//file, or filing into // archive//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/.yaml, mdl|rsk//.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 /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 } // Config files (.zddc / .zddc.zip) always require `a` (admin/config-edit) // regardless of create/overwrite — see configWriteAction. action = configWriteAction(abs, action) 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 // /.history//, 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 /.history// 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/.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 } // Config files (.zddc / .zddc.zip) require `a` (admin/config-edit) to // delete — see configWriteAction. action := configWriteAction(abs, policy.ActionDelete) 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. // // Config files (.zddc / .zddc.zip) are policy, not content: relocating // one mutates policy at BOTH ends (removing it from the source dir, // installing it at the dest), so each side escalates to ActionAdmin — // the same VerbA/config-edit bar PUT and DELETE enforce. Without this a // caller holding only `w`/`c` could plant an attacker-controlled cascade // (admins:/acl:) via the header-borne destination, which no dispatch // gate inspects. See configWriteAction. if !authorizeAction(cfg, w, r, srcAbs, srcURL, configWriteAction(srcAbs, policy.ActionWrite)) { return } if !authorizeAction(cfg, w, r, dstAbs, dstURL, configWriteAction(dstAbs, 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//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// 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 / 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/.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//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 internal/zddc/defaults/) 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 via // AutoOwnFencedAt. It is an opt-in the default tree does not set — // the working/staging/incoming/reviewing party homes are auto-owned // but UNFENCED, so ancestor grants (e.g. project_team cr) cascade // through and they behave as shared team folders. An operator can // set auto_own_fenced on a position to make it private. 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//staging// and working at // archive//working//, the project-level pairing // no longer maps cleanly. Operators who want a per-batch drafting // space create it inside their own working// 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 // // where 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 = /. 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 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/.yaml). Applies at the party-segment // depth and below (//[/...]). Registration itself // (creating ssr/.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 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, "", "" // / — 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/) } // The gate only guards INTRODUCING a new party. Once the party // directory exists on disk the party is established, so a PUT/move // into its existing subtree (e.g. editing a file already filed under // working//…) must not be blocked — the registration check is // an onboarding guard, not a write gate. Without this, editing any // pre-existing file under a party folder whose registry row is // missing or differently-cased 409s on save. if fi, err := os.Stat(filepath.Join(fsRoot, project, peer, p)); err == nil && fi.IsDir() { return false, "", p } 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...) }