Skip to content

feat(files): files as a channel-scoped root entity - #302

Merged
raaymax merged 18 commits into
devfrom
feat/files-root-entity
Jun 18, 2026
Merged

feat(files): files as a channel-scoped root entity#302
raaymax merged 18 commits into
devfrom
feat/files-root-entity

Conversation

@raaymax

@raaymax raaymax commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Summary

Turns files from message attachments (metadata embedded inside the message body) into a first-class, channel-scoped root entity that messages reference via fileIds. This finishes the long-standing // TODO make this a separate entity and lays the groundwork for a per-channel Files view. Fully backward compatible — old messages keep rendering via legacy embedded attachments and DM history is untouched.

Design doc: docs/files-design.md.

Changes

Backend — new file/ domain

  • register (draft on upload), attach (link on message send), remove (owner-only, ref-counted storage cleanup), getChannel query
  • FileEntry type + FileRepo (batch-by-ids, attached-channel listing)
  • channel-scoped routes under /api/channels/:id/files (upload / list / delete)
  • message:create accepts fileIds; reads resolve fileIds → files (batched, no N+1); message:remove cascades file removal

Storage

  • computes image resolution at upload, exposes stat()
  • remove() sweeps {id}-* thumbnail variants by prefix (fs / gcs / memory) — fixes a pre-existing thumbnail leak

