From 974fbf759ec8db726c87acd6fb56b56fc549f43c Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:24:45 +0200 Subject: [PATCH] feat(server): tus upload endpoint at /api/files with auth, ownership, size, finalize hooks --- packages/server/src/index.ts | 2 + packages/server/src/routes/files.test.ts | 382 +++++++++++++++++++ packages/server/src/routes/files.ts | 394 ++++++++++++++++++++ packages/server/src/utils/auth.ts | 94 +++-- packages/server/src/utils/storageJanitor.ts | 25 ++ 5 files changed, 871 insertions(+), 26 deletions(-) create mode 100644 packages/server/src/routes/files.test.ts create mode 100644 packages/server/src/routes/files.ts diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2c1d566a..cfcc9b61 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -14,6 +14,7 @@ import { spaceRoutes } from './routes/spaces.js'; import { channelRoutes } from './routes/channels.js'; import { messageRoutes } from './routes/messages.js'; import { uploadRoutes } from './routes/uploads.js'; +import { filesRoutes } from './routes/files.js'; import { dmRoutes } from './routes/dm.js'; import { livekitRoutes } from './routes/livekit.js'; import { socialRoutes } from './routes/social.js'; @@ -98,6 +99,7 @@ async function main(): Promise { await app.register(channelRoutes); await app.register(messageRoutes); await app.register(uploadRoutes); + await app.register(filesRoutes); await app.register(dmRoutes); await app.register(livekitRoutes); await app.register(socialRoutes); diff --git a/packages/server/src/routes/files.test.ts b/packages/server/src/routes/files.test.ts new file mode 100644 index 00000000..a3b4d618 --- /dev/null +++ b/packages/server/src/routes/files.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import jwt from 'jsonwebtoken'; +import { eq } from 'drizzle-orm'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import { signJwt } from '../utils/auth.js'; + +setWorkerId(9); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; + +// Each test gets a fresh tmp dir so tus file I/O stays isolated. +let tmpDir: string; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +// Mock heavy media-processing so tests don't need real files / ffmpeg. +vi.mock('../utils/thumbnail.js', () => ({ + generateThumbnail: vi.fn().mockResolvedValue(null), + isResizableImage: vi.fn().mockReturnValue(false), + probeImageDimensions: vi.fn().mockResolvedValue(null), + probeMediaMeta: vi.fn().mockResolvedValue(null), + generateVideoThumbnail: vi.fn().mockResolvedValue(null), +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +// Override config paths to use the test-local tmpDir so tus file I/O is isolated. +// We do this by mocking the config module. +vi.mock('../config.js', async () => { + // Grab the real config first (runs dotenv so JWT_SECRET etc. are set) + const real = await import('../config.js'); + // We'll patch the directory fields; the proxy below reads `tmpDir` at + // call time, which is reassigned in each beforeEach. + return { + config: new Proxy(real.config, { + get(target, prop: string) { + if (prop === 'uploadDir') return tmpDir ?? target.uploadDir; + if (prop === 'tusUploadDir') return tmpDir ? path.join(tmpDir, '.tus') : target.tusUploadDir; + return (target as Record)[prop]; + }, + }), + }; +}); + +async function buildApp(): Promise { + const { filesRoutes } = await import('./files.js'); + const f = Fastify(); + await f.register(filesRoutes); + return f; +} + +const USER_A_ID = 'user-a'; +const USER_B_ID = 'user-b'; + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'backspace-tus-test-')); + + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + + // Seed instance_settings (ensureDefaults equivalent) + testDb.insert(schema.instanceSettings).values({ + id: 1, + updatedAt: Date.now(), + maxUploadSizeBytes: 10 * 1024 * 1024 * 1024, // 10 GB default for most tests + }).run(); + + // Seed two users for ownership tests + testDb.insert(schema.users).values([ + { id: USER_A_ID, username: 'user_a', passwordHash: 'x', isAdmin: 0, createdAt: Date.now() }, + { id: USER_B_ID, username: 'user_b', passwordHash: 'x', isAdmin: 0, createdAt: Date.now() }, + ]).run(); + + app = await buildApp(); +}); + +afterEach(async () => { + await app.close(); + // FileStore.checkOrCreateDirectory() fires an async fs.mkdir in its + // constructor callback. Deleting the tree before that callback resolves + // causes an ENOENT uncaught error. We drain one I/O tick to let the + // callback settle (it will see EEXIST and be ignored), then clean up. + await new Promise(resolve => setTimeout(resolve, 50)); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── Helper: build a tus-compatible Upload-Metadata header ────────────────── +function tusMetadata(fields: Record): string { + return Object.entries(fields) + .map(([k, v]) => `${k} ${Buffer.from(v).toString('base64')}`) + .join(','); +} + +describe('POST /api/files — tus upload endpoint', () => { + it('unauthenticated POST returns 401', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/files', + headers: { + 'tus-resumable': '1.0.0', + 'upload-length': '1024', + 'content-length': '0', + }, + }); + expect(res.statusCode).toBe(401); + }); + + it('authenticated create returns 201 + Location + Upload-Expires', async () => { + const token = signJwt({ userId: USER_A_ID, username: 'user_a' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/files', + headers: { + 'authorization': `Bearer ${token}`, + 'tus-resumable': '1.0.0', + 'upload-length': '512', + 'upload-metadata': tusMetadata({ originalName: 'test.png' }), + 'content-length': '0', + }, + }); + expect(res.statusCode).toBe(201); + expect(res.headers['location']).toBeTruthy(); + expect(res.headers['upload-expires']).toBeTruthy(); + }); + + it('non-owner PATCH returns 403', async () => { + const tokenA = signJwt({ userId: USER_A_ID, username: 'user_a' }); + const tokenB = signJwt({ userId: USER_B_ID, username: 'user_b' }); + + // User A creates the upload + const createRes = await app.inject({ + method: 'POST', + url: '/api/files', + headers: { + 'authorization': `Bearer ${tokenA}`, + 'tus-resumable': '1.0.0', + 'upload-length': '4', + 'upload-metadata': tusMetadata({ originalName: 'secret.txt' }), + 'content-length': '0', + }, + }); + expect(createRes.statusCode).toBe(201); + + const location = createRes.headers['location'] as string; + // Extract the upload ID path from the full Location URL + const uploadPath = location.replace(/^https?:\/\/[^/]+/, ''); + + // User B tries to PATCH — must be rejected + const patchRes = await app.inject({ + method: 'PATCH', + url: uploadPath, + headers: { + 'authorization': `Bearer ${tokenB}`, + 'tus-resumable': '1.0.0', + 'upload-offset': '0', + 'content-type': 'application/offset+octet-stream', + 'content-length': '4', + }, + payload: Buffer.from('data'), + }); + expect(patchRes.statusCode).toBe(403); + }); + + it('rejects DELETE from a non-owner with 403', async () => { + const tokenA = signJwt({ userId: USER_A_ID, username: 'user_a' }); + const tokenB = signJwt({ userId: USER_B_ID, username: 'user_b' }); + + const createRes = await app.inject({ + method: 'POST', + url: '/api/files', + headers: { + 'authorization': `Bearer ${tokenA}`, + 'tus-resumable': '1.0.0', + 'upload-length': '8', + 'upload-metadata': tusMetadata({ originalName: 'mine.txt' }), + 'content-length': '0', + }, + }); + expect(createRes.statusCode).toBe(201); + const uploadPath = (createRes.headers['location'] as string).replace(/^https?:\/\/[^/]+/, ''); + + const delRes = await app.inject({ + method: 'DELETE', + url: uploadPath, + headers: { + 'authorization': `Bearer ${tokenB}`, + 'tus-resumable': '1.0.0', + }, + }); + expect(delRes.statusCode).toBe(403); + }); + + it('rejects HEAD from a non-owner with 403', async () => { + const tokenA = signJwt({ userId: USER_A_ID, username: 'user_a' }); + const tokenB = signJwt({ userId: USER_B_ID, username: 'user_b' }); + + const createRes = await app.inject({ + method: 'POST', + url: '/api/files', + headers: { + 'authorization': `Bearer ${tokenA}`, + 'tus-resumable': '1.0.0', + 'upload-length': '8', + 'upload-metadata': tusMetadata({ originalName: 'mine.txt' }), + 'content-length': '0', + }, + }); + expect(createRes.statusCode).toBe(201); + const uploadPath = (createRes.headers['location'] as string).replace(/^https?:\/\/[^/]+/, ''); + + const headRes = await app.inject({ + method: 'HEAD', + url: uploadPath, + headers: { + 'authorization': `Bearer ${tokenB}`, + 'tus-resumable': '1.0.0', + }, + }); + expect(headRes.statusCode).toBe(403); + }); + + it('rejects requests from a soft-deleted user', async () => { + const token = signJwt({ userId: USER_A_ID, username: 'user_a' }); + + // Mark user A as soft-deleted AFTER signing the token. + testDb.update(schema.users) + .set({ isDeleted: 1 }) + .where(eq(schema.users.id, USER_A_ID)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/files', + headers: { + 'authorization': `Bearer ${token}`, + 'tus-resumable': '1.0.0', + 'upload-length': '4', + 'upload-metadata': tusMetadata({ originalName: 'x.bin' }), + 'content-length': '0', + }, + }); + expect(res.statusCode).toBe(401); + }); + + it("rejects tokens issued before the user's password was changed", async () => { + // Sign a token with iat = T1 (1000s ago). + const t1 = Math.floor(Date.now() / 1000) - 1000; + const { config } = await import('../config.js'); + const token = jwt.sign( + { userId: USER_A_ID, username: 'user_a', iat: t1 }, + config.jwtSecret, + { algorithm: 'HS256', expiresIn: '7d' }, + ); + + // Set passwordChangedAt to T2 (500s ago) in milliseconds — newer than iat. + const t2Ms = (Math.floor(Date.now() / 1000) - 500) * 1000; + testDb.update(schema.users) + .set({ passwordChangedAt: t2Ms }) + .where(eq(schema.users.id, USER_A_ID)) + .run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/files', + headers: { + 'authorization': `Bearer ${token}`, + 'tus-resumable': '1.0.0', + 'upload-length': '4', + 'upload-metadata': tusMetadata({ originalName: 'x.bin' }), + 'content-length': '0', + }, + }); + expect(res.statusCode).toBe(401); + }); + + it('completes an upload end-to-end and returns Attachment JSON in final PATCH response', async () => { + const token = signJwt({ userId: USER_A_ID, username: 'user_a' }); + const body = Buffer.from('hello world\n'); // 12 bytes + + const createRes = await app.inject({ + method: 'POST', + url: '/api/files', + headers: { + 'authorization': `Bearer ${token}`, + 'tus-resumable': '1.0.0', + 'upload-length': String(body.length), + 'upload-metadata': tusMetadata({ originalName: 'greeting.txt' }), + 'content-length': '0', + }, + }); + expect(createRes.statusCode).toBe(201); + const uploadPath = (createRes.headers['location'] as string).replace(/^https?:\/\/[^/]+/, ''); + + const patchRes = await app.inject({ + method: 'PATCH', + url: uploadPath, + headers: { + 'authorization': `Bearer ${token}`, + 'tus-resumable': '1.0.0', + 'upload-offset': '0', + 'content-type': 'application/offset+octet-stream', + 'content-length': String(body.length), + }, + payload: body, + }); + // tus returns 204 from the underlying handler, but onUploadFinish overrides + // with a 200 + JSON body. Either way the body should contain the Attachment. + expect([200, 204]).toContain(patchRes.statusCode); + const json = JSON.parse(patchRes.body) as { + id: string; filename: string; size: number; mimetype: string; + }; + expect(json.id).toBeTruthy(); + expect(json.size).toBe(body.length); + expect(json.mimetype).toBe('application/octet-stream'); // .txt isn't in EXT_MIMETYPES + expect(json.filename.endsWith('.txt')).toBe(true); + + // The attachments row should exist in the DB. + const row = testDb + .select() + .from(schema.attachments) + .where(eq(schema.attachments.id, json.id)) + .get(); + expect(row).toBeTruthy(); + expect(row?.size).toBe(body.length); + expect(row?.uploaderId).toBe(USER_A_ID); + + // The committed file should exist in the upload dir. + expect(fs.existsSync(path.join(tmpDir, json.filename))).toBe(true); + }); + + it('oversize Upload-Length returns 413', async () => { + // Set the instance limit to a small value + testDb.update(schema.instanceSettings) + .set({ maxUploadSizeBytes: 1024 }) // 1 KB limit + .run(); + + const token = signJwt({ userId: USER_A_ID, username: 'user_a' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/files', + headers: { + 'authorization': `Bearer ${token}`, + 'tus-resumable': '1.0.0', + 'upload-length': String(10 * 1024 * 1024 * 1024), // 10 GB > 1 KB + 'upload-metadata': tusMetadata({ originalName: 'huge.bin' }), + 'content-length': '0', + }, + }); + expect(res.statusCode).toBe(413); + }); +}); diff --git a/packages/server/src/routes/files.ts b/packages/server/src/routes/files.ts new file mode 100644 index 00000000..8de3f737 --- /dev/null +++ b/packages/server/src/routes/files.ts @@ -0,0 +1,394 @@ +import type { FastifyInstance } from 'fastify'; +import { Server as TusServer } from '@tus/server'; +import { FileStore } from '@tus/file-store'; +import { config } from '../config.js'; +import { getDb, schema } from '../db/index.js'; +import { generateSnowflake } from '../utils/snowflake.js'; +import { verifyJwtAndUser, AuthError } from '../utils/auth.js'; +import { + parseUploadMetadata, + extractExtension, + buildTusMetadata, + isOwnerOfUpload, + type UploadMetadata, +} from '../utils/tusHooks.js'; +import { + generateThumbnail, + isResizableImage, + probeImageDimensions, + probeMediaMeta, + generateVideoThumbnail, + thumbFilename, +} from '../utils/thumbnail.js'; +import { eq } from 'drizzle-orm'; +import type { Attachment } from '@backspace/shared'; +import fs from 'node:fs'; +import path from 'node:path'; + +// ─── Per-user rate limit for upload creation (30 creates / min) ───────────── +const UPLOAD_CREATE_LIMIT = 30; +const UPLOAD_CREATE_WINDOW_MS = 60_000; +const createCounts = new Map(); + +function checkCreateRateLimit(userId: string): boolean { + const now = Date.now(); + const entry = createCounts.get(userId); + if (!entry || now - entry.windowStart > UPLOAD_CREATE_WINDOW_MS) { + createCounts.set(userId, { count: 1, windowStart: now }); + return true; + } + if (entry.count >= UPLOAD_CREATE_LIMIT) return false; + entry.count += 1; + return true; +} + +// ─── Per-user rate limit for PATCH (slowloris defense, ~1000 / min / user) ── +// Production runs behind Caddy, so req.socket.remoteAddress is always +// 127.0.0.1 — keying on userId is the only meaningful identifier. +const PATCH_PER_USER_LIMIT = 1000; +const PATCH_WINDOW_MS = 60_000; +const patchCounts = new Map(); + +function withinPatchLimit(userId: string): boolean { + const now = Date.now(); + const entry = patchCounts.get(userId); + if (!entry || now - entry.windowStart > PATCH_WINDOW_MS) { + patchCounts.set(userId, { count: 1, windowStart: now }); + return true; + } + if (entry.count >= PATCH_PER_USER_LIMIT) return false; + entry.count += 1; + return true; +} + +// ─── Periodic prune of stale rate-limit entries (every 60s) ───────────────── +let rateLimitPruneTimer: ReturnType | null = null; +function startRateLimitPruner(): void { + if (rateLimitPruneTimer) return; + rateLimitPruneTimer = setInterval(() => { + const now = Date.now(); + for (const [k, v] of createCounts) { + if (now - v.windowStart > UPLOAD_CREATE_WINDOW_MS) createCounts.delete(k); + } + for (const [k, v] of patchCounts) { + if (now - v.windowStart > PATCH_WINDOW_MS) patchCounts.delete(k); + } + }, 60_000); + // Don't keep the event loop alive solely for this timer + if (typeof rateLimitPruneTimer.unref === 'function') rateLimitPruneTimer.unref(); +} + +// ─── Derive mimetype from metadata filename, falling back to octet-stream ─── +const EXT_MIMETYPES: Record = { + '.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.png': 'image/png', '.gif': 'image/gif', '.svg': 'image/svg+xml', + '.avif': 'image/avif', '.tiff': 'image/tiff', '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.wav': 'audio/wav', + '.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus', + '.pdf': 'application/pdf', +}; + +function mimetypeFromFilename(originalName: string): string { + const ext = path.extname(originalName).toLowerCase(); + return EXT_MIMETYPES[ext] ?? 'application/octet-stream'; +} + +export async function filesRoutes(app: FastifyInstance): Promise { + // Ensure tus staging directory exists + fs.mkdirSync(config.tusUploadDir, { recursive: true }); + startRateLimitPruner(); + + const tusServer = new TusServer({ + path: '/api/files', + datastore: new FileStore({ + directory: config.tusUploadDir, + expirationPeriodInMilliseconds: config.tusExpirationMs, + }), + respectForwardedHeaders: true, + relativeLocation: false, + disableTerminationForFinishedUploads: true, + + // ── Auth: verify JWT on every incoming request ───────────────────────── + async onIncomingRequest(req, _res, uploadId) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw { status_code: 401, body: 'Missing or invalid authorization header' }; + } + const token = authHeader.slice(7); + let identity: { userId: string; username: string; homeInstance: string | null }; + try { + identity = await verifyJwtAndUser(token); + } catch (err) { + if (err instanceof AuthError) { + throw { status_code: err.statusCode, body: err.message }; + } + throw { status_code: 401, body: 'Invalid or expired token' }; + } + const userId = identity.userId; + + // Attach userId to the raw request so downstream hooks can read it. + (req as NodeJS.Dict & typeof req).userId = userId; + + // Ownership guard for any method that targets a specific upload by id. + // PATCH/HEAD/DELETE/GET all leak or modify state otherwise: + // - DELETE: any authed user could cancel any upload + // - HEAD: leaks Upload-Metadata (userId, snowflakeId, originalName) + // - GET: streams partial file contents during the .tus staging window + // - PATCH: writes bytes into someone else's upload + const guarded = req.method === 'PATCH' || req.method === 'HEAD' + || req.method === 'DELETE' || req.method === 'GET'; + if (guarded && uploadId) { + // PATCH-specific slowloris/rate defense (per-user, see comment above). + if (req.method === 'PATCH') { + if (!withinPatchLimit(userId)) { + throw { status_code: 429, body: 'PATCH rate limit exceeded' }; + } + } + + let upload; + try { + upload = await tusServer.datastore.getUpload(uploadId); + } catch (err: unknown) { + // Let tus format 404 / 410 errors with status_code; let other + // errors propagate so the caller sees the underlying failure. + if (err !== null && typeof err === 'object' && 'status_code' in err) { + throw err; + } + throw err; + } + if (!isOwnerOfUpload(upload.metadata as UploadMetadata, userId)) { + throw { status_code: 403, body: 'Forbidden: you do not own this upload' }; + } + } + }, + + // ── Upload create: assign snowflake + validate size + store userId ───── + async onUploadCreate(req, res, upload) { + const userId: string = (req as NodeJS.Dict & typeof req).userId as string; + + // Per-user create rate limit + if (!checkCreateRateLimit(userId)) { + throw { status_code: 429, body: 'Upload creation rate limit exceeded' }; + } + + // Read dynamic upload limit from instance_settings, fall back to config + const db = getDb(); + const settings = db + .select({ maxUploadSizeBytes: schema.instanceSettings.maxUploadSizeBytes }) + .from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)) + .get(); + const maxSize = settings?.maxUploadSizeBytes ?? config.maxUploadSize; + + if (upload.size !== undefined && upload.size > maxSize) { + throw { status_code: 413, body: 'File too large' }; + } + + // Extract originalName from existing metadata (decoded by tus already) + const rawMetaHeader = req.headers['upload-metadata'] as string | undefined; + const parsedMeta = parseUploadMetadata(rawMetaHeader ?? null); + const originalName = parsedMeta.originalName ?? parsedMeta.filename; + if (!originalName || !originalName.trim()) { + throw { + status_code: 400, + body: 'Upload-Metadata missing required field: filename', + }; + } + + // Assign snowflake and embed userId into tus metadata + const snowflakeId = generateSnowflake(); + const augmented = buildTusMetadata({ snowflakeId, userId, originalName }); + + // tus's metadata type is Record. buildTusMetadata + // returns UploadMetadata with optional fields; we filter undefined to + // satisfy that constraint (all three fields are guaranteed strings here + // since we just constructed them above). + const mergedMetadata: Record = {}; + for (const [k, v] of Object.entries({ ...upload.metadata, ...augmented })) { + if (v !== undefined) mergedMetadata[k] = v; + } + + return { res, metadata: mergedMetadata }; + }, + + // ── Upload finish: media-process FIRST, then rename as commit point, + // then INSERT. Rename is the atomicity barrier — if anything before + // rename throws, the file stays in .tus/ where the tus expiration + // sweep + cleanupStorage will eventually reap it. If INSERT throws + // after rename, we best-effort delete the renamed file (and the + // unlinked-attachment janitor would catch any survivors). + async onUploadFinish(req, res, upload) { + const requestUserId: string = (req as NodeJS.Dict & typeof req).userId as string; + const meta = upload.metadata ?? {}; + + const snowflakeId = meta.snowflakeId ?? null; + const storedUserId = meta.userId ?? null; + const originalName = meta.originalName ?? 'upload'; + + // Defense in depth: re-verify ownership + if (!snowflakeId || !storedUserId || storedUserId !== requestUserId) { + throw { status_code: 403, body: 'Forbidden: ownership mismatch' }; + } + + const ext = extractExtension(originalName); + const filename = `${snowflakeId}${ext}`; + const srcPath = path.join(config.tusUploadDir, upload.id); + const dstPath = path.join(config.uploadDir, filename); + + // Ensure upload dir exists (may differ from tus staging dir) — needed + // before media processing because thumbnails are written there. + fs.mkdirSync(config.uploadDir, { recursive: true }); + + const mimetype = mimetypeFromFilename(originalName); + + // ── Media processing (on the still-staging file in .tus/) ──────────── + // Wrapped defensively: any failure here must not abort the upload — + // we still want the file to land and get an attachments row, just + // without thumb/dimensions/duration metadata. + let stagedThumbName: string | null = null; + let width: number | null = null; + let height: number | null = null; + let duration: number | null = null; + try { + if (isResizableImage(mimetype)) { + stagedThumbName = await generateThumbnail(srcPath, mimetype, config.uploadDir); + const dims = await probeImageDimensions(srcPath); + if (dims) { + width = dims.width; + height = dims.height; + } + } else if (mimetype.startsWith('video/')) { + const videoThumb = await generateVideoThumbnail(srcPath, config.uploadDir); + if (videoThumb) { + stagedThumbName = videoThumb.thumbnailFilename; + width = videoThumb.width; + height = videoThumb.height; + } + const mediaMeta = await probeMediaMeta(srcPath, mimetype); + if (mediaMeta) { + duration = mediaMeta.duration ?? null; + if (width === null && mediaMeta.width) width = mediaMeta.width; + if (height === null && mediaMeta.height) height = mediaMeta.height; + } + } else if (mimetype.startsWith('audio/')) { + const mediaMeta = await probeMediaMeta(srcPath, mimetype); + if (mediaMeta?.duration) duration = mediaMeta.duration; + } + } catch (err) { + console.error('[files] Media processing failed (non-fatal):', err); + // Reset to a clean baseline; if a partial thumb was written we + // attempt to clean it up below. + if (stagedThumbName) { + try { + fs.unlinkSync(path.join(config.uploadDir, stagedThumbName)); + } catch { /* ignore */ } + stagedThumbName = null; + } + width = null; height = null; duration = null; + } + + // ── Commit point: rename .tus/ → uploads/ ────── + // After this succeeds we own a permanent file. If anything below + // throws we must delete it (or the unlinked-attachment janitor will). + fs.renameSync(srcPath, dstPath); + + // The thumb generators name their output `_thumb.webp` + // — i.e. `_thumb.webp`. Rename it to the canonical + // `_thumb.webp` so storageJanitor's reference set matches. + let thumbnailFilename: string | null = null; + if (stagedThumbName) { + const stagedThumbPath = path.join(config.uploadDir, stagedThumbName); + const finalThumbName = thumbFilename(filename); + const finalThumbPath = path.join(config.uploadDir, finalThumbName); + try { + fs.renameSync(stagedThumbPath, finalThumbPath); + thumbnailFilename = finalThumbName; + } catch (err) { + console.error('[files] Thumbnail rename failed (non-fatal):', err); + try { fs.unlinkSync(stagedThumbPath); } catch { /* ignore */ } + thumbnailFilename = null; + } + } + + // Best-effort: delete tus .json sidecar + const sidecarPath = `${srcPath}.json`; + if (fs.existsSync(sidecarPath)) { + try { fs.unlinkSync(sidecarPath); } catch { /* ignore */ } + } + + const size = upload.size ?? fs.statSync(dstPath).size; + const now = Date.now(); + + // ── Insert attachments row ─────────────────────────────────────────── + try { + const db = getDb(); + db.insert(schema.attachments).values({ + id: snowflakeId, + uploaderId: storedUserId, + messageId: null, + dmMessageId: null, + filename, + originalName, + mimetype, + size, + thumbnailFilename, + width, + height, + duration, + createdAt: now, + }).run(); + } catch (err) { + // INSERT failed after the rename succeeded — best-effort delete the + // committed file + thumbnail so we don't leak an orphan. The + // storageJanitor unlinked-attachment sweep would also catch it + // eventually, but this keeps the failure window small. + try { fs.unlinkSync(dstPath); } catch { /* ignore */ } + if (thumbnailFilename) { + try { fs.unlinkSync(path.join(config.uploadDir, thumbnailFilename)); } catch { /* ignore */ } + } + throw err; + } + + const attachment: Attachment = { + id: snowflakeId, + messageId: '', + filename, + originalName, + mimetype, + size, + thumbnailFilename: thumbnailFilename ?? undefined, + width: width ?? undefined, + height: height ?? undefined, + duration: duration ?? undefined, + createdAt: now, + }; + + return { + res, + status_code: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(attachment), + }; + }, + }); + + // Register the content-type parser that tus requires + app.addContentTypeParser( + 'application/offset+octet-stream', + (_request, _payload, done) => done(null), + ); + + // Delegate all /api/files and /api/files/* requests to tus. + // reply.hijack() tells Fastify the response is being managed externally — + // prevents lifecycle races (double-send, reply timing) under load. + app.all('/api/files', (request, reply) => { + reply.hijack(); + tusServer.handle(request.raw, reply.raw); + }); + app.all('/api/files/*', (request, reply) => { + reply.hijack(); + tusServer.handle(request.raw, reply.raw); + }); +} diff --git a/packages/server/src/utils/auth.ts b/packages/server/src/utils/auth.ts index eeb498bc..1be49665 100644 --- a/packages/server/src/utils/auth.ts +++ b/packages/server/src/utils/auth.ts @@ -33,6 +33,67 @@ export function verifyJwt(token: string): JwtPayload { return decoded; } +/** + * AuthError carries an HTTP status code so raw-IncomingMessage paths + * (e.g. tus hooks) can re-throw with a status the caller maps onto + * its own response object. + */ +export class AuthError extends Error { + statusCode: number; + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} + +/** + * Verify a JWT token AND confirm the user still exists, isn't deleted, + * and the token hasn't been revoked by a password change. Returns the + * resolved user identity. Throws AuthError (statusCode = 401) on any + * failure. + * + * Used by Fastify's `authenticate` preHandler AND by raw-IncomingMessage + * paths (tus hooks) that can't go through the preHandler pipeline. + */ +export async function verifyJwtAndUser(token: string): Promise<{ + userId: string; + username: string; + homeInstance: string | null; +}> { + let payload: JwtPayload; + try { + payload = verifyJwt(token); + } catch { + throw new AuthError('Invalid or expired token', 401); + } + + const db = getDb(); + const user = db.select({ + id: schema.users.id, + isDeleted: schema.users.isDeleted, + passwordChangedAt: schema.users.passwordChangedAt, + homeInstance: schema.users.homeInstance, + }).from(schema.users).where(eq(schema.users.id, payload.userId)).get(); + + if (!user || user.isDeleted === 1) { + throw new AuthError('This account has been deleted', 401); + } + + // Reject tokens issued before the last password change (token revocation). + // JWT `iat` is in seconds; passwordChangedAt is in milliseconds. + if (user.passwordChangedAt && payload.iat) { + if (payload.iat < Math.floor(user.passwordChangedAt / 1000)) { + throw new AuthError('Token has been revoked — please log in again', 401); + } + } + + return { + userId: payload.userId, + username: payload.username, + homeInstance: user.homeInstance ?? null, + }; +} + export async function authenticate( request: FastifyRequest, reply: FastifyReply, @@ -45,33 +106,14 @@ export async function authenticate( const token = authHeader.slice(7); try { - const payload = verifyJwt(token); - - // Verify user exists and is not deleted/revoked - const db = getDb(); - const user = db.select({ - id: schema.users.id, - isDeleted: schema.users.isDeleted, - passwordChangedAt: schema.users.passwordChangedAt, - homeInstance: schema.users.homeInstance, - }).from(schema.users).where(eq(schema.users.id, payload.userId)).get(); - - if (!user || user.isDeleted === 1) { - return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 }); + const identity = await verifyJwtAndUser(token); + (request as FastifyRequest & { userId: string; username: string }).userId = identity.userId; + (request as FastifyRequest & { userId: string; username: string }).username = identity.username; + (request as FastifyRequest & { userId: string; username: string }).homeInstance = identity.homeInstance; + } catch (err) { + if (err instanceof AuthError) { + return reply.code(err.statusCode).send({ error: err.message, statusCode: err.statusCode }); } - - // Reject tokens issued before the last password change (token revocation) - if (user.passwordChangedAt && payload.iat) { - // JWT iat is in seconds, passwordChangedAt is in milliseconds - if (payload.iat < Math.floor(user.passwordChangedAt / 1000)) { - return reply.code(401).send({ error: 'Token has been revoked — please log in again', statusCode: 401 }); - } - } - - (request as FastifyRequest & { userId: string; username: string }).userId = payload.userId; - (request as FastifyRequest & { userId: string; username: string }).username = payload.username; - (request as FastifyRequest & { userId: string; username: string }).homeInstance = user.homeInstance ?? null; - } catch { return reply.code(401).send({ error: 'Invalid or expired token', statusCode: 401 }); } } diff --git a/packages/server/src/utils/storageJanitor.ts b/packages/server/src/utils/storageJanitor.ts index df636113..c9031a17 100644 --- a/packages/server/src/utils/storageJanitor.ts +++ b/packages/server/src/utils/storageJanitor.ts @@ -693,12 +693,18 @@ export function cleanupSoftDeletedDmChannels(): number { return purged; } +// cleanupStorage is expensive (full disk scan + DB joins) so we run it at +// most once per 24h rather than on every janitor tick. +const STORAGE_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; +let lastStorageCleanupAt = 0; + /** * Run all periodic federation/GC cleanup tasks: * - Expired outbox entries * - Old mutation log entries * - Stale file queue entries * - Soft-deleted DM channels past grace period + * - Once-per-day: cleanupStorage (orphan files + unlinked + dangling) */ export async function runFederationJanitor(): Promise { try { @@ -729,6 +735,25 @@ export async function runFederationJanitor(): Promise { } catch (err) { console.error('[storage-janitor] Approval request expiry error:', err); } + + // Once-per-day storage cleanup. Sweeps orphan disk files (unreferenced + // by any DB row) + unlinked attachments (uploaded but never attached + // to a message past the 1h grace) + dangling attachment rows. Backs up + // the tus POST_FINISH atomicity guarantees in routes/files.ts. + const now = Date.now(); + if (now - lastStorageCleanupAt >= STORAGE_CLEANUP_INTERVAL_MS) { + lastStorageCleanupAt = now; + try { + const result = cleanupStorage(false); + if (result.deletedFiles > 0 || result.deletedAttachmentRecords > 0 || result.errors.length > 0) { + console.log( + `[storage-janitor] Daily storage sweep: deletedFiles=${result.deletedFiles} freedBytes=${result.freedBytes} deletedAttachmentRecords=${result.deletedAttachmentRecords} errors=${result.errors.length}`, + ); + } + } catch (err) { + console.error('[storage-janitor] Storage cleanup error:', err); + } + } } catch (err) { console.error('[storage-janitor] Federation GC sweep error:', err); }