package handler import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "os" "path/filepath" "strings" "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" ) // IsWriteMethod reports whether this method is handled by the file API. // Used by the dispatcher to gate writes through ServeFileAPI before the // read-path tree of static / app / directory handling. func IsWriteMethod(method string) bool { switch method { case http.MethodPut, http.MethodDelete, http.MethodPost: return true } return false } // ServeFileAPI is the entry point for write methods. The dispatcher // has already verified the path doesn't contain reserved segments. // Caller must have already enforced the dot-prefix / _app guards // (these match dispatch's existing ones, but we re-check defensively). func ServeFileAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPut: serveFilePut(cfg, w, r) case http.MethodDelete: serveFileDelete(cfg, w, r) case http.MethodPost: serveFilePost(cfg, w, r) default: w.Header().Set("Allow", "GET, HEAD, PUT, DELETE, POST, OPTIONS") http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) } } // resolveTargetPath validates urlPath, joins it onto cfg.Root, and // rejects traversal/hidden segments. Returns absolute path + the // cleaned URL path (with one leading "/"). func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL string, ok bool, status int, msg string) { if urlPath == "" || urlPath == "/" { return "", "", false, http.StatusBadRequest, "empty path" } cleanURL = "/" + strings.Trim(urlPath, "/") if strings.HasSuffix(urlPath, "/") { cleanURL += "/" } // Reject hidden / reserved segments. Mirrors dispatch's guard, // applied here too because external callers reach ServeFileAPI // only via dispatch — but defense in depth costs nothing. for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") { if seg == "" { continue } if seg == "_app" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") { return "", "", false, http.StatusNotFound, "reserved path segment" } } rel := filepath.FromSlash(strings.TrimPrefix(strings.TrimSuffix(cleanURL, "/"), "/")) abs := filepath.Join(cfg.Root, rel) if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root { return "", "", false, http.StatusNotFound, "path traversal" } return abs, cleanURL, true, 0, "" } // authorizeAction runs the ACL chain for a verb-tagged write to absPath. // The chain is computed from the closest existing ancestor (so writes // that create a brand-new file inherit the parent directory's chain). // Returns allowed=false with the response status already written on deny. // // Admin escape hatches: root admins (IsAdmin) and subtree admins // (IsSubtreeAdmin) get unconditional access — the cascade evaluator // and the WORM mask do not see their requests at all. This matches // the existing admin-bypass semantics in /.profile/zddc and is the // only way to mutate filed documents in Issued/Received. // // .zddc writes use the stricter CanEditZddc rule (strict-ancestor // admin authority) regardless of the action verb, since the file // being written is itself the source of the authority decision and // the strict-ancestor rule is the existing defense against // self-elevation. func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool { probe := filepath.Dir(absPath) for { info, err := os.Stat(probe) if err == nil && info.IsDir() { break } if probe == cfg.Root || !strings.HasPrefix(probe, cfg.Root+string(filepath.Separator)) { probe = cfg.Root break } probe = filepath.Dir(probe) } email := EmailFromContext(r) // Admin bypass — root and subtree. if zddc.IsAdmin(cfg.Root, email) { return true } if zddc.IsSubtreeAdmin(cfg.Root, probe, email) { return true } // .zddc writes: CanEditZddc enforces the strict-ancestor rule that // prevents a subtree admin from elevating themselves by editing the // .zddc that grants their authority. Non-admins fall through to the // regular decider — they will be denied unless an explicit `a` verb // is granted to a non-admin role at this path, which is unusual. if filepath.Base(absPath) == ".zddc" { zddcDir := filepath.Dir(absPath) if zddc.CanEditZddc(cfg.Root, zddcDir, email) { return true } // Non-admin .zddc writes go through the normal cascade with // action=admin. Most deployments will have no acl.permissions // entry granting `a`, so this denies; operators who want // non-admin .zddc edits can grant `a` explicitly. } chain, err := zddc.EffectivePolicy(cfg.Root, probe) if err != nil { slog.Warn("file API ACL chain error", "path", absPath, "err", err) } decider := DeciderFromContext(r) allowed, _ := policy.AllowActionFromChain(r.Context(), decider, chain, email, urlPath, action) if !allowed { http.Error(w, "Forbidden", http.StatusForbidden) return false } return true } // readBodyCapped consumes r.Body up to cfg.MaxWriteBytes. Returns 413 // on overflow. The body is read fully into memory — small / medium files // are the dominant traffic and atomic write needs the whole payload before // the rename. Streaming PUTs (chunked uploads, multi-part resumable) // are out of scope for this iteration. func readBodyCapped(cfg config.Config, w http.ResponseWriter, r *http.Request) ([]byte, bool) { limit := cfg.MaxWriteBytes if limit <= 0 { limit = 256 * 1024 * 1024 } // http.MaxBytesReader writes a 413 itself when the limit is hit // during read, but its error message is not always recognizable — // we wrap it to surface a clean status code from the wrapped error. r.Body = http.MaxBytesReader(w, r.Body, limit) body, err := io.ReadAll(r.Body) if err != nil { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) return nil, false } http.Error(w, "Bad Request — could not read body: "+err.Error(), http.StatusBadRequest) return nil, false } return body, true } // fileETag returns the SHA-256 first-32-hex of bytes — the same scheme // the static file serve handler uses, so PUT response ETags match what // a subsequent GET would compute. func fileETag(body []byte) string { sum := sha256.Sum256(body) return hex.EncodeToString(sum[:])[:32] } // fileETagOnDisk returns the ETag of the file at absPath (or "" if it // doesn't exist). Used to evaluate If-Match on PUT/DELETE/MOVE. func fileETagOnDisk(absPath string) (string, error) { body, err := os.ReadFile(absPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", nil } return "", err } return fileETag(body), nil } // checkIfMatch returns true if the request's If-Match header (when // present) matches the current ETag for absPath. Empty header always // passes. Wildcard ("*") passes iff the file exists. On precondition // failure the response is written as 412 and false is returned. // // Special case for PUT: when allowMissing is true and the file doesn't // exist, the wildcard "*" form fails (per RFC) but a specific ETag is // treated as a no-current-file hit (412). This distinguishes // create-new from update-existing semantically. func checkIfMatch(w http.ResponseWriter, r *http.Request, absPath string) bool { 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 } func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path) if !ok { http.Error(w, msg, status) return } if strings.HasSuffix(cleanURL, "/") { http.Error(w, "PUT must target a file, not a directory", http.StatusBadRequest) return } // Resolve canonical-folder casing on the way in (no side effects): a // request for /Project/working/foo.md when the on-disk folder is // Working/ should land in Working/, not create a duplicate sibling. // The actual MkdirAll for missing canonical ancestors and the // auto-own .zddc seeding happen after authorisation, below. if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil { abs = r2 } // Stat first so we can choose action=create vs action=write before the // ACL gate runs — this matters because role grants may include `c` but // not `w` (or vice versa), and the gate must check the right verb. existed := false if info, err := os.Stat(abs); err == nil { if info.IsDir() { http.Error(w, "Conflict — a directory exists at this path", http.StatusConflict) return } existed = true } action := policy.ActionCreate if existed { action = policy.ActionWrite } // .zddc writes always require `a` (admin) regardless of create/overwrite. if filepath.Base(abs) == ".zddc" { action = policy.ActionAdmin } if !authorizeAction(cfg, w, r, abs, cleanURL, action) { return } if !checkIfMatch(w, r, abs) { return } body, ok := readBodyCapped(cfg, w, r) if !ok { return } // Now that the write is authorized, materialise any missing canonical // ancestors and seed auto-own .zddc files for them. if email := EmailFromContext(r); email != "" { if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil { slog.Warn("ensure canonical ancestors", "path", abs, "err", err) } } if err := zddc.WriteAtomic(abs, body); err != nil { auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Invalidate ETag cache (static.go memoizes by mtime; rename produces // a fresh mtime so a stale entry is harmless, but clearing is cheap). etagCacheM.Delete(abs) etag := fileETag(body) w.Header().Set("ETag", `"`+etag+`"`) w.Header().Set("X-ZDDC-Source", "fileapi:put") respStatus := http.StatusCreated if existed { respStatus = http.StatusOK } w.WriteHeader(respStatus) auditFile(r, "put", cleanURL, respStatus, len(body), nil) } func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) { abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path) if !ok { http.Error(w, msg, status) return } if strings.HasSuffix(cleanURL, "/") { http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest) return } info, err := os.Stat(abs) if err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "Not Found", http.StatusNotFound) return } http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if info.IsDir() { http.Error(w, "Conflict — DELETE of directories is not supported", http.StatusConflict) return } action := policy.ActionDelete if filepath.Base(abs) == ".zddc" { action = policy.ActionAdmin } if !authorizeAction(cfg, w, r, abs, cleanURL, action) { return } if !checkIfMatch(w, r, abs) { return } if err := os.Remove(abs); err != nil { auditFile(r, "delete", cleanURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } etagCacheM.Delete(abs) w.Header().Set("X-ZDDC-Source", "fileapi:delete") w.WriteHeader(http.StatusNoContent) auditFile(r, "delete", cleanURL, http.StatusNoContent, 0, nil) } func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) { op := strings.ToLower(strings.TrimSpace(r.Header.Get(headerOp))) switch op { case opMove: serveFileMove(cfg, w, r) case opMkdir: serveFileMkdir(cfg, w, r) case "": http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest) default: http.Error(w, "Bad Request — unknown "+headerOp+" value: "+op, http.StatusBadRequest) } } func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) { srcAbs, srcURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path) if !ok { http.Error(w, msg, status) return } if strings.HasSuffix(srcURL, "/") { http.Error(w, "MOVE source must be a file path", http.StatusBadRequest) return } dstHeader := r.Header.Get(headerDestination) if dstHeader == "" { http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest) return } // Destination is sent as a URL path; decode percent-encoding. if dec, err := url.PathUnescape(dstHeader); err == nil { dstHeader = dec } dstAbs, dstURL, ok, status, msg := resolveTargetPath(cfg, dstHeader) if !ok { http.Error(w, "destination: "+msg, status) return } if strings.HasSuffix(dstURL, "/") { http.Error(w, "MOVE destination must be a file path", http.StatusBadRequest) return } // Resolve canonical-folder casing on src + dst (no side effects). if r2, err := zddc.ResolveCanonicalPath(cfg.Root, srcAbs); err == nil { srcAbs = r2 } if r2, err := zddc.ResolveCanonicalPath(cfg.Root, dstAbs); err == nil { dstAbs = r2 } // Source must exist as a regular file. srcInfo, err := os.Stat(srcAbs) if err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "Not Found", http.StatusNotFound) } else { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } return } if srcInfo.IsDir() { http.Error(w, "Conflict — MOVE of directories is not supported", http.StatusConflict) return } // Destination must not exist (no implicit overwrite). If-Match on the // SOURCE is still respected for concurrency on the source bytes. if _, err := os.Stat(dstAbs); err == nil { http.Error(w, "Conflict — destination already exists", http.StatusConflict) return } else if !errors.Is(err, os.ErrNotExist) { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // ACL: source side requires `w` (rename mutates the source); dest // side requires `c` (creates a new path). Cross-folder moves run // both gates against potentially different chains. if !authorizeAction(cfg, w, r, srcAbs, srcURL, policy.ActionWrite) { return } if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) { return } if !checkIfMatch(w, r, srcAbs) { return } // Ensure destination's canonical ancestors are created (with auto-own // .zddc seeding) before the rename. This lets a MOVE from working/foo // → archive//issued/foo materialise the per-party folders on // the way in. if email := EmailFromContext(r); email != "" { if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, dstAbs, email, 0o755); err != nil { slog.Warn("ensure canonical ancestors (move dst)", "path", dstAbs, "err", err) } } if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil { auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if err := os.Rename(srcAbs, dstAbs); err != nil { // Cross-device or permission errors: report 500 — the client // will retry or surface the failure to the user. auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error — rename failed: "+err.Error(), http.StatusInternalServerError) return } etagCacheM.Delete(srcAbs) etagCacheM.Delete(dstAbs) // Compute new ETag from the moved bytes for the response — clients // that want to keep tracking should pin to this ETag. if etag, err := fileETagOnDisk(dstAbs); err == nil && etag != "" { w.Header().Set("ETag", `"`+etag+`"`) } w.Header().Set("X-ZDDC-Source", "fileapi:move") w.Header().Set("X-ZDDC-Destination", dstURL) w.WriteHeader(http.StatusOK) auditFile(r, "move", srcURL+" -> "+dstURL, http.StatusOK, 0, nil) } func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path) if !ok { http.Error(w, msg, status) return } // Resolve canonical-folder casing on the way in (no side effects). if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil { abs = r2 } if !authorizeAction(cfg, w, r, abs, cleanURL, policy.ActionCreate) { return } // Idempotent: if the dir already exists, treat it as success; // if a file is at the path, conflict. if info, err := os.Stat(abs); err == nil { if info.IsDir() { w.Header().Set("X-ZDDC-Source", "fileapi:mkdir") w.WriteHeader(http.StatusOK) auditFile(r, "mkdir", cleanURL, http.StatusOK, 0, nil) return } http.Error(w, "Conflict — a file exists at this path", http.StatusConflict) return } // Materialise any missing canonical ancestors (working/, staging/, // archive//incoming/) before creating the target itself. This // also seeds auto-own .zddc on each newly-created canonical ancestor. email := EmailFromContext(r) if email != "" { if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil { slog.Warn("ensure canonical ancestors", "path", abs, "err", err) } } if err := os.MkdirAll(abs, 0o755); err != nil { auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Auto-ownership for the newly-created directory itself. // // Two cases yield an auto-own .zddc inside abs: // - The new directory is itself a canonical auto-own position // (e.g. an explicit MKCOL of /Project/working). In this case // IsAutoOwnPath(abs, cfg.Root) is true. // - The new directory's parent is canonical auto-own — every child // mkdir under working/, staging/, or archive//incoming/ // gets the creator's grant. if email != "" { if zddc.IsAutoOwnPath(abs, cfg.Root) || zddc.IsAutoOwnPath(filepath.Dir(abs), cfg.Root) { if err := zddc.WriteAutoOwnZddc(abs, email); err != nil { slog.Warn("auto-own .zddc write failed", "path", abs, "err", err) } } } w.Header().Set("X-ZDDC-Source", "fileapi:mkdir") w.WriteHeader(http.StatusCreated) auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil) } // 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...) }