feat(server): tus upload endpoint at /api/files with auth, ownership, size, finalize hooks

This commit is contained in:
Jannis Braun
2026-04-30 01:50:03 +02:00
parent 21022eaa73
commit 974fbf759e
5 changed files with 871 additions and 26 deletions
+68 -26
View File
@@ -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);
}