- Add complete docs/systems/ reference (18 system docs) - Add federation relay status doc and prior spec/plan docs - Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md) - CLAUDE.md updates - Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
24 KiB
File & Upload System
Source files:
packages/server/src/routes/uploads.ts-- Upload endpoint (multipart reception, size enforcement) and file serving (cache, security, Range)packages/server/src/utils/thumbnail.ts-- Image thumbnail generation (sharp), video thumbnail extraction (ffmpeg), image dimension probing, profile image resizing, media metadata extractionpackages/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/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/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.
1. Upload Pipeline
Endpoint: POST /api/uploads
| Property | Value |
|---|---|
| Auth | JWT required (authenticate preHandler) |
| Content type | Multipart (request.file()) |
| Rate limit | 30 requests / 1 minute (keyed by userId or IP) |
| Size limit | Dynamic from instance_settings.maxUploadSizeBytes, fallback config.maxUploadSize (env MAX_UPLOAD_SIZE, default 100 MB) |
| Response | 201 with Attachment object |
Upload Flow
Client (multipart POST)
-> authenticate JWT
-> read maxUploadSizeBytes from instance_settings (id=1)
-> request.file({ limits: { fileSize: maxSize } })
-> generate snowflake ID
-> derive filename: "${snowflakeId}${originalExtension}"
-> stream to disk via pipeline(data.file, writeStream)
-> check truncation (file exceeded limit) -> 413 + delete file
-> fs.statSync for actual file size
-> media processing (image/video/audio)
-> insert attachments record (messageId=NULL, dmMessageId=NULL)
-> return Attachment { id, filename, originalName, mimetype, size, thumbnailFilename?, width?, height?, duration? }
Key details:
- Filename: snowflake ID + original file extension (e.g.,
1234567890123456.png) - The attachment is created with
messageId = NULL-- it is linked to a message later when the message is sent viaPOST /channels/:id/messagesorPOST /dm/:id/messages - Returned
Attachment.messageIdis set to''(empty string) in the response, not null - Upload directory:
config.uploadDir(defaultpackages/server/data/uploads/), created on route registration if missing
Error Responses
| Code | Condition |
|---|---|
400 |
No file in multipart request |
413 |
File exceeds dynamic size limit (file deleted from disk after truncation detection) |
429 |
Rate limit exceeded |
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:
-select_streams v:0 -show_entries stream=width,height -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
- ffprobe for dimensions:
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.
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) |
All require JWT + admin role. See docs/systems/api.md for request/response formats.
9. Client-Side: Upload & Attachment Display
Upload Flow (MessageInput.tsx)
- Files added via:
- File picker button (
<input type="file">triggered by attach button click) - Drag and drop (
onDrophandler on input container) -- addse.dataTransfer.filesto state - Paste (
onPastehandler) -- extractsimage/*items from clipboardDataTransferItemList
- File picker button (
- Files stored in component state as
File[] - Preview rendered inline: images as
<img>withURL.createObjectURL, non-images as file icon + name - On submit:
- Resolves correct API client for channel origin (
getApiForOrigin(getChannelOrigin(channelId))) - Uploads files sequentially via
uploadClient.uploads.uploadWithProgress(file, onProgress) - Progress tracked per-file in a
Map<number, number>(file index -> percentage 0-100) - Collects
attachment.idfrom each successful upload - Failed uploads: toast notification, skipped (partial sends allowed if text or other attachments present)
- Sends message with collected
attachmentIdsarray
- Resolves correct API client for channel origin (
Progress Tracking (api/client.ts)
Two upload methods:
| Method | Transport | Timeout | Progress |
|---|---|---|---|
uploads.upload(file) |
fetch API |
120 seconds | No |
uploads.uploadWithProgress(file, onProgress) |
XMLHttpRequest |
10 minutes | Yes, via xhr.upload.progress event |
Both send multipart FormData with the file under key 'file'.
The progress callback receives (loaded: number, total: number) from the XHR progress event.
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/* |
<video> with native controls, poster from thumbnail, preload none (with dimensions) or metadata (without), max 400px wide / 300px tall |
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.
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.