Files
SnapOtter/apps/api/src/routes/user-files.ts
T
SnapOtter 4e64ee2779 fix(security): comprehensive security audit and hardening
Auth: login rate limit 30/min (was 500), global rate limit 1000/min (was
unlimited), password/username max lengths on all Zod schemas, session
invalidation on role change, API key legacy scan bounded to 100 keys.

SVG: hardened regex sanitizer with CDATA stripping, XML entity decoding,
set/animate/iframe/embed blocking, comprehensive data: URI blocking,
use element external href blocking. 11 attack payload fixtures added.

SSRF: fixed DNS rebinding TOCTOU by pinning resolved IPs via custom
HTTP/HTTPS agents. Added 6to4 and NAT64 to blocked IPv6 ranges.

Docker: capability dropping (cap_drop ALL + minimal cap_add), resource
limits (4g/8g mem, 512/1024 pids), healthcheck timeout, password
removed from startup banner, default password warning comments.

Network: CSP and HSTS applied in all environments (not just production),
stack traces removed from all error responses, internal paths stripped
from error details, per-route rate limits on uploads (60/min) and URL
fetches (200/hour).

Files: exclusive temp file creation (O_EXCL), disk space circuit
breaker, per-user storage quotas, settings payload 64KB size guard.

Python sidecar: script name allowlist in dispatcher, minimal environment
for subprocess spawns.

Dependencies: fixed 6 production CVEs (drizzle-orm, fastify, fast-uri,
@fastify/static, next, archiver/lodash). Pinned all GitHub Actions to
SHA hashes.

114 security tests added. Full OWASP Top 10 penetration test matrix
verified against production Docker container (30/30 pass after
hardening).
2026-05-13 21:33:50 +08:00

648 lines
21 KiB
TypeScript

