feat(server): tus upload endpoint at /api/files with auth, ownership, size, finalize hooks
This commit is contained in:
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<typeof drizzle<typeof schema>>;
|
||||
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<string, unknown>)[prop];
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
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<void>(resolve => setTimeout(resolve, 50));
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Helper: build a tus-compatible Upload-Metadata header ──────────────────
|
||||
function tusMetadata(fields: Record<string, string>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { count: number; windowStart: number }>();
|
||||
|
||||
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<string, { count: number; windowStart: number }>();
|
||||
|
||||
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<typeof setInterval> | 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<string, string> = {
|
||||
'.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<void> {
|
||||
// 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<unknown> & 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<unknown> & 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<string, string | null>. 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<string, string | null> = {};
|
||||
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<unknown> & 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/<id> → uploads/<snowflakeId><ext> ──────
|
||||
// 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 `<basename(srcPath)>_thumb.webp`
|
||||
// — i.e. `<uploadId>_thumb.webp`. Rename it to the canonical
|
||||
// `<snowflakeId>_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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
try {
|
||||
@@ -729,6 +735,25 @@ export async function runFederationJanitor(): Promise<void> {
|
||||
} 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user