# 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 helpers - `packages/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 (drives `attachments.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 purge - `packages/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, replyTo - `packages/web/src/stores/pendingMessageStore.ts` -- Optimistic attachment-bearing message bubbles awaiting transfer completion - `packages/web/src/stores/pendingMessageRehydrate.ts` -- Orchestrator that fires the deferred `POST /messages` once all transfers in a bubble complete - `packages/web/src/utils/imageActions.ts` -- Client-side image save-to-disk and copy-to-clipboard actions - `packages/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 bubbles - `packages/web/src/components/layout/TransferIndicator.tsx` -- Channel-header transfer indicator + global tray panel - `packages/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](https://tus.io/protocols/resumable-upload) 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 `.json` metadata sidecar. | | `${uploadDir}/` | server | Final files renamed on `POST_FINISH` to `${snowflakeId}${ext}`. | ### POST_FINISH Hook When the final PATCH completes, the hook: 1. Verifies `metadata.userId === req.user.id` (defense in depth -- ownership was already enforced on PATCH). 2. Renames `${uploadDir}/.tus/` to `${uploadDir}/${snowflakeId}${ext}`, where `ext = path.extname(metadata.originalName).toLowerCase()`. 3. Runs media processing (sharp for images, ffmpeg/ffprobe for video/audio) -- same code path as the legacy multipart endpoint used to. 4. Inserts an `attachments` row with `messageId = NULL` (linked to a message later when the user sends). 5. Returns the new `Attachment` JSON 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)` in `crossStoreResolvers.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/cors` registration in `index.ts` permits 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.maxUploadSizeBytes` at PRE_CREATE; tus's own `maxSize` is 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 1. **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` (via `thumbFilename()`) - Returns `null` if skipped or on error 2. **Dimension probing** (`thumbnail.ts:probeImageDimensions`) - Uses `sharp(filepath).metadata()` to extract `width` and `height` - Works for all formats including animated GIFs ### Video Processing **Condition:** `mimetype.startsWith('video/')` 1. **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) 2. **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's `codec_name`) when available 3. **Web-playability classification** (`mediaPlayable.ts:classifyVideoPlayable`) - The finish hook calls `classifyVideoPlayable(mimetype, codec)` and stores the result in `attachments.playable` (tri-state, see below). - The browser `