From e2c57144dd1f8f9a7de629228967725861cb50c7 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 10:37:32 +0800 Subject: [PATCH] fix(api): batch user-files delete to eliminate N+1 queries --- apps/api/src/routes/user-files.ts | 92 ++++++++++++++++++------------- 1 file changed, 55 insertions(+), 37 deletions(-) diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 9e40479d..0890c9e7 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -11,7 +11,7 @@ */ import { randomUUID } from "node:crypto"; import { extname } from "node:path"; -import { and, desc, eq, like, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, like, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; @@ -481,52 +481,70 @@ export async function userFileRoutes(app: FastifyInstance): Promise { } const { ids } = parsed.data; - let deletedCount = 0; + // Check files:all permission once upfront + const canDeleteAll = await hasEffectivePermission(user, "files:all"); + + // Batch ownership check: single SELECT for all requested IDs + const candidates = await db + .select({ id: schema.userFiles.id, userId: schema.userFiles.userId }) + .from(schema.userFiles) + .where(inArray(schema.userFiles.id, ids)); + + const validIds = candidates + .filter((f) => f.userId === user.id || canDeleteAll) + .map((f) => f.id); + + if (validIds.length === 0) { + await auditLog(request.log, "FILE_DELETED", { userId: user.id, count: 0, ids }); + return reply.send({ deleted: 0 }); + } type DeleteChainRow = { id: string; stored_name: string; }; - for (const id of ids) { - // Ownership check: non-admin users can only delete their own files - const [file] = await db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)); - if (!file || (file.userId !== user.id && !(await hasEffectivePermission(user, "files:all")))) - continue; - // Collect all files in the chain using a recursive CTE - const cteResult = await db.execute(sql` - WITH RECURSIVE chain(id, stored_name) AS ( - SELECT f.id, f.stored_name - FROM user_files f - WHERE f.id = ( - WITH RECURSIVE ancestors(id, parent_id) AS ( - SELECT id, parent_id FROM user_files WHERE id = ${id} - UNION ALL - SELECT uf.id, uf.parent_id FROM user_files uf - INNER JOIN ancestors a ON uf.id = a.parent_id - ) - SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1 - ) - UNION ALL - SELECT child.id, child.stored_name - FROM user_files child - INNER JOIN chain c ON child.parent_id = c.id - ) - SELECT id, stored_name FROM chain - `); - const chainRows = cteResult.rows; + // Single recursive CTE to collect all chain members for every valid ID + const cteResult = await db.execute(sql` + WITH RECURSIVE + ancestors(id, parent_id) AS ( + SELECT id, parent_id FROM user_files + WHERE id = ANY(${validIds}::uuid[]) + UNION ALL + SELECT uf.id, uf.parent_id FROM user_files uf + INNER JOIN ancestors a ON uf.id = a.parent_id + ), + chain(id, stored_name) AS ( + SELECT f.id, f.stored_name FROM user_files f + WHERE f.id IN (SELECT id FROM ancestors WHERE parent_id IS NULL) + UNION ALL + SELECT child.id, child.stored_name + FROM user_files child + INNER JOIN chain c ON child.parent_id = c.id + ) + SELECT DISTINCT id, stored_name FROM chain + `); + const chainRows = cteResult.rows; - for (const row of chainRows) { - await deleteStoredFile(row.stored_name); - await deleteThumbnail(row.stored_name); - await db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id)); - deletedCount++; - } + // Filesystem deletes (must loop; cannot batch across the OS) + for (const row of chainRows) { + await deleteStoredFile(row.stored_name); + await deleteThumbnail(row.stored_name); } - await auditLog(request.log, "FILE_DELETED", { userId: user.id, count: deletedCount, ids }); + // Batch DB delete + const chainIds = chainRows.map((r) => r.id); + if (chainIds.length > 0) { + await db.delete(schema.userFiles).where(inArray(schema.userFiles.id, chainIds)); + } - return reply.send({ deleted: deletedCount }); + await auditLog(request.log, "FILE_DELETED", { + userId: user.id, + count: chainRows.length, + ids, + }); + + return reply.send({ deleted: chainRows.length }); }); /**