The server's `playable` flag is computed Chromium-first, but HEVC web-playability is browser-dependent: WebKit (Safari on macOS/iOS) decodes HEVC via the OS while Chromium/Firefox/Electron can't. Treating the flag as global wrongly showed Safari users the download fallback for files they can play inline. The client now treats `playable === false` as "needs a capability check": it pre-renders the fallback only when the current browser also can't decode the format, gated on a one-time canPlayType probe (BROWSER_SUPPORTS_HEVC). Capable browsers attempt inline playback; the <video> onError handler remains the safety net for genuine failures.
37 KiB
File & Upload System
Source files:
packages/server/src/routes/uploads.ts-- File serving (cache, security, Range)packages/server/src/routes/files.ts-- tus protocol endpoints (/api/files/*), PRE_CREATE / PRE_PATCH / POST_FINISH hooks, janitor helperspackages/server/src/utils/thumbnail.ts-- Image thumbnail generation (sharp), video thumbnail extraction (ffmpeg), image dimension probing, profile image resizing, media metadata extraction (incl. video codec)packages/server/src/utils/mediaPlayable.ts--classifyVideoPlayable(mimetype, codec)web-playability classifier (drivesattachments.playable)packages/server/src/utils/fileCleanup.ts-- File deletion helpers (disk + thumbnail + attachment record cleanup)packages/server/src/utils/storageJanitor.ts-- Storage stats, orphan detection, cleanup routines (orphaned files, unlinked attachments, dangling references, old media), federation GC, soft-deleted DM channel purgepackages/web/src/stores/transferStore.ts-- Client transfer manager (uploads via tus-js-client, downloads via fetch + FS Access)packages/web/src/stores/composerStore.ts-- Per-channel staged transfer IDs, draft text, replyTopackages/web/src/stores/pendingMessageStore.ts-- Optimistic attachment-bearing message bubbles awaiting transfer completionpackages/web/src/stores/pendingMessageRehydrate.ts-- Orchestrator that fires the deferredPOST /messagesonce all transfers in a bubble completepackages/web/src/utils/imageActions.ts-- Client-side image save-to-disk and copy-to-clipboard actionspackages/web/src/utils/cropImage.ts-- Client-side image cropping pipeline (canvas-based, WebP output)packages/web/src/components/chat/AttachmentRenderer.tsx-- Attachment display component (images, video, audio, generic files, federation badges)packages/web/src/components/chat/AttachmentProgress.tsx-- Radial-progress overlay for in-flight transfers in optimistic bubblespackages/web/src/components/layout/TransferIndicator.tsx-- Channel-header transfer indicator + global tray panelpackages/web/src/components/chat/ImagePreview.tsx-- Full-screen image preview modal with save/copy toolbar
DB tables: attachments, instance_settings (maxUploadSizeBytes). See docs/systems/database.md for full schemas.
Out of scope: Federation file replication/download queue (see docs/systems/federation.md), admin storage stats UI, admin user management.
Capability Matrix
| Browser | Picker (showOpenFilePicker) |
Drag-drop FS handle | Save destination handle | Reload survival |
|---|---|---|---|---|
| Chrome / Edge | yes | yes | yes | Auto-resume |
| Firefox / Safari | — | — | — | Re-pick required |
Paste-from-clipboard yields a File, not a handle, on every browser -- never reload-resumable.
1. Upload Pipeline (tus)
The server speaks the tus resumable upload protocol on /api/files/* (see routes/files.ts). Uploads are chunked, resumable across tab reload and network drop, and authenticated on every request.
Endpoint Group: /api/files/*
| Method | Path | Purpose | Auth | Hooks |
|---|---|---|---|---|
POST |
/api/files/ |
Create upload session. Returns Location (per-upload URL) and Upload-Expires (24 h). |
JWT | PRE_CREATE: rate limit 30/min/user, size validation, snowflake assignment |
HEAD |
/api/files/:uploadId |
Resume probe. Returns Upload-Offset. |
JWT + ownership | -- |
PATCH |
/api/files/:uploadId |
Append bytes at offset. | JWT + ownership | PRE_PATCH: slowloris rate ~1000/min/IP |
DELETE |
/api/files/:uploadId |
Abort and discard partial bytes. | JWT + ownership | -- |
OPTIONS |
/api/files/ |
tus capability advertisement (extensions, max size). | none | -- |
Storage Layout
| Path | Owner | Contents |
|---|---|---|
${uploadDir}/.tus/ |
tus | In-progress uploads + per-upload <id>.json metadata sidecar. |
${uploadDir}/ |
server | Final files renamed on POST_FINISH to ${snowflakeId}${ext}. |
POST_FINISH Hook
When the final PATCH completes, the hook:
- Verifies
metadata.userId === req.user.id(defense in depth -- ownership was already enforced on PATCH). - Renames
${uploadDir}/.tus/<uploadId>to${uploadDir}/${snowflakeId}${ext}, whereext = path.extname(metadata.originalName).toLowerCase(). - Runs media processing (sharp for images, ffmpeg/ffprobe for video/audio) -- same code path as the legacy multipart endpoint used to.
- Inserts an
attachmentsrow withmessageId = NULL(linked to a message later when the user sends). - Returns the new
AttachmentJSON in the final-PATCH response body, so the client can stage the attachment ID without an extra round trip.
Janitor
| Trigger | Function / Path | Sweeps |
|---|---|---|
| User cancels mid-upload | Client tus.abort(true) → tus DELETE |
Immediate cleanup of the .tus/ payload + sidecar. |
| User discards a paused/failed bubble | transferStore.abortUpload → manual fetch DELETE (when no live tus instance) |
Immediate cleanup of the .tus/ payload + sidecar. |
| Janitor tick (every ~30 s) | cleanupTusUploads() |
Invokes @tus/file-store.deleteExpired() (24 h Upload-Expires default, configurable via tusExpirationMs). |
| Janitor tick (every ~30 s) | cleanupTusStragglers() |
Defensive unlink of any .tus/ entry whose mtime is older than tusStragglerSweepMs (48 h default) — catches orphans the tus library missed (payload without sidecar, sidecar without payload). |
| Admin-triggered | POST /api/admin/storage/cleanup-tus → cleanupStaleTusSessions(thresholdMs, dryRun) |
Manual sweep with configurable maxAgeHours (default 1 h). Supports preview (dryRun=true) before live deletion. |
| Janitor tick (post-finalize) | getUnlinkedAttachments() |
1 h grace for finalized attachment rows that were never linked to a message. |
Stats: getStorageStats() exposes staleTusSessions + staleTusSize for the admin Storage Overview, computed via getStaleTusInfo(60 * 60 * 1000) — entries with mtime older than 1 h. The display threshold is fixed (matches the cleanup default); the admin route's maxAgeHours is what's actually configurable.
Security
- JWT verified on every tus request (PRE_CREATE, PRE_PATCH, finalize, HEAD, DELETE).
- Federated uploads use a per-origin JWT. When the target space is hosted on a remote instance, the client must send that instance's scoped token (resolved via
getTokenForOrigin(origin)incrossStoreResolvers.ts), not the home-instance token — otherwise the remote rejects the request as it can't verify the home signature or resolve the userId. - CORS for federated tus uploads. The server's
@fastify/corsregistration inindex.tspermits the tus protocol's request headers (Tus-Resumable,Upload-Length,Upload-Offset,Upload-Metadata,Upload-Defer-Length,Upload-Concat,Upload-Checksum,X-HTTP-Method-Override) and exposes the response headers tus-js-client needs to read across origins (Location,Tus-Resumable,Tus-Version,Tus-Extension,Tus-Max-Size,Tus-Checksum-Algorithm,Upload-Offset,Upload-Length,Upload-Metadata,Upload-Expires). Without these, browser preflight blocks cross-origin POST/HEAD/PATCH/DELETE on/api/files/*. - PRE_PATCH ownership check:
metadata.userId === req.user.id. Required to prevent in-flight upload hijack between session creation and finalize. - Size validated against
instance_settings.maxUploadSizeBytesat PRE_CREATE; tus's ownmaxSizeis set as defense-in-depth. - Original filename round-trips through tus metadata (base64-encoded per spec); the on-disk filename uses snowflake + sanitized extension only.
2. MIME Type Handling
Extension-to-MIME Map (EXT_MIMETYPES)
Used as fallback when serving files without a DB record (thumbnails, orphans).
| Category | Extensions | MIME types |
|---|---|---|
| Images | .webp, .jpg, .jpeg, .png, .gif, .svg, .avif, .tiff, .bmp, .ico |
image/webp, image/jpeg, image/png, image/gif, image/svg+xml, image/avif, image/tiff, image/bmp, image/x-icon |
| Video | .mp4, .webm, .mov |
video/mp4, video/webm, video/quicktime |
| Audio | .mp3, .ogg, .wav, .flac, .aac, .opus |
audio/mpeg, audio/ogg, audio/wav, audio/flac, audio/aac, audio/opus |
| Documents | .pdf |
application/pdf |
Fallback MIME for unknown extensions: application/octet-stream.
Resizable Image Types (RESIZABLE_MIMETYPES)
Only these MIME types receive thumbnail generation:
image/jpeg, image/png, image/webp, image/gif, image/avif, image/tiff
Not resizable: image/svg+xml, image/bmp, image/x-icon -- these are served as-is.
3. Media Processing
Processing occurs inline during upload, before the response is sent. All processing is non-fatal -- failures are logged but the upload still succeeds.
Image Processing
Condition: isResizableImage(mimetype) returns true
-
Thumbnail generation (
thumbnail.ts:generateThumbnail)- Skip if width <= 800px (
THUMBNAIL_MAX_WIDTH) - Skip if animated (GIF with
metadata.pages > 1-- Sharp would flatten to single frame) - Resize to max 800px width,
withoutEnlargement: true - Output: WebP at quality 80 (
THUMBNAIL_QUALITY) - Filename:
${snowflakeId}_thumb.webp(viathumbFilename()) - Returns
nullif skipped or on error
- Skip if width <= 800px (
-
Dimension probing (
thumbnail.ts:probeImageDimensions)- Uses
sharp(filepath).metadata()to extractwidthandheight - Works for all formats including animated GIFs
- Uses
Video Processing
Condition: mimetype.startsWith('video/')
-
Thumbnail extraction (
thumbnail.ts:generateVideoThumbnail)- Requires ffmpeg on system PATH (availability cached on first check)
- Extracts a single frame using ffmpeg, trying seek times
['1', '0'](falls back to 0s for short clips) - ffmpeg command:
-ss {time} -i {filepath} -frames:v 1 -f image2pipe -vcodec png - - Frame is piped to stdout as PNG buffer (max 50 MB)
- The PNG frame is then processed through sharp:
- Dimensions read from frame metadata (rotation-corrected by ffmpeg)
- Resized to max 800px width, converted to WebP quality 80
- Returns
{ thumbnailFilename, width, height }(dimensions are from the original frame, not the thumbnail)
-
Metadata probing (
thumbnail.ts:probeMediaMeta)- ffprobe for dimensions + codec:
-select_streams v:0 -show_entries stream=width,height,codec_name -of json - ffprobe for duration:
-show_entries format=duration -of json - Duration rounded to 2 decimal places
- If thumbnail extraction failed, dimensions fall back to ffprobe values
- Returns
codec(the primary video stream'scodec_name) when available
- ffprobe for dimensions + codec:
-
Web-playability classification (
mediaPlayable.ts:classifyVideoPlayable)- The finish hook calls
classifyVideoPlayable(mimetype, codec)and stores the result inattachments.playable(tri-state, see below). - The browser
<video>element can't decode every uploaded format. The dominant failure case is a macOS screen recording — avideo/quicktime(.mov) container holding an HEVC (H.265) stream — which Chromium, Firefox and stock Electron can't decode. The file uploads fine and a server-side ffmpeg poster is generated, but inline playback silently fails (stuck at 0:00 with no error). attachments.playableis a deliberate tri-state:0/false— codec is confidently undecodable in mainstream browsers (HEVC, ProRes, WMV, MPEG-1/2, etc.). The client renders a download fallback card directly, no flash of a dead player.1/true— web-standard codec (H.264/AVC, VP8/VP9, AV1, Theora) in a web container (video/mp4,video/webm,video/ogg). Plays inline.NULL— unknown / optimistic. Codec couldn't be probed (ffmpeg absent or probe failed), or it's a web-safe codec in a container with inconsistent cross-browser support (H.264 in .mov). The client attempts inline playback and degrades via the<video>onErrorhandler.
falseis never widened beyond codecs known to fail everywhere, so an instance without ffmpeg keeps prior behaviour (attempt playback) rather than regressing every video to "unplayable".- The verdict is computed Chromium-first and is not authoritative per-client. Web-playability of HEVC is browser-dependent — WebKit (Safari/iOS) decodes it via the OS while Chromium/Firefox/Electron don't. The client therefore treats
playable === falseas "needs a capability check": it pre-renders the fallback only when the current browser also can't decode the format (BROWSER_SUPPORTS_HEVCinAttachmentRenderer.tsx). See §9 Attachment Rendering.
- The finish hook calls
Audio Processing
Condition: mimetype.startsWith('audio/')
- Duration probing (
thumbnail.ts:probeMediaMeta)- ffprobe for duration only (same command as video duration)
- No thumbnail or dimension extraction
ffmpeg Availability
- Checked once via
execFile('ffprobe', ['-version'])with 5s timeout - Result cached in module-level
ffmpegAvailablevariable - If unavailable: video thumbnails and all media metadata extraction are silently disabled
- All ffmpeg/ffprobe calls use 10-second timeout (
FFMPEG_TIMEOUT)
4. Profile Image Resizing
Profile images (avatars, banners, space icons) use the general upload pipeline but are additionally resized server-side after the user/space update.
thumbnail.ts:resizeProfileImage(filepath, type)
| Type | Max dimension (px) |
|---|---|
avatar |
256 |
icon |
256 |
banner |
1280 |
Behavior:
- Uses sharp with
{ animated: true }to preserve GIF animation - No-op if image width is already <= max dimension
- Writes to temp file (
filepath + '.tmp'), then atomically renames (fs.renameSync) to avoid corruption - Non-fatal: on error, original file is preserved, temp file cleaned up
Profile Upload Lifecycle
When a user sets an avatar/banner or a space sets an icon/banner:
- Client uploads file via
POST /api/uploads(creates attachment record) - Client sends
PATCH /users/@meorPATCH /spaces/:idwith the filename - Server strips
/api/uploads/prefix if present - Old file deleted from disk (
deleteUploadFile) - Old attachment record cleaned up (
deleteAttachmentByFilename) - New attachment record cleaned up (reference now lives in
users/spacestable) - File resized in-place via
resizeProfileImage
The attachment record for profile images is intentionally deleted -- the authoritative reference moves to the users.avatar/users.banner or spaces.icon/spaces.banner column.
Mobile Transfer-Chrome Surfaces
In-flight transfers (chat attachments, profile/banner uploads) are surfaced via the shared TransferIndicator component. Mount points:
- Chat header (desktop): every
MainContent.tsxheader variant — DM, group DM, space text channel, voice. - Chat header (mobile):
MobileChatScreen.tsxmounts<TransferIndicator />in its custom header. - Settings/Instance screens (mobile): mounted via
MobileScreenHeader.tsx'srightActionsslot on every settings screen —MobileSettingsScreen(hub + each direct panel),MobileInstancePanel, and the sixsettings-instance-*sub-panel wrappers inMobileShell.tsx. This guarantees that a profile-picture or banner upload triggered from settings has visible progress chrome and an abort affordance regardless of the user's current screen.
The component is lightweight when idle — the underlying transferStore Map subscription is a single useMemo over Array.from(...).filter(t => t.tray), and the rendered button is a small icon (no badge) until at least one transfer is active. Safe to mount on every settings screen without performance impact.
The dropdown panel uses touchstart + mousedown listeners for click-outside dismissal so a single tap on iOS Safari closes the tray. Panel width is min(300px, calc(100vw - 16px)) to prevent right-edge clipping on narrow viewports while keeping the desktop panel size unchanged.
5. Thumbnail Generation Details
Filename Convention
thumbnail.ts:thumbFilename(original)
input: "1234567890123456.png"
output: "1234567890123456_thumb.webp"
Strips the original extension, appends _thumb.webp.
Parameters
| Parameter | Value |
|---|---|
| Max width | 800px (THUMBNAIL_MAX_WIDTH) |
| Format | WebP |
| Quality | 80 (THUMBNAIL_QUALITY) |
| Enlargement | Disabled (withoutEnlargement: true) |
When Thumbnails Are NOT Generated
- Image width <= 800px (already small enough)
- Animated images (GIF with multiple pages) -- Sharp would strip animation
- SVG, BMP, ICO (not in
RESIZABLE_MIMETYPES) - ffmpeg unavailable (video thumbnails only)
- Any processing error (non-fatal, logged)
6. File Serving
Endpoint: GET /api/uploads/:filename
| Property | Value |
|---|---|
| Auth | None (public) |
| Path safety | path.basename(filename) prevents directory traversal |
MIME Resolution Priority
attachments.mimetypefrom DB (lookup by filename)EXT_MIMETYPESmap (extension-based fallback for thumbnails/orphans)application/octet-stream(final fallback)
Response Headers
| Header | Value | Purpose |
|---|---|---|
Cache-Control |
public, max-age=31536000, immutable |
1-year cache, immutable (filenames are snowflake-based, never reused) |
Content-Type |
Resolved MIME type | |
X-Content-Type-Options |
nosniff |
Prevents MIME sniffing |
Content-Security-Policy |
default-src 'none'; style-src 'unsafe-inline'; img-src 'self' |
Prevents script execution in uploaded files |
X-Frame-Options |
DENY |
Prevents iframe embedding |
Accept-Ranges |
bytes |
Advertises Range support |
Content-Length |
File size in bytes |
Content-Disposition (Forced Download)
Files that trigger Content-Disposition: attachment:
- SVGs (
image/svg+xml) -- prevents XSS via inline SVG rendering - Non-media files (anything not
image/*,video/*, oraudio/*)
Filename is URI-encoded: attachment; filename="${encodeURIComponent(originalName)}".
Range Requests (A/V Seeking)
When Range header is present:
- Parse
bytes=start-end(end defaults tofileSize - 1if omitted) - Set
Content-Range: bytes start-end/totalSize - Set
Content-Lengthto chunk size - Return
206 Partial Contentwithfs.createReadStream({ start, end })
Without Range header: streams entire file with 200 OK.
7. File Cleanup Utilities
fileCleanup.ts:deleteUploadFile(filename)
Deletes a file and its thumbnail from disk.
path.basename(filename) -- directory traversal prevention
fs.unlinkSync(filePath) -- tolerates ENOENT
fs.unlinkSync(thumbPath) -- always attempted, silently ignored if missing
fileCleanup.ts:deleteAttachmentFiles(rows)
Batch deletion: calls deleteUploadFile for each { filename } in the array.
fileCleanup.ts:deleteAttachmentByFilename(filename)
Deletes the attachment DB record and its thumbnail from disk. Used when profile images are set/replaced (the reference moves to users/spaces tables).
1. Look up attachment record by filename
2. Delete thumbnail file from disk (if thumbnailFilename is set)
3. Delete attachment DB record
Idempotent: no-op if no record exists.
8. Storage Janitor
File Classification
storageJanitor.ts:classifyFile() categorizes files by extension for storage stats:
| Category | Extensions |
|---|---|
image |
.jpg, .jpeg, .png, .gif, .webp, .svg, .ico, .bmp, .avif |
video |
.mp4, .webm, .mov, .avi, .mkv |
audio |
.mp3, .ogg, .wav, .flac, .aac, .m4a, .opus |
document |
.pdf, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .txt, .csv, .json, .xml |
other |
Everything else |
Referenced File Detection
getReferencedFilenames() builds a set of all filenames that should exist on disk:
- User avatars (
users.avatarwhere not null) - User banners (
users.bannerwhere not null) - Space icons (
spaces.iconwhere not null) - Space banners (
spaces.bannerwhere not null) - Attachment filenames (
attachments.filename) - Attachment thumbnails (
attachments.thumbnailFilenamewhere not null)
All values are path.basename()-normalized.
getProfileReferencedFilenames() is a subset: only user avatars/banners and space icons/banners. Used to protect profile images during cleanup.
Unlinked Attachment Detection
getUnlinkedAttachments() finds attachment records where:
messageId IS NULLANDdmMessageId IS NULL(never linked to a message)createdAt < (now - 1 hour)(UNLINKED_AGE_MS = 3,600,000ms)- Filename is NOT in profile-referenced set (protects in-use profile images)
These are files uploaded but never sent in a message (abandoned uploads, profile images whose attachment record should have been cleaned up).
Dangling Attachment Detection
getDanglingAttachments() finds attachment records whose message no longer exists:
- Space messages:
attachments.message_id IS NOT NULLbut no matchingmessages.id - DM messages:
attachments.dm_message_id IS NOT NULLbut no matchingdm_messages.id
Uses raw SQL with LEFT JOIN for efficiency.
Storage Stats (getStorageStats())
Returns StorageStats object:
{
totalFiles: number; // All files on disk
totalSize: number; // Total bytes on disk
referencedFiles: number; // Files with DB references
referencedSize: number; // Bytes of referenced files
orphanedFiles: number; // Files on disk with no DB reference
orphanedSize: number; // Bytes of orphaned files
unlinkedAttachments: number; // DB records with no message link (>1h old)
unlinkedSize: number;
danglingAttachments: number; // DB records pointing to deleted messages
danglingSize: number;
breakdown: StorageBreakdown[]; // Per-category {type, count, size}, sorted by size desc
}
Cleanup Routines
cleanupStorage(dryRun: boolean) -> CleanupResult
Three-phase cleanup:
Phase 1: Orphaned disk files -- Files on disk not referenced by any DB record (attachment, profile).
- Deletes file + thumbnail via
deleteUploadFile
Phase 2: Unlinked attachment records -- Attachment DB records with no message link, older than 1 hour.
- If file is used as a profile image: keep file, delete only the thumbnail and DB record
- If file is not a profile image: delete file, thumbnail, and DB record
Phase 3: Dangling attachment records -- Attachment DB records pointing to deleted messages.
- Deletes file, thumbnail, and DB record
cleanupOldMedia(maxAgeDays: number, dryRun: boolean) -> CleanupResult
Age-based media cleanup:
- Finds attachments where
(message_id IS NOT NULL OR dm_message_id IS NOT NULL) AND created_at < cutoff - Skips files used as profile images
- Deletes file, thumbnail, and DB record
CleanupResult
{
dryRun: boolean;
deletedFiles: number;
freedBytes: number;
deletedAttachmentRecords: number;
errors: string[];
}
When dryRun = true: counts are computed but no files/records are deleted.
Federation GC (runFederationJanitor())
Periodic cleanup of federation data (called by background worker):
| Task | Function | Criteria |
|---|---|---|
| Expired outbox entries | cleanupFederationOutbox() |
expiresAt < now |
| Old mutation log | cleanupFederationMutationLog(90) |
mutatedAt < (now - 90 days) |
| Stale file queue | cleanupFederationFileQueue() |
Completed entries > 7 days old, OR expiresAt < now |
| Soft-deleted DM channels | cleanupSoftDeletedDmChannels() |
deletedAt < (now - 24 hours) |
Soft-Deleted DM Channel Purge
cleanupSoftDeletedDmChannels() hard-deletes DM channels that were soft-deleted more than 24 hours ago. Cascades in transaction:
1. Collect message IDs and attachment filenames
2. Transaction:
- Delete dm_reactions (by message IDs)
- Delete embeds (by message IDs)
- Delete attachments DB records (by message IDs)
- Delete federation_file_queue entries (by message IDs)
- Delete dm_messages
- Delete dm_members
- Delete read_states
- Delete federation_outbox entries (by channel ID as contextId)
- Delete federation_mutation_log entries (by channel ID as contextId)
- Delete dm_channels record
3. Delete attachment files from disk (outside transaction)
Admin Endpoints
The admin routes (routes/admin.ts) expose the janitor functions via REST:
| Endpoint | Method | Function |
|---|---|---|
GET /api/admin/storage/stats |
GET | getStorageStats() |
GET /api/admin/storage/orphans |
GET | getOrphanedFiles() |
POST /api/admin/storage/cleanup |
POST | cleanupStorage(dryRun) |
POST /api/admin/storage/cleanup-media |
POST | cleanupOldMedia(maxAgeDays, dryRun) |
POST /api/admin/storage/cleanup-tus |
POST | cleanupStaleTusSessions(maxAgeHours * 3600 * 1000, dryRun) |
All require JWT + admin role. See docs/systems/api.md for request/response formats.
9. Client-Side Architecture
Three stores with strict separation of concerns:
| Store | Source file | Ownership |
|---|---|---|
transferStore |
packages/web/src/stores/transferStore.ts |
Every byte transfer (uploads + downloads). Source of truth for the global tray. Persists transfer metadata via transferStore@v1. Tus uploads route the bearer token through getTokenForOrigin(origin) so federated uploads use the per-instance JWT, not the home-instance one. |
composerStore |
packages/web/src/stores/composerStore.ts |
Per-channel staged transfer IDs + draft text + replyTo. Replaces MessageInput component-local state. Persists via composerStore@v1. |
pendingMessageStore |
packages/web/src/stores/pendingMessageStore.ts |
Composed-but-not-yet-sent attachment-bearing bubbles, keyed by clientId. Persists via pendingMessageStore@v1. Text-only messages are out of scope -- they keep the existing chatStore.sendMessage temp_* optimistic path. |
Optimistic Bubble Lifecycle
- User attaches a file ->
transferStore.startUpload(eager, fires before Send) -> the new transferId joinscomposerStore[channelId].stagedTransferIds. - User clicks Send ->
pendingMessageStore.append({ ... transferIds })->composerStore.clear. The bubble renders inMessageListat itscreatedAtLocalposition. - The orchestrator (
packages/web/src/stores/pendingMessageRehydrate.ts) pollslistReadyForDeferredSend(); once every transfer in the bubble iscompletedwith anattachmentId, it firesPOST /messageswith the collectedattachmentIdsand removes the bubble on success. - WS echo dedup: when a real
message_createarrives withuserId === currentUser.idwhose content + sorted attachmentIds match a pending bubble, the bubble is removed (FIFO tiebreaker). - Failure: bubble flips to
state: 'failed'. Retry re-runs failed transfers only; discard aborts everything and clears the bubble.
Reload Survival
transferStore,composerStore,pendingMessageStoreall use Zustandpersistagainst versionedlocalStoragekeys (transferStore@v1,composerStore@v1,pendingMessageStore@v1).- File handles (Chrome/Edge picker + drag-drop only) are persisted to IndexedDB via
idbHandles.tskeyed bytransfer.fileHandleId. Permission is re-prompted on resume only when the explicit Resume click provides a user-gesture; the boot path queries silently and never prompts. - Bytes themselves never persist. On unsupported browsers the user re-picks (uploads) or restarts (downloads).
- TTL: pending bubbles whose
tusExpiresAtis past are dropped on rehydrate with a one-time toast. - All-transfers-already-complete branch: if every transferId in a rehydrated pending bubble already has an
attachmentId, the deferredPOST /messagesfires immediately on app start.
Boot-time normalization
On store rehydrate (onRehydrateStorage in transferStore.ts -> normalizeRehydratedTransfers):
- Any transfer left in
'active'state (defensive —partializealready filters most) is demoted to'paused'. No live worker exists post-reload. - For each
'paused'transfer, if the bytes are unrecoverable (upload with nofileHandleId; download with nodestFileHandleId), the transfer is marked'failed'with an actionable message ("File no longer available — discard and re-upload" / "Download cannot resume — bytes lost. Restart the download."). The UI then surfaces the discard control instead of a misleading paused state. - For each
'paused'transfer with a stored handle, the rehydrate path silently queries permission viaqueryHandlePermission(never callsrequestPermission, so no user-gesture is required and no prompt appears). If'granted', the transfer auto-resumes on the next tick. If'prompt'or'denied', it stays paused — the user's click on Resume provides the user-gesture forrequestPermission.
Idempotent: re-running is harmless (already-resumed transfers move to 'active', no-op for completed/failed).
The paused state is visually distinct in AttachmentProgress.tsx: desaturated grey conic-gradient ring with a centered pause-icon disk (instead of the mint ring + percentage text used while active), so the user can immediately tell "nothing is happening" from "in progress".
Attachment Rendering (AttachmentRenderer.tsx)
URL resolution for attachment/thumbnail:
- If filename starts with
httpor/: used as-is (federated or absolute path) - Otherwise: prefixed with
/api/uploads/
| MIME category | Rendering |
|---|---|
image/* |
<img> with click-to-preview, lazy loading, aspect ratio from width/height, max 400x300px, uses thumbnail if available |
video/* |
VideoAttachment sub-component. Playable (playable !== false): <video src> with native controls, poster from thumbnail, preload none (with dimensions) or metadata (without), max 400px wide / 300px tall, with an onError handler that falls back to the download card. Unplayable (playable === false, e.g. HEVC .mov): renders the download card directly — poster (if any) under a "Can't play here — download" overlay, plus filename, duration, size and a one-tap download. Browser-aware: the playable === false verdict is computed Chromium-first, so the client only pre-fails when this browser also can't decode the format. WebKit (Safari/iOS) decodes HEVC via the OS, so a Safari user still gets inline playback (gated on BROWSER_SUPPORTS_HEVC, a one-time canPlayType probe for hvc1/hev1); the onError handler remains the safety net. Never a silently broken player. |
audio/* |
Audio card with icon, filename, size, <audio> with native controls, preload metadata, max 420px wide |
| Other | Download link card with file icon, filename (link-styled), size |
Federation Status Badges
Inline badges shown for federated attachments:
federationStatus |
Badge | Tooltip |
|---|---|---|
remote |
Cloud icon (muted) | "Hosted on {username}'s instance. Download to keep a local copy." |
remote_partial |
Warning triangle (amber) | "File couldn't be cached on {username}'s instance (limit: {N} MB). They can still view it from yours." |
The federationMeta JSON is parsed for display details (source username, rejection limits).
Image Preview (ImagePreview.tsx)
Full-screen overlay (z-[200]) with bg-surface-overlay backdrop.
- Opens via
useUIStore.openImagePreview(url)(triggered by clicking an image inAttachmentRenderer) - Shows full-resolution image (not thumbnail) -- max 90vw x 90vh,
object-contain - Toolbar (top-right): Save, Copy, Close buttons
- Click backdrop to close, click image to prevent close propagation
- Managed by
activeModal === 'imagePreview'state
10. Client-Side: Image Actions
imageActions.ts:saveImage(url, filename?)
Downloads an image by fetching as blob and creating a temporary download link:
1. Derive filename from URL (last path segment) or use provided name
2. fetch(url) -> blob -> URL.createObjectURL -> <a download> click -> revoke
3. Fallback on error: window.open(url) + toast "Opened in new tab"
imageActions.ts:copyImageToClipboard(url)
Copies image to clipboard as PNG:
1. GIF detection (by URL extension or Tenor/Klipy domain pattern):
- If GIF: copy URL as text instead (preserves animation)
2. fetch(url) -> blob
3. If response type is image/gif: copy URL as text
4. If PNG: use blob directly
5. Otherwise: convert to PNG via canvas (drawImage -> toBlob('image/png'))
6. navigator.clipboard.write([ClipboardItem({ 'image/png': pngBlob })])
7. Fallback on error: copy URL as text + toast
GIF detection patterns (isGifUrl):
- URL path ends with
.gif - URL matches
media.tenor.comorstatic.klipy.com
11. Client-Side: Image Cropping
cropImage.ts:cropImage(imageSrc, pixelCrop, outputType?, options?)
Used by profile image editors (avatar/banner crop dialogs, integrated with react-easy-crop).
Parameters:
| Param | Type | Default | Description |
|---|---|---|---|
imageSrc |
string |
required | Image URL or data URI |
pixelCrop |
PixelCrop |
required | { x, y, width, height } in pixels |
outputType |
string |
'image/webp' |
MIME type for output |
options.maxDimension |
number? |
none | Max width/height (downscale if exceeded) |
options.quality |
number? |
0.85 |
Compression quality (0-1) |
options.outputType |
string? |
uses outputType param |
Overrides the positional param |
Pipeline:
1. Load image with crossOrigin='anonymous'
2. Draw crop region at original size onto canvas
3. If maxDimension set and crop exceeds it:
- Scale uniformly: scale = maxDimension / max(width, height)
- Draw scaled onto second canvas
4. Export via canvas.toBlob(finalType, quality)
5. WebP fallback: if toBlob returns null for WebP, retry as PNG (old Safari compatibility)
Returns a Blob (Promise).
PixelCrop Interface
interface PixelCrop {
x: number; // Left offset in source image pixels
y: number; // Top offset in source image pixels
width: number; // Crop width in pixels
height: number; // Crop height in pixels
}
12. Asset URL Resolution (Federation)
assetUrls.ts
Handles URL rewriting for federated content:
| Function | Purpose |
|---|---|
stripUploadPrefix(filename) |
Strips /api/uploads/ prefix from filename; no-op for bare filenames and absolute URLs |
resolveAssetUrl(filename, origin) |
Converts relative filename to absolute URL for remote origins; pass-through for http-prefixed URLs |
normalizeUserAssets(user, origin) |
Rewrites user.avatar and user.banner for remote origins; also sets homeInstance/homeUserId for users local to the remote instance |
normalizeMessageAssets(message, origin) |
Rewrites user assets + attachment filenames/thumbnails for remote origins; recurses into replyTo |
These functions are called client-side when displaying content from federated instances, ensuring relative upload paths are resolved to the correct remote server.
13. Download Pipeline
transferStore.startDownload(url, opts) is the single client-side entry point for saving any file (right-click Save Image / Save Video / Save Audio, the file-card download button, and the ImagePreview toolbar). The store branches on capability:
Path 1: FS Access (Chrome/Edge with showSaveFilePicker)
- Prompt the user via
showSaveFilePicker. Persist the returned handle to IndexedDB. - Open a writable; stream
fetchbody to disk; update progress per chunk. - Pause:
controller.abort()and close the writable. Resume: re-permission the handle, query the existing file size, restartfetchwithRange: bytes=<size>-. - Reload survival: the handle persists in IDB and the partial bytes are already on disk -- resume picks up where the previous tab left off.
Path 2: Blob fallback (Safari/Firefox/paste-no-handle)
fetchaccumulates body to in-memory chunks.- On completion:
URL.createObjectURL+ temporary anchor click +URL.revokeObjectURL. - Pause is unsupported (the tray button is disabled with a tooltip). Reload discards the in-memory blob with a one-time toast.
Triggers
| Source file | Site |
|---|---|
packages/web/src/utils/imageActions.ts |
saveImage() -- right-click Save Image, ImagePreview toolbar |
packages/web/src/components/chat/messageMenuItems.tsx |
Save Image / Save Video / Save Audio menu entries |
packages/web/src/components/chat/AttachmentRenderer.tsx |
File-card download button (replaces the previous native <a download>) |
Boundary with Federation
This spec covers local file storage and serving. The boundary:
| This spec (uploads.md) | Federation spec (federation.md) |
|---|---|
| Multipart upload reception | File download queue (federation_file_queue) |
| Thumbnail/metadata generation | Size validation against remote peer limits |
| Disk storage and serving | file_rejected relay event |
| Orphan detection and cleanup | File queue worker (background download) |
| Storage stats and admin cleanup | remoteMaxUploadSize on peer records |
attachments record creation |
sourceUrl, federationStatus, federationMeta fields |
The attachments table contains federation-specific columns (sourceUrl, federationStatus, federationMeta) that are populated by the federation file download worker, not by the upload pipeline.