/**
* User file library CRUD routes.
*
* GET /api/v1/files — List latest files (one per version chain)
* POST /api/v1/files/upload — Upload one or more image files
* GET /api/v1/files/:id — File details + full version history
* GET /api/v1/files/:id/download — Stream file as attachment
* GET /api/v1/files/:id/thumbnail — 300px JPEG thumbnail on-the-fly
* DELETE /api/v1/files — Bulk delete entire version chains
* POST /api/v1/files/save-result — Save a tool processing result (new version)
*/
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import { and, desc, eq, like, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { env } from "../config.js";
import { db, schema, sqlite } from "../db/index.js";
import { auditLog } from "../lib/audit.js";
import {
deleteStoredFile,
deleteThumbnail,
getCachedThumbnail,
getStoredFilePath,
saveFile,
saveThumbnail,
} from "../lib/file-storage.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
import { decodeHeic } from "../lib/heic-converter.js";
import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
import { hasEffectivePermission } from "../permissions.js";
import { getAuthUser, requireAuth } from "../plugins/auth.js";
// ── Helpers ────────────────────────────────────────────────────────
function formatToMime(format: string): string {
const map: Record<string, string> = {
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
bmp: "image/bmp",
tiff: "image/tiff",
avif: "image/avif",
};
return map[format] ?? "application/octet-stream";
}
function extToMime(ext: string): string {
const clean = ext.toLowerCase().replace(/^\./, "");
const map: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
bmp: "image/bmp",
tiff: "image/tiff",
tif: "image/tiff",
avif: "image/avif",
};
return map[clean] ?? "application/octet-stream";
}
function serializeFile(row: typeof schema.userFiles.$inferSelect) {
return {
id: row.id,
originalName: row.originalName,
mimeType: row.mimeType,
size: row.size,
width: row.width,
height: row.height,
version: row.version,
parentId: row.parentId,
toolChain: row.toolChain ? JSON.parse(row.toolChain) : [],
createdAt: row.createdAt.toISOString(),
};
}
/**
* Check whether a user has exceeded their storage quota.
* Returns the total bytes used, or throws if the quota is exceeded.
*/
function checkStorageQuota(userId: string | null): void {
if (!userId || env.MAX_STORAGE_PER_USER_MB <= 0) return;
const result = db
.select({ total: sql<number>`coalesce(sum(${schema.userFiles.size}), 0)` })
.from(schema.userFiles)
.where(eq(schema.userFiles.userId, userId))
.get();
const usedBytes = result?.total ?? 0;
const limitBytes = env.MAX_STORAGE_PER_USER_MB * 1024 * 1024;
if (usedBytes >= limitBytes) {
const error = new Error(
`Storage quota exceeded. Used ${(usedBytes / (1024 * 1024)).toFixed(1)}MB of ${env.MAX_STORAGE_PER_USER_MB}MB`,
);
(error as Error & { statusCode: number }).statusCode = 413;
throw error;
}
}
// ── Route registration ─────────────────────────────────────────────
export async function userFileRoutes(app: FastifyInstance): Promise<void> {
/**
* GET /api/v1/files
*
* Returns the latest version of each file chain, sorted by createdAt DESC.
* A file is "latest" if its id is not referenced as a parentId by any other file.
*
* Query params:
* search — filter on originalName (SQL LIKE)
* limit — default 50
* offset — default 0
*/
app.get(
"/api/v1/files",
async (
request: FastifyRequest<{
Querystring: { search?: string; limit?: string; offset?: string };
}>,
reply: FastifyReply,
) => {
const user = requireAuth(request, reply);
if (!user) return;
const limit = parseInt(request.query.limit ?? "50", 10) || 50;
const offset = parseInt(request.query.offset ?? "0", 10) || 0;
const search = request.query.search?.trim();
// A file is the "latest" if no other row has it as its parentId.
// We use a SQL NOT IN subquery for this.
const latestCondition = sql`${schema.userFiles.id} NOT IN (
SELECT parent_id FROM user_files WHERE parent_id IS NOT NULL
)`;
// Build the where clauses
const conditions = [latestCondition];
// Users without files:all only see their own files
if (!hasEffectivePermission(user, "files:all")) {
conditions.push(eq(schema.userFiles.userId, user.id));
}
if (search) {
const escaped = search.replace(/[%_\\]/g, "\\$&");
conditions.push(like(schema.userFiles.originalName, `%${escaped}%`));
}
const rows = db
.select()
.from(schema.userFiles)
.where(and(...conditions))
.orderBy(desc(schema.userFiles.createdAt))
.limit(limit)
.offset(offset)
.all();
// Total count (for pagination)
const countResult = db
.select({ count: sql<number>`count(*)` })
.from(schema.userFiles)
.where(and(...conditions))
.get();
return reply.send({
files: rows.map(serializeFile),
total: countResult?.count ?? 0,
limit,
offset,
});
},
);
/**
* POST /api/v1/files/upload
*
* Multipart form with one or more image file parts.
* Validates each (magic bytes + dimensions), stores to disk, creates DB record.
*/
app.post(
"/api/v1/files/upload",
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = getAuthUser(request);
const userId = user?.id ?? null;
// Enforce per-user storage quota before accepting uploads
try {
checkStorageQuota(userId);
} catch (err) {
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
return reply.status(statusCode).send({ error: (err as Error).message });
}
const created: ReturnType<typeof serializeFile>[] = [];
const parts = request.parts();
for await (const part of parts) {
if (part.type !== "file") continue;
// Consume the stream into a buffer
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
if (buffer.length === 0) continue;
// Validate image
const validation = await validateImageBuffer(buffer, part.filename);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid file "${part.filename}": ${validation.reason}`,
});
}
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
const safeName = sanitizeFilename(part.filename ?? "upload");
const mimeType = formatToMime(validation.format);
// Persist to disk
const storedName = await saveFile(safeBuffer, safeName);
// Create DB record
const id = randomUUID();
try {
db.insert(schema.userFiles)
.values({
id,
userId,
originalName: safeName,
storedName,
mimeType,
size: safeBuffer.length,
width: validation.width,
height: validation.height,
version: 1,
parentId: null,
toolChain: null,
})
.run();
} catch {
return reply.status(409).send({ error: "Failed to save file record" });
}
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (row) created.push(serializeFile(row));
}
if (created.length === 0) {
return reply.status(400).send({ error: "No valid files uploaded" });
}
auditLog(request.log, "FILE_UPLOADED", {
userId,
count: created.length,
files: created.map((f) => f.originalName),
});
return reply.status(201).send({ files: created });
},
);
/**
* GET /api/v1/files/:id
*
* Returns full metadata for a file plus the complete version chain
* (from the root ancestor down through every version to the latest).
*/
app.get(
"/api/v1/files/:id",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) {
return reply.status(404).send({ error: "File not found" });
}
// Walk the full version chain using a recursive CTE.
// First find the root ancestor, then collect all descendants.
interface ChainRow {
id: string;
original_name: string;
mime_type: string;
size: number;
width: number | null;
height: number | null;
version: number;
parent_id: string | null;
tool_chain: string | null;
created_at: number;
}
const chainRows = sqlite
.prepare(`
WITH RECURSIVE
ancestors(id, parent_id) AS (
SELECT id, parent_id FROM user_files WHERE id = ?
UNION ALL
SELECT uf.id, uf.parent_id FROM user_files uf
INNER JOIN ancestors a ON uf.id = a.parent_id
),
chain(id, original_name, mime_type, size, width, height,
version, parent_id, tool_chain, created_at) AS (
SELECT f.id, f.original_name, f.mime_type, f.size, f.width, f.height,
f.version, f.parent_id, f.tool_chain, f.created_at
FROM user_files f
WHERE f.id = (SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1)
UNION ALL
SELECT child.id, child.original_name, child.mime_type, child.size,
child.width, child.height, child.version, child.parent_id,
child.tool_chain, child.created_at
FROM user_files child
INNER JOIN chain c ON child.parent_id = c.id
)
SELECT * FROM chain ORDER BY version ASC
`)
.all(id) as ChainRow[];
const versions = chainRows.map((r) => ({
id: r.id,
originalName: r.original_name,
mimeType: r.mime_type,
size: r.size,
width: r.width,
height: r.height,
version: r.version,
parentId: r.parent_id,
toolChain: r.tool_chain ? JSON.parse(r.tool_chain) : [],
createdAt: new Date(r.created_at * 1000).toISOString(),
}));
return reply.send({
file: serializeFile(file),
versions,
});
},
);
/**
* GET /api/v1/files/:id/download
*
* Stream the stored file back to the client as an attachment.
*/
app.get(
"/api/v1/files/:id/download",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) {
return reply.status(404).send({ error: "File not found" });
}
const filePath = getStoredFilePath(file.storedName);
const stream = createReadStream(filePath);
stream.on("error", () => {
if (!reply.raw.headersSent) {
reply.status(404).send({ error: "File not found on disk" });
}
});
return reply
.header("Content-Type", file.mimeType)
.header(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(file.originalName)}"`,
)
.send(stream);
},
);
/**
* GET /api/v1/files/:id/thumbnail
*
* Generate and return a 300px-wide JPEG thumbnail on the fly via Sharp.
*/
app.get(
"/api/v1/files/:id/thumbnail",
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const { id } = request.params;
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all"))) {
return reply.status(404).send({ error: "File not found" });
}
// Serve from disk cache if available
const cached = await getCachedThumbnail(file.storedName);
if (cached) {
return reply
.header("Content-Type", "image/jpeg")
.header("Cache-Control", "public, max-age=86400, immutable")
.send(cached);
}
const filePath = getStoredFilePath(file.storedName);
try {
const rawBuffer = await readFile(filePath);
const validation = await validateImageBuffer(rawBuffer, file.originalName);
let decoded: Buffer<ArrayBuffer> = Buffer.from(rawBuffer);
if (validation.valid && validation.format === "heif") {
decoded = Buffer.from(await decodeHeic(rawBuffer));
} else if (validation.valid && needsCliDecode(validation.format)) {
try {
const fileExt = file.originalName.split(".").pop()?.toLowerCase();
decoded = Buffer.from(await decodeToSharpCompat(rawBuffer, validation.format, fileExt));
} catch {
// Sharp will attempt the raw buffer directly
}
}
const fileBuffer = decoded;
const thumbnail = await sharp(fileBuffer)
.resize(300, null, { withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
// Cache to disk (non-blocking, don't fail the request)
saveThumbnail(file.storedName, thumbnail).catch(() => {});
return reply
.header("Content-Type", "image/jpeg")
.header("Cache-Control", "public, max-age=86400, immutable")
.send(thumbnail);
} catch {
return reply.status(422).send({ error: "Could not generate thumbnail" });
}
},
);
/**
* DELETE /api/v1/files
*
* Bulk delete. Body: { ids: string[] }
* For each id, deletes the entire version chain (all ancestors and descendants).
*/
app.delete("/api/v1/files", async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const deleteSchema = z.object({
ids: z.array(z.string()).min(1, "ids must be a non-empty array of strings"),
});
const parsed = deleteSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.issues.map((i) => i.message).join("; "),
});
}
const { ids } = parsed.data;
let deletedCount = 0;
interface DeleteChainRow {
id: string;
stored_name: string;
}
for (const id of ids) {
// Ownership check: non-admin users can only delete their own files
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
if (!file || (file.userId !== user.id && !hasEffectivePermission(user, "files:all")))
continue;
// Collect all files in the chain using a recursive CTE
const chainRows = sqlite
.prepare(`
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 = ?
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
`)
.all(id) as DeleteChainRow[];
for (const row of chainRows) {
await deleteStoredFile(row.stored_name);
await deleteThumbnail(row.stored_name);
db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id)).run();
deletedCount++;
}
}
auditLog(request.log, "FILE_DELETED", { userId: user.id, count: deletedCount, ids });
return reply.send({ deleted: deletedCount });
});
/**
* POST /api/v1/files/save-result
*
* Save the output of a tool as a new version linked to a parent file.
* Multipart fields:
* file — the processed image
* parentId — id of the parent user file record
* toolId — the tool that produced this result
*/
app.post("/api/v1/files/save-result", async (request: FastifyRequest, reply: FastifyReply) => {
const user = getAuthUser(request);
const userId = user?.id ?? null;
// Enforce per-user storage quota before saving results
try {
checkStorageQuota(userId);
} catch (err) {
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
return reply.status(statusCode).send({ error: (err as Error).message });
}
let fileBuffer: Buffer | null = null;
let filename = "result";
let parentId: string | null = null;
let toolId: string | null = null;
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "result");
} else if (part.fieldname === "parentId") {
parentId = (part.value as string).trim() || null;
} else if (part.fieldname === "toolId") {
toolId = (part.value as string).trim() || null;
}
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No file provided" });
}
if (!parentId) {
return reply.status(400).send({ error: "parentId is required" });
}
// Validate the image
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid file: ${validation.reason}`,
});
}
// Look up the parent to compute the next version and carry forward the tool chain
const parent = db
.select()
.from(schema.userFiles)
.where(eq(schema.userFiles.id, parentId))
.get();
if (!parent) {
return reply.status(404).send({ error: "Parent file not found" });
}
const nextVersion = parent.version + 1;
// Build the tool chain: append the new toolId to the parent's chain
const existingChain: string[] = parent.toolChain ? JSON.parse(parent.toolChain) : [];
const newChain = toolId ? [...existingChain, toolId] : existingChain;
// Determine the original filename (preserve parent's name, update extension)
const ext = extname(filename) || extname(parent.originalName);
const baseName = parent.originalName.replace(/\.[^.]+$/, "");
const resultName = `${baseName}${ext}`;
const mimeType = formatToMime(validation.format) || extToMime(ext);
// Sanitize SVG results to prevent XXE, SSRF, and script injection
const safeResultBuffer = isSvgBuffer(fileBuffer) ? sanitizeSvg(fileBuffer) : fileBuffer;
// Persist to disk
const storedName = await saveFile(safeResultBuffer, resultName);
// Create DB record
const id = randomUUID();
try {
db.insert(schema.userFiles)
.values({
id,
userId,
originalName: resultName,
storedName,
mimeType,
size: safeResultBuffer.length,
width: validation.width,
height: validation.height,
version: nextVersion,
parentId,
toolChain: JSON.stringify(newChain),
})
.run();
} catch {
return reply.status(409).send({ error: "Failed to save result record" });
}
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
return reply.status(201).send({ file: row ? serializeFile(row) : null });
});
app.log.info("User file routes registered");
}