Migration

  • additive backfill of File rows from embedded attachments in plaintext channels (DMs are forward-only; the server can't read encrypted attachment metadata)
  • legacy embedded attachments are never stripped

Frontend — data path

  • uploads go to the channel endpoint, storing the file entity id + storageId
  • messages reference fileIds, kept plaintext through encryption so encrypted DMs still attach files server-side
  • permanent dual-read rendering: legacy attachments + resolved files flow through one path; optimistic ghost renders files immediately

E2E posture

Public/private channel file metadata is server-readable; for DMs the channel-scoped upload means filename/content-type now reach the server in plaintext (accepted for now — encryption is a documented later step). DM history remains fully readable via the permanent legacy path.

Testing

  • New files.test.ts: register → attach via message → channel list (REST) → message-remove cascade + storage sweep; owner-only direct delete
  • No regressions: channel / messages / readReceipt suites green (26 tests)
  • Server + @quack/api type-check clean; frontend production build passes

Not included (follow-up)

  • The visible Files-view UI tab (channel-header tab + browser organism + SSE live updates) — net-new UI, not required for entries/backfill
  • In-browser manual verification of the rewired upload/render flow is still pending

raaymax added 4 commits June 17, 2026 14:55
Introduce files as a first-class, channel-scoped root entity that messages
reference via fileIds, instead of metadata embedded in the message body.

- new file/ core domain: register (draft on upload), attach (link on message
  send), remove (owner-only, ref-counted storage cleanup), getChannel query
- FileEntry type + FileRepo (batch-by-ids, attached-channel listing)
- channel-scoped upload/list/delete routes under /api/channels/:id/files
- message:create accepts fileIds; reads resolve fileIds -> files (batched);
  message:remove cascades file removal
- storage: compute image resolution at upload, expose stat(), sweep
  {id}-* thumbnail variants on remove (fs/gcs/memory)
- additive backfill migration for plaintext channels (DMs forward-only);
  legacy embedded attachments are never stripped
- upload via POST /api/channels/:id/files; store file entity id + storageId
- messages reference files via fileIds (kept plaintext through encryption so
  encrypted DMs attach files server-side); files no longer embedded on send
- render legacy embedded attachments and new resolved files through one path
  (permanent dual-read); optimistic ghost shows files immediately
- FilesAPI.list/remove for the per-channel files view
- deno fmt normalization across migration files and BACKLOG.md
- remove stale .planning/pr-full/FINAL-REPORT.md
Comment thread app/src/js/core/models/files.ts Outdated
toJSON(): Array<{ id?: string; clientId: string; fileName: string; fileSize: number; contentType: string }> {
return this.list.map((f) => ({
id: f.id,
id: f.storageId,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do something about this? can we refactor the old code to make this obsolete?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in b9c6a7c by removing the root cause: /avatar and /emoji were the only consumers of that storage-id-as-attachment shape, so they're gone and FilesModel.toJSON() with them. Commands are now text-only.

Avatars/emojis are profile/workspace assets, not channel attachments — they'll be reintroduced as first-class uploads (dedicated endpoints + UI, no channel File entity) in a follow-up. Tracked in v4 scope §6. Existing avatars/emojis keep rendering; only setting new ones pauses until the follow-up.

raaymax added 14 commits June 18, 2026 16:22
These slash-commands attached a channel-scoped File entity just to carry
a storage id (the reason the frontend leaked storageId via
FilesModel.toJSON). Avatars and emojis are profile/workspace assets, not
channel message attachments, so they will be reintroduced as first-class
uploads with dedicated endpoints + UI (tracked in v4 scope).

- delete EmojiCommand/AvatarCommand + registry entries
- commands are now text-only (drop attachments/fileIds from the command
  body, route schema, Command type, and execute resolution)
- remove FilesModel.toJSON; the command path no longer sends files
- update command/emoji/profile/users tests; seed the emoji-listing test
  via repo instead of the removed /emoji command
New messages carry files only via fileIds (File entities). The legacy
attachments array is no longer accepted or persisted on create/update.

The read path is unchanged: old plaintext rows keep their stored
attachments, old encrypted messages still carry attachments inside the
decrypted body, and the client renders both legacy attachments and new
files. Editing an old message preserves its stored attachments (updates
only $set the provided fields).
WithImages now uses resolved File entities (message.files) instead of the
legacy embedded attachments; add WithLegacyAttachments to document the
permanent dual-read path for old/encrypted messages.
storageId is a @quack/storage implementation detail; the frontend used
it to build file URLs, leaking it. New files are now addressed by their
File entity id:

- new GET /api/channels/:channelId/files/:fileId resolves the entity ->
  storageId and streams (with thumbnail + download support)
- toClientFile() strips storageId from every client-facing payload
  (message.files, channel files list, upload response); MessageFile drops
  the field and the frontend File model no longer carries it
- frontend builds URLs via getFileUrl(channelId, fileId)

Legacy /api/files/:storageId stays (deprecated) to serve old plaintext
attachments, decrypted DM attachments, and avatars/emojis until a
follow-up migrates encrypted attachments and removes it.
Match the message convention (userId = the user who created the row).
The files collection is new/unreleased so no data migration is needed;
the backfill migration is updated in place.
message:update now accepts fileIds: it persists them, runs file:attach
for newly-referenced files, and re-resolves files for the response and
SSE broadcast. This is the building block the future client-side
migration of encrypted DM attachments depends on (register File rows for
embedded attachments, then point the existing message at them).

Add-only for now: detaching files removed on edit (delete + storage
sweep) and the migration's cross-author attach (message:update is
owner-only) are deferred.
…dening

- assert storageId is absent from message.files and the channel files list
- assert /avatar and /emoji now 404 (removed) and aren't listed by /help
- download endpoint: thumbnail (?w&h), download=true disposition, and
  403 for a non-member of a private channel
…url helpers

Regression fix: Files.tsx was rebuilding image/download URLs from file.id
via the legacy /api/files/:storageId hooks and ignoring the url from
toAttachments. After files moved to entity-id addressing this produced
/api/files/{entityId} (404) for new attachments and always showed 0 B.

- toAttachments now carries size and the correct url (new channel-scoped
  endpoint for resolved files, legacy storageId endpoint for embedded
  attachments)
- Files.tsx consumes that url (thumbnail via ?h, download via
  ?download=true) and the real size instead of rebuilding from file.id
- remove the now-unused getThumbnailUrl/getDownloadUrl hooks and the
  client.api.getThumbnail/getDownloadUrl methods they wrapped
- mock getFileUrl in the Message story so the image renders
Fetch request-body streaming (duplex: half) requires HTTP/2; the Vite
dev server is HTTP/1.1, so the streaming upload failed ALPN negotiation
and froze at 0%. Select the upload strategy via a build-time flag:

- prod (HTTP/2 behind the proxy): ReadableStream streaming, no buffering
  — important for large files
- dev (BUFFERED_UPLOAD=true): buffer to a Blob and POST via XHR (works
  over HTTP/1.1, real upload progress), also the fallback for browsers
  without request-stream support

Also fixes two latent bugs in the buffered path: it sent multipart
FormData (the route expects a raw body + Content-Disposition) and
streamToBlob pushed chunk.value (undefined) instead of chunk.
- pnpm with minimumReleaseAge (1wk cooldown), blockExoticSubdeps, default
  build-script blocking (esbuild allowlisted)
- route install paths through Socket Firewall (sfw) in CI + Docker
- restore dev HTTPS via @vitejs/plugin-basic-ssl, fixing the crypto.subtle
  login crash from the prior cert-free dev server
- least-privilege permissions on CI test jobs
- storage: after merging dev's off-event-loop resize pool, route image
  resolution reads through the worker too (merge had left readResolution
  calling the now-removed PhotonImage import) and copy bytes into the Blob
  before transferring the buffer to the worker
- storage: clamp resize dimensions to 2048 to prevent unbounded-allocation DoS
- download: send X-Content-Type-Options: nosniff and default to attachment
  (inline only for safe raster types) to prevent stored XSS via svg/html;
  sanitize the Content-Disposition filename
- upload: parse content-disposition safely (no 500 on a missing header) and
  sanitize the stored filename
- attach: only attach draft files, preventing an already-attached file from
  being re-pointed to another message
- migration down: drop only the indexes it created instead of the whole files
  collection (which would destroy live data on rollback)
@raaymax
raaymax merged commit 09ad482 into dev Jun 18, 2026
1 check passed
@raaymax
raaymax deleted the feat/files-root-entity branch June 18, 2026 23:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant