logicspike/docs

Media Service

Media library: feature gaps and "this file already exists" (2026-10-12)

Status (2026-10-13): ALL BUILT AND LIVE — phases A–H of the approved plan, shipped phase by phase (§7 has versions, rollbacks and what changed from this design). Founder asked for (1) the Library's feature gaps, thought through as a product designer, and (2) whether uploading a file that's already in Media can warn "this file already exists" — in every service, with the other cases that come with it. Owner: founder decided §5 on 2026-10-12 (scope: everything incl. "later"; storage: meter only; duplicate rules as recommended; ship each phase); build session implemented. Facts as of 2026-10-12, read from apps/media-service, packages/core-media, apps/seller-dashboard/src/modules/media, components/media/* and every upload surface in the dashboard.


1. Short answer: can we warn "this file already exists"?

Yes. Same mechanism Google Drive, Dropbox and iCloud use: a fingerprint of the file's contents (SHA-256). Two files with the same fingerprint are the same file, byte for byte, whatever they're called.

  • The browser computes the fingerprint of the picked file before uploading anything (Web Crypto, about 50 ms for a 10 MB file; the file never leaves the device for the check).
  • It asks media "do I already have any of these?" and media answers only with files the person could already open — so the check can't be used to discover a colleague's Only-me file.
  • If there's a match, a small dialog shows the existing file (preview, name, who added it, when) and lets the person use it, upload anyway, or cancel.

What it can catch reliably: the exact same file again — same photo, same PDF, same video, under any name, from any service. What it can't catch with a fingerprint: the same picture saved differently (resized, re-compressed, a screenshot of it, a cropped version). That needs a "visual fingerprint" (perceptual hash) and gives "looks similar" hints with false positives. Possible later for images only (§4, later).

Why it isn't there today: the browser uploads straight to storage (presigned PUT), so media never sees the bytes, and no fingerprint column exists (media_files has no hash/checksum). Both are additive to fix.


2. How duplicate detection works

2.1 Fingerprints

Column (new, nullable) What Who computes it Trusted for
content_sha256 fingerprint of the stored bytes media, server-side: at /upload/complete it reads the object back (≤ 10 MB) and hashes it; ingest-url and /internal/upload already hold the bytes matching anyone's file
source_sha256 fingerprint of the original file the person picked, before the image editor re-encoded it the browser, sent with presign matching the uploader's own files only

Why two: the image editor re-encodes images (image-editor/lib/canvas.ts:283), so the stored bytes of a cropped/compressed photo never equal the original. Without source_sha256, re-picking the same photo in the Library would never match.

Why source_sha256 only matches your own files: media can't verify it (it never sees the original). If it matched everyone's files, a member could upload an altered image (a swapped payment QR code, an edited invoice scan) while claiming the fingerprint of the real one; a colleague uploading the real one would be offered the altered one. Trusting your own claim about your own files is harmless.

Partial unique index is not wanted (duplicates must stay allowed — "Upload anyway"). Index: (tenant_id, content_sha256) and (tenant_id, uploaded_by, source_sha256), both WHERE status IN ('uploaded','archived').

Existing files get content_sha256 from a one-off janitor job (read bytes → hash → store), batched; today that's ~26 rows.

2.2 The check

POST /files/match { fingerprints: [sha256, …] } (≤ 50) → for each fingerprint, the best match the caller can read:

  • same workspace; status uploaded (or archived, reported as such); listable; never delegated (form and mail files are service-managed) and never pending_upload;
  • passes canReadMedia (so a colleague's Only-me file is never reported);
  • matches content_sha256, or source_sha256 when uploaded_by = caller.

Returns { id, fileName, mimeType, fileSize, visibility, serviceCode, uploadedBy (you / a member), createdAt, status, previewUrl }.

The check is a hint, never a gate: if it fails, times out (2 s) or the server is old, the upload just proceeds.

2.3 When it runs, per surface

Surface Check on "Use this one" means Notes
Library upload (free-mode editor) the original, before the editor opens open the existing file's detail panel (and highlight it) warn before anyone spends time cropping
Overview Quick Upload (no editor) the file same
Blog cover / author avatar / newsletter images (preset crop via MediaDrawer) the final bytes, after the editor return the existing file's URL instead of uploading a crop to a preset is a new image; only an identical result is a duplicate
Blog body paste/drop, carousel the file return the existing URL
Social composer (use-media-upload.ts) the file attach the existing mediaId composer stores mediaIds, so reuse is clean
Blog markdown import (ingest-url) server-side, after fetch silently reuse an identical public blog file no dialog in a bulk import
Mailbox attachments, form uploads never — per-message / per-submission lifecycle; mailbox releases its copy after send
Workspace logo, profile avatar never — single slot, replaced often
Collections entries not in v1 — separate service path (/internal/upload)

Services that need a public link (blog, newsletter, social, brand logo) only consider public matches. A Workspace or Only-me match there is ignored and the upload proceeds — no dialog that ends in "you can't use it".

Choke points: browser uploads all pass through presignUpload (modules/media/api/media.api.ts:223) — via uploadFile and the composer's own hook. Put the check in one helper (findExisting(files)) called by uploadFile's callers' shared hooks (useMediaUploader, useMediaLibrary, use-media-upload, upload-image.ts), not inside uploadFile itself, because the dialog and "use this one" differ per surface.

2.4 The dialog (minimal UI)

One file:

Already in your library
[preview]  logo-final.png · 240 KB
           Added by you · 3 Oct · Workspace
                         [Upload anyway]  [Use this one]
  • In the Library, the primary button reads Show it.
  • Added by someone else: "Added by a member" (same wording as the detail panel's owner field).
  • Esc / ✕ cancels the upload.

Several files (one dialog, not one per file):

3 of 8 files are already in your library
[thumb] logo-final.png        Skip ▾
[thumb] team.jpg              Skip ▾
[thumb] brochure.pdf          Skip ▾      (Skip / Upload anyway)
                         [Upload 5 new files]

2.5 Other cases and what happens

Case Behaviour
Same file twice in one selection uploaded once, silently
Same file dropped again right after uploading it matched (it's in the library now) → dialog
Match is in the archive "It's in your archive" → Restore it (if you can manage it) / Upload anyway
Match is a colleague's Only me file not reported; upload proceeds as normal (no leak)
Match is from another service (a Blog image found from the Library) shown with "Added by Blog"; Show it opens it
Match has a different visibility than you picked (you chose Public, it's Workspace) dialog shows its visibility; Show it lets you switch it (G24 rules) — never changed automatically
Same name, different content no warning — names aren't unique (image.png, Screenshot…); a prompt here would be noise. Replacing a file is an explicit action (§4 Replace)
Two people upload the same file at the same moment both succeed; rare, harmless
Check fails / slow / offline upload proceeds; no dialog
File larger than the limit rejected before fingerprinting, with the limit stated (see B3)
Abandoned upload (pending_upload) ignored
File permanently deleted, then re-uploaded no match; normal upload

2.6 Not proposed: storage-level dedupe

Making two rows share one stored object would save storage silently, but every delete, visibility move and archive would have to reference-count shared bytes. R2 storage is cheap and the cap is 10 MB per file; the person-facing prompt is the value. Revisit only if storage cost becomes real (and quota G2 is enforced).


3. Gaps that are broken today (fix first)

ID What's wrong Where Fix
B1 Type filter only filters the page already loaded. "Images" on a page of 20 mixed files shows whatever images happen to be in those 20; "Load more" can add nothing. useMediaLibrary.ts:108-117; server has no type filter (files.ts:103-187) server-side `type=image
B2 Grid/List toggle does nothing — viewMode never reaches MediaGrid. MediaFiles.tsx:206,213 build the list view (name, type, size, visibility, added, by) or remove the toggle
B3 Size limit mismatch: dashboard accepts up to 50 MB, server caps at 10 MB. Images are compressed by the editor, but videos, PDFs and the Overview Quick Upload (no editor) fail after the upload starts with a server error. MediaUploadZone.tsx:27; upload.ts:51 reject over 10 MB up front: "Max 10 MB"
B4 Search treats % and _ as wildcards ("50%" matches everything). files.ts:149 escape them
B5 "Docs" means PDF only, in the filter and in stats. useMediaLibrary.ts:115; stats files.ts:251-311 define documents once (pdf today; more when more types are allowed)
B6 Permanent delete doesn't know where a file is used: deleting a blog post's image breaks the published post (404) with no warning. The API also allows permanent delete of a file that isn't archived (the UI only offers it in Archive). files.ts:469-487, :405 Where used (§4); API: permanent delete only from archive
B7 Upload progress is fake (fixed 10 → 30 → 80 → 100); a slow 10 MB upload sits at 30 %. media.api.ts:262-323 real progress via XHR (mailbox's AttachmentTray already does it)
B8 Editors' media pickers show only that service's own public files, first 20, no search, no paging; Library files never appear (= G26). MediaPicker.tsx:34 search + paging + "All public files"
B9 MediaDrawer says "post" even in Newsletter. MediaDrawer.tsx:29 neutral copy
B10 Dead code: FeaturedImagePicker.tsx, slot-builder-modal.tsx (never imported). delete
B11 Stale comment says fileSize is never reconciled; /complete now overwrites it with the real size. content.ts:96-98 fix comment

4. Missing features, prioritised

P1 — core library hygiene

Feature Design notes
Rename Detail panel: click the name to edit. Only the name before the extension is editable; the extension is shown, locked (the server derives the served type from it — renaming photo.png to photo.html must be impossible). PATCH /files/:id { fileName }, 1–200 chars, no / \ or control characters, same extension or 400. Who: same as archive (uploader, or media:files.admin); never delegated files. The link doesn't change (the stored key has no name in it); downloads use the new name. Fixes imported.png names from the blog importer.
Multi-file upload + queue multiple on the input; drop many; a small queue under the upload zone (per-file progress, cancel, retry, done ✓). Files that need the editor go through it one at a time; "Skip editing" for the rest. Mailbox's AttachmentTray is the pattern.
Duplicate check §2.
Sort Newest (default), Oldest, Name A–Z, Largest. Server-side; cursor pagination must follow the sort.
Where used Detail panel: "Used in: Blog post 'Launch notes' · Newsletter 'October'". Warns on archive, permanent delete and Public → Workspace. Needs each service to report its references to media (blog stores URLs inside post HTML; content-engine, collections, forms and mailbox store mediaIds). Biggest item; it also makes "Use this one" safe at scale, because shared files become visible.

P2

Feature Design notes
Expiring share link "Share a link…" on Workspace/Only-me files: a link that works for 1 day / 7 days, no sign-in. Covers most "make it public to send it to a client" moments without making the file public forever. Uses the existing presigned-link machinery (7-day maximum).
Replace file Detail panel: upload a new version under the same file. References by id (social, collections) follow automatically. Public links are cached as immutable for a year, so the public link changes on replace; with Where used we can say which posts still point at the old one.
Alt text / description One field in the detail panel; prefilled into blog/newsletter/social when the file is picked (social composer already keeps alt text per slot).
Dimensions Store width × height at upload (browser already decodes images); show in card tooltip/detail; pickers can say "too small for a banner".
Paste to upload ⌘V an image anywhere on the Library page.
Better preview PDF preview (first page / browser viewer), zoom on images.
Storage meter + quota "1.2 GB of 5 GB" on Overview; enforce the plan's storage_mb (G2).

Later

Feature Design notes
Folders or tags With search, sort and filters, a library of hundreds works without them. When needed, prefer tags (a file in two places, fits "minimal UI") over nested folders.
Favorites Probably not needed once search + sort exist.
Bulk visibility, bulk download (zip) G25.
"Looks similar" Visual fingerprint for images; hint only.
Activity record ("Priya made this public") G27, with the gateway audit log.
Keyboard shortcuts / search, u upload, arrows to move, Enter to open.

5. Decisions for the founder

  1. Same name, different file → no warning (recommended), or warn "a file with this name exists"?
  2. source_sha256 (pre-editor fingerprint) matches only your own uploads (recommended, safe), or everyone's (catches more, but a member could plant an altered image under a real one's fingerprint)?
  3. In editors' pickers, ignore non-public matches (recommended) — or offer "Make it public and use it" (changes a Library file for everyone)?
  4. Order: B1–B11 fixes → Rename + multi-upload + sort → duplicate check (Library first, then every uploader) → Where used → P2. Recommended; Where used could move earlier if "Use this one" in editors ships first.

6. Build sketch for the duplicate check (when approved)

  1. Core migration: content_sha256, source_sha256 + two partial indexes (additive; manual-migrations/ + packages/core-database/scripts/ like the storage migration). Run before the media deploy.
  2. Media: hash at /complete (read back), ingest-url, /internal/upload; presign accepts sourceSha256 (64 hex chars or ignored); POST /files/match; janitor job backfills content_sha256.
  3. Dashboard: lib/fingerprint.ts (hook-free, Web Crypto), findExisting(), DuplicateDialog (single + batch), wired into Library, Overview, useMediaUploader, upload-image.ts, use-media-upload.ts.
  4. Tests: match only readable files (Only-me not reported, other workspace not reported, delegated never), source_sha256 only for own files, archived reported as archived, check failure never blocks upload, batch with internal duplicates.

7. Rollout record (2026-10-12/13)

Plan: ~/.claude/plans/can-we-start-planning-misty-kernighan.md. Branch media/library-v2, fast-forwarded to main phase by phase. Founder decisions: everything including "later"; storage meter only (no enforcement); duplicate rules as recommended; ship each phase when verified.

Phase What Commits Deployed (rollback)
A B1–B11 fixes, sort + keyset paging, list view, real upload progress, named downloads dc8ec625, 9cc4e76d, 7a21898c (review fixes) media fc648dda (5b619234)
B One migration for every phase; rename, alt text, dimensions 93d783e9, a0d550a3 2026-10-13-media-library-v2.sql applied 2026-10-12; media fc648dda
C Upload queue (many files), paste, / and u, PDF + zoom previews b84141f2 dashboard only
D "Already in your library" everywhere; fingerprints; picker upgrade f182b0e9, 6bc8a3f9 media 5b8c7e14 (fc648dda)
E Expiring share link; replace with version kept f0fc94a0, 8f65a89a media 0343d3e4 (5b8c7e14)
F Where used (gateway fan-out + a read-only route per service) 6f242634, 037d8a61 media 7132e8e5 (0343d3e4), blog e643c290 (b255f862), newsletter 95f1ab31 (14a39a56), content-engine ead45cc8 (01f4e334), collections 1ac1e435 (7a8cdb49), gateway aa6692e2 (4b1338a4)
G Storage meter (manager current from media_files) a6ac8b97 manager ff2fa244 (b187f760)
H Bulk visibility, zip download, tags, stars, "looks similar" ce709c9a, 24273db6 media 648b98f5 (7132e8e5)
Review Independent review of B–H, all findings fixed (below) 056ef513, b14b0d93 media f3674b40 (648b98f5), collections b023fc8c (1ac1e435), gateway de977c03 (aa6692e2)

Dashboard: Vercel logicspike-office redeploys on each push (all READY). Every media rollback above is schema-safe: the migration is additive.

In-app checks by the founder, 2026-10-14: passed — upload several files at once, "Already in your library", rename + download under the new name, 7-day share link in a private window, "Used in" on a blog cover, Replace with the previous version in the Archive, tags and stars filters, storage meter, bulk Visibility and zip Download. All checks passed.

Where the build differs from this design

  • Blog paste/drop/carousel and the social composer reuse an identical public file silently (own service only) instead of showing a dialog — a dialog mid-typing was noise, and only the same service's files are reused so their visibility can't later change under the post. The Library and the editors' picker show the dialog.
  • Replace keeps the previous version (keys swapped, old version archived as " (previous).ext") instead of deleting the old bytes: old public links keep working and restoring undoes it.
  • Communication was not deployed: main carries its undeployed "DEPLOY-AFTER-SOAK" fail-closed guard. The brand logo for "Used in" is read through communication's existing GET /v1/sender-settings.
  • The zip download uses a small store-only zip writer (lib/zip.ts) rather than a dependency.
  • Existing files get content_sha256 from the janitor's 6-hourly run (backfillFingerprints, 200 per run); /internal/janitor/run needs the internal token, which this session doesn't have.

Review of B–H (2026-10-13), all fixed

  • A visibility move racing a Replace could leave the file pointing at bytes in the wrong bucket and delete the previous version's bytes: the move now also guards on the key, so whichever lands second is refused.
  • "Used in" said "Not used anywhere" for a file made private after being public (its key and old embeds stay): usage-terms now returns the key for every non-delegated file; the gateway answers 404 to browsers on that route, so the key stays server-side.
  • An admin's Replace made the admin own the "(previous)" version: it now stays the file owner's.
  • Collections "Used in" counted deleted entries and collections.
  • A second duplicate prompt overwrote the first, leaving its batch stuck in "checking": prompts now queue. Closing a "looks similar" prompt now uploads (it's a different file); an exact duplicate is still skipped.
  • "Used in" was cached for the whole session, so a use added since could be missed: it's kept 30 s, and archive/delete forever/making private always ask fresh.
  • Leaving the page didn't stop queued uploads: nothing new starts after unmount.
  • A failed Replace left its uploaded version as a stray file (now removed); a refused unstar in the Starred view lost the file from the list (now put back); the storage meter misread unlimited plans (-1); zip names could collide with a file already named "a (2).png".

Gap log (found, not fixed in this round)

ID Gap Why not now Owner
L1 "Used in" is single-file; bulk archive/delete keep the generic warning N fan-outs per bulk action build
L2 Usage searches are strpos scans over a workspace's posts/campaigns/entries (no trigram index) — fine at today's sizes Add pg_trgm indexes if a workspace grows large build
L3 Collection entries have no page of their own; "Used in" links to the collection Collections UI build
L4 Share links can't be revoked before they expire (presigned R2 links; = access-audit G10) Inherent; rotate the R2 key to kill all founder (accepted)
L5 A presigned upload link stays valid for its hour after /complete; re-PUTting other bytes of the same size would leave content_sha256 stale. Only misleads duplicate hints (the dialog always shows the existing file's preview) Fix: sign x-amz-checksum-sha256 at presign (needs bucket CORS for that header) build
L6 Files uploaded before 2026-10-13 have no dHash ("looks similar" can't match them); the worker can't decode images to backfill Only new uploads build (accepted)
L7 Storage is one workspace total; blog.storage_mb isn't split out Product decision founder
L8 No storage enforcement (= access-audit G2) Founder's choice 2026-10-12 founder
L9 No in-app activity record ("who made this public / renamed / replaced") — only log lines (= access-audit G27) Waits on the gateway audit log build
L10 Dashboard tests notification-bell useAttention and FormShell read-only save are timing-flaky under the full suite (pass alone) Not media build
L11 ingest-url reuse doesn't exclude service = "media" (hardening only; only the blog importer calls it, always with blog) No caller passes media build
L12 The picker now lists the workspace's public Library images; their uploader can later make one non-public and break the post that picked it "Used in" names the post before the change is confirmed founder: keep (decided 2026-10-14)
Media Service