File-browser: make Owner column, per-file lock, and folder item-counts real (deferred from #311) #315
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/Cairn#315
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Deferred from the file-browser redesign (#311, PR2
internal/api/listmeta.go). The mockup depicts three pieces of per-item data that do not exist in the current model, so PR2 shipped only the two signals that are truthfully sourceable (shared,hold) and left these out rather than fabricate them. This issue is the placeholder to deep-dive later.What's missing and why
1. Owner (the Owner column + avatar)
storage.FileInfo(internal/storage/storage.go) isName / Path / Size / ModTime / IsDir / ContentType / ETag— there is no uploader/creator field, and by design the storage backend is the single source of truth with no DB-backed file index. So "who owns this file" is simply not recorded anywhere today. In a personal home the answer is trivially the viewer (which is why the column is hidden there); in a shared space it's genuinely unknown.document.body.setAttribute("data-space","personal")default inweb/static/index.html. That default should come out as part of this work, once owner data exists, sodata-spacereflects the real context (personal/space/shares) again.2. Per-file lock (the
lockchip)There is no per-file lock concept in Cairn. The only lock is
internal/statestore/lock.go— an instance boot-guard that stops two instances sharing one backend. The mockup'slockchip has nothing to bind to. Would need a real per-path lock subsystem (WebDAVLOCK? advisory app-level locks?) with a decision on semantics and enforcement.3. Folder item-counts (the "12 items" name sub-line)
Requires counting each subdirectory's children. On the main file-listing path that's a
Listper folder = N+1 backend round-trips on every navigation — a real latency/load regression on an object store. Needs either a cheap count source, a bounded/opt-in fetch, or a cached index before it's safe to turn on.Not blocking
The redesign is complete and live without these;
sharedandholdchips already ship. This is a "when we want to invest" feature, not a bug. Owner is the highest-value of the three and the one that would also let us retire thedata-space="personal"default.Refs: #311 (PR2/PR3),
internal/api/listmeta.go,internal/storage/storage.go,internal/statestore/lock.go.Status check after the v0.6.8x–v0.6.101 UI epic:
3. Folder item-counts — DONE (shipped in the file-browser fidelity round,
internal/api/listcounts.go).fillItemCountssets anitemschild count on directory entries, one extraListper subfolder, capped at 100 counted folders per listing so a huge directory cannot turn one browse into a listing storm; entries past the cap keep no count and render as the plain kind. Covered bylistcounts_test.go.1. Owner column — NOT done.
storage.FileInfostill carries no uploader/creator (listmeta.gostill deliberately omits it), and thedata-space="personal"default that hides the Owner column is still in place. The open design question stands: where creator metadata lives given no-DB + S3/Garage, and what happens for files created out-of-band (WebDAV, direct backend).2. Per-file lock — NOT done. Still no per-path lock subsystem; the only lock remains the instance boot-guard in
internal/statestore/lock.go.Remaining scope of this issue = owner metadata (and retiring the
data-spacedefault with it) + the per-file lock decision.Owner column — design ratified with Nikola (2026-09-01)
Research first: classic Nextcloud/ownCloud keep a DB file index (
oc_filecache) whose production pathology (100 GB+ tables, per-file SELECTs, million-file loads choking) is the strongest argument for Cairn's no-DB stance. oCIS rebuilt on our philosophy and landed on per-node metadata beside the data — xattrs first, then messagepack sidecars, because xattrs have hard size limits and break on shared filesystems (lesson: one mechanism for both drivers, never xattrs). S3 constraint:ListObjectsV2never returns user metadata, sox-amz-meta-*or per-file sidecars are a HEAD/GET per file — the N+1 that must not exist on the listing path. Conclusion: read cost must scale with folders browsed, never files stored.Decisions
D1 — Semantics. Owner = the original uploader/creator of a file or folder, recorded at creation, display-only in v1. Within a space, the space-owner additionally holds ownership over all contents (already expressed by the space model; nothing extra stored per file). Peering deliveries: owner = the recipient; if the recipient is a space, the space-owner. Transferable ownership and owner-as-permission-anchor are explicitly future work and must not be blocked by the data shape.
D2 — Storage: per-folder manifest. One hidden object per directory,
.cairn-meta.json, mapping child name →{owner, at}. One extra GET per listing regardless of file count. Lives inside the directory it describes, so folder rename/move carries it for free. Written at the trash layer's level in the stack (below scope, above encrypt) → ciphertext at rest on S3, and invisible/unwritable from every user surface (listing, WebDAV, shares, peering) with reserved-name enforcement. A folder's own record lives in its parent's manifest. Read-modify-write serialised per folder in-process — safe under the single-replica + statestore-lock topology; no CAS needed on Garage.D3 — Unknown owners: honest em dash + free inference. No record → server-side inference fills what is structurally certain: paths under
/home/<user>→ that user; space contents → the space-owner. Only genuinely ambiguous files (pre-existing shared-space content, out-of-band backend writes) render "—". No backfill sweep, nothing invented.D4 — Lifecycle. Move preserves the record (entry migrates between manifests). Copy records the copier. Delete removes the entry; the trash sidecar gains an
ownerfield so restore (user or admin) re-records the original owner. Renames into a trash tree drop the source entry; renames out are handled by the restore path. A failed manifest write never fails the user's operation — owner is best-effort display metadata (unlike holds); log and continue.D5 — API.
GET /api/v1/filesitems gainowner,omitempty, filled in the existing enrichment pass (with shared/hold/items): one manifest GET per listing, inference applied server-side so the UI never re-implements the rules. Missing/unreadable manifest degrades to pure inference; listing never fails over a badge.D6 — UI. Owner column live in space and shares contexts, hidden in the personal home — and this is the moment the intentional
data-space="personal"default from #311 is retired sodata-spacereflects real context. Cell = avatar initial + username per the design system (13.5px/500 name treatment); Details panel gains an Owner line; i18n in all four locales.D7 — Testing. Unit-level: manifest round trip, full lifecycle (move/copy/delete/restore), inference rules, concurrent-upload serialisation, invisibility/unwritability of
.cairn-meta.json(same shape as the.trashtests), and a guard asserting exactly one manifest read per listing regardless of item count.Future-proofing.
{owner, at}is a struct, not a bare string: transfer (#D1 future) becomes "write a new value + audit line"; permission-anchoring reads the same record. No migration needed later.Per-file lock remains the open remainder of this issue and gets its own design pass after owner ships.
Per-file lock — design ratified with Nikola (2026-09-02)
Owner and item-counts shipped earlier (v0.6.102–v0.6.105); this settles the last piece. Context that reframed the issue: Cairn already fakes WebDAV locks —
internal/davadvertises class 2 and "grants" every LOCK with a token, enforcing nothing (documented MVP choice; Finder/MS Office refuse class-1 servers). So Office clients believe they hold locks today and last-save-wins on concurrent edits. Research anchors: Nextcloud's split between invisible transactional locking and the user-facingfiles_lockapp (three lock types: user/app/token; manual locks indefinite by default; a CVE where unlock permission checks were wrong), RFC 4918 enforcement essentials (423 + precondition bodies, If header, Lock-Token, Timeout/refresh), and the finding that desktop Office co-authoring is SharePoint/OneDrive-only — over WebDAV, Word's exclusive-lock dance IS the feature, and it only works against real enforcement. Co-editing (WOPI + editor server) is acknowledged as a separate future epic (Nikola will raise it himself; deliberately no issue filed).Decisions
L1 — Scope: manual + real DAV locks in one system. Two lock types from day one:
user(manual, row menu, indefinite) anddav(created by real LOCK handling: token, RFC timeout, refresh, auto-expiry). The documented fake is retired, not built beside. Theapptype is reserved for the future co-editing epic.L2 — Semantics. A lock protects one FILE's content from other people's writes: Write/Delete/Rename(src or dst)/Copy-onto refused with
storage.ErrLockedfor everyone but the holder; Open/Stat/List never blocked. Distinct from legal holds (admin compliance instrument protecting existence, subtree-scoped, does not stop editing). Files only in v1 — folder locking mimics the hold authority model and stays out.L3 — Unlock rights: holder + admin + space-owner (for paths inside their space, resolved via
SpacesAPI.owner). Admin/space-owner break-lock is an explicit, audited unlock — there is NO silent admin write-through in the storage layer (one exact permission check in one place; the files_lock CVE lesson).L4 — Expiry.
userlocks indefinite by default with an optional instance cap setting (off by default).davlocks always expire per RFC Timeout (instance cap ~1h, floor 30s), reaped lazily; refresh via LOCK+If extends.L5 — Storage. Tenth statestore-managed file
locks.json: path →{owner, type, token?, at, expires?}; resolved backend paths; full in-memory index (enforcement is a map lookup, zero extra I/O, scales independent of file count); synchronous write-through — a lock that fails to persist fails the request (opposite of ownermeta's best-effort, on purpose: a lock is a promise).L6 — Enforcement. New
lockguardstorage decorator directly above ownermeta: sees backend paths + ctx user; trash's delete-rename passes through it, so deleting a locked file is refused for free. Holder renaming their file carries the lock; delete removes it.writeErrmaps ErrLocked → 423.L7 — WebDAV. LOCK → real
davlock (opaquelocktoken:UUID, Lock-Token header, lockdiscovery body, Timeout honoured/capped); refresh supported; conflicting lock → 423 +no-conflicting-lock; UNLOCK validates Lock-Token (bad token → 403); writes hit the shared guard, ErrLocked → 423 +lock-token-submitted; PROPFIND reportsactivelock. Holder identity exempts ordinary writes (no If-token demanded on PUT — identity is the stronger check we actually have). DAV-created locks can be lifted from the web UI.L8 — UI. Row menu Lock/Unlock (permission-aware), the existing
data-t="lock"chip binds with "Locked by X" title, refused writes surface the 423 message as a toast, listing enrichment gainslocked/lockOwnerbeside shared/hold (in-memory lookups in the existing pass). i18n ×4, en-GB.L9 — Audit. lock, unlock, and break-lock all written to the audit log.
L10 — Future, designed-for but not built. (a)
applocks for the co-editing epic. (b) Nikola's space-owner extension: an instance setting ("Space owners may lock folders in their spaces", off by default) with an admin-dashboard row and a proper space-owner UX for locking folders/subtrees — folder-lock semantics (ancestor checks, rename rules, DAV depth-infinity) designed when that ships.Testing: store grant/deny/persistence/expiry; guard verb × identity matrix incl. trash path and lock-follows-rename; DAV grant/refresh/conflict/UNLOCK-validation/PUT-423; API permission matrix incl. space-owner; mutation check on the guard predicate.
All three parts are now shipped:
internal/storage/filelock(statestore-backedlocks.json+ guard decorator between trash and ownermeta),storage.ErrLocked→ 423,POST/DELETE /api/v1/lockwith holder/admin/space-owner unlock andlock/unlock/break-lockaudit events,locked/lockOwnerlisting enrichment, real WebDAV LOCK/UNLOCK (tokens, Timeout 30s–1h, refresh,no-conflicting-lock/lock-token-submittedpreconditions, activelock in PROPFIND, no downgrade of a manual lock by a DAV LOCK), and the UI (row-menu Lock/Unlock on files, "Locked" chip with holder tooltip, toasts, i18n ×4).Deployed to both dogfoods.
Deferred, on record: L4's admin-configurable expiry cap for user locks (needs a settings field + admin row — currently user locks are simply indefinite); the space-owner folder-locking instance setting (L2 note); permission-aware hiding of the Unlock menu entry (server enforces the permission matrix; the client always shows Unlock on a locked row and surfaces the 403). App-type locks remain reserved for the co-editing epic (L10).
Closing.