authorizeAction (file API) and executePlanReview both used to make their own IsAdmin / IsSubtreeAdmin / CanEditZddc calls before falling through to the decider. After this commit every admin/elevation branch is in policy.InternalDecider.Allow — the handlers just call AllowActionFromChainP with the principal and let the decider decide. fileapi.go authorizeAction: - ~60 lines → ~20 lines. - Three early-outs (IsAdmin / IsSubtreeAdmin / CanEditZddc) removed. - .zddc strict-ancestor rule preserved: AllowActionFromChainP detects action == ActionAdmin (serveFilePut tags .zddc writes that way) and applies excludeLeaf=true to IsAdminForChain. planreview.go executePlanReview: - Two preflight checks now flow through AllowActionFromChainP. - The "is admin OR is subtree admin? else fall through to decider" braid collapses to one decider call per target. - Behavior preserved: subtree-admin authority required for the reviewing/staging workflow roots (strict-ancestor via ActionAdmin), WORM-cr authority required for received/<tracking>/ creation. Plan Review and Accept Transmittal tests still pass, lock-in invariants still hold (un-elevated admin denied, elevated admin bypasses, subtree scope, strict-ancestor, etc.). Next: remove the now-dead IsAdmin / IsSubtreeAdmin / CanEditZddc helpers (still referenced by profilehandler and authcheck), or keep them — they're not on a hot path and the migration there is its own commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
758 lines
26 KiB
Go
758 lines
26 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.
|
|
//
|
|
// 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 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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 "":
|
|
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)
|
|
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/<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)) {
|
|
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 <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...)
|
|
}
|