mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(api): batch user-files delete to eliminate N+1 queries
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { extname } from "node:path";
|
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 type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -481,52 +481,70 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
const { ids } = parsed.data;
|
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 = {
|
type DeleteChainRow = {
|
||||||
id: string;
|
id: string;
|
||||||
stored_name: string;
|
stored_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const id of ids) {
|
// Single recursive CTE to collect all chain members for every valid ID
|
||||||
// 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<DeleteChainRow>(sql`
|
const cteResult = await db.execute<DeleteChainRow>(sql`
|
||||||
WITH RECURSIVE chain(id, stored_name) AS (
|
WITH RECURSIVE
|
||||||
SELECT f.id, f.stored_name
|
ancestors(id, parent_id) AS (
|
||||||
FROM user_files f
|
SELECT id, parent_id FROM user_files
|
||||||
WHERE f.id = (
|
WHERE id = ANY(${validIds}::uuid[])
|
||||||
WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
||||||
SELECT id, parent_id FROM user_files WHERE id = ${id}
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT uf.id, uf.parent_id FROM user_files uf
|
SELECT uf.id, uf.parent_id FROM user_files uf
|
||||||
INNER JOIN ancestors a ON uf.id = a.parent_id
|
INNER JOIN ancestors a ON uf.id = a.parent_id
|
||||||
)
|
),
|
||||||
SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1
|
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
|
UNION ALL
|
||||||
SELECT child.id, child.stored_name
|
SELECT child.id, child.stored_name
|
||||||
FROM user_files child
|
FROM user_files child
|
||||||
INNER JOIN chain c ON child.parent_id = c.id
|
INNER JOIN chain c ON child.parent_id = c.id
|
||||||
)
|
)
|
||||||
SELECT id, stored_name FROM chain
|
SELECT DISTINCT id, stored_name FROM chain
|
||||||
`);
|
`);
|
||||||
const chainRows = cteResult.rows;
|
const chainRows = cteResult.rows;
|
||||||
|
|
||||||
|
// Filesystem deletes (must loop; cannot batch across the OS)
|
||||||
for (const row of chainRows) {
|
for (const row of chainRows) {
|
||||||
await deleteStoredFile(row.stored_name);
|
await deleteStoredFile(row.stored_name);
|
||||||
await deleteThumbnail(row.stored_name);
|
await deleteThumbnail(row.stored_name);
|
||||||
await db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id));
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user