feat(server): tus upload endpoint at /api/files with auth, ownership, size, finalize hooks
This commit is contained in:
@@ -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