mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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).
This commit is contained in:
@@ -15,19 +15,19 @@
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/multipart": "^9.0.0",
|
||||
"@fastify/rate-limit": "^10.2.0",
|
||||
"@fastify/static": "^8.1.0",
|
||||
"@fastify/static": "^9.1.0",
|
||||
"@neplex/vectorizer": "^0.0.5",
|
||||
"@scalar/fastify-api-reference": "^1.49.5",
|
||||
"@sentry/node": "^10.49.0",
|
||||
"@snapotter/ai": "workspace:*",
|
||||
"@snapotter/image-engine": "workspace:*",
|
||||
"@snapotter/shared": "workspace:*",
|
||||
"archiver": "^7.0.1",
|
||||
"archiver": "^8.0.0",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"exif-reader": "^2.0.3",
|
||||
"fastify": "^5.2.0",
|
||||
"fastify": "^5.8.5",
|
||||
"fflate": "^0.8.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"mupdf": "^1.27.0",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@types/pdfkit": "^0.17.5",
|
||||
"@types/potrace": "^2.1.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"license": "AGPL-3.0"
|
||||
|
||||
+7
-10
@@ -111,11 +111,9 @@ app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) =>
|
||||
if (statusCode >= 500) {
|
||||
captureException(error, request);
|
||||
}
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
reply.status(statusCode).send({
|
||||
error: statusCode >= 500 ? "Internal server error" : error.message,
|
||||
...(statusCode < 500 && { details: error.message }),
|
||||
...(!isProduction && statusCode >= 500 && { details: error.stack ?? error.message }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,24 +124,23 @@ await app.register(cors, {
|
||||
: process.env.NODE_ENV !== "production",
|
||||
});
|
||||
|
||||
// Security headers
|
||||
// Security headers -- applied in all environments. HSTS is ignored over plain
|
||||
// HTTP so it is safe (and desirable) to send it in dev/staging too. CSP catches
|
||||
// injection issues early when applied during development.
|
||||
app.addHook("onSend", async (_request, reply) => {
|
||||
reply.header("X-Content-Type-Options", "nosniff");
|
||||
reply.header("X-Frame-Options", "DENY");
|
||||
reply.header("X-XSS-Protection", "0");
|
||||
reply.header("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
reply.header("Content-Security-Policy", buildCsp(_request.url.startsWith("/api/docs")));
|
||||
}
|
||||
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
reply.header("Content-Security-Policy", buildCsp(_request.url.startsWith("/api/docs")));
|
||||
});
|
||||
|
||||
// Always register rate-limit plugin so per-route limits (login brute-force protection) work.
|
||||
// When RATE_LIMIT_PER_MIN=0, the global limit is set high enough to be effectively unlimited
|
||||
// while still enabling per-route overrides like the login endpoint.
|
||||
// RATE_LIMIT_PER_MIN defaults to 300 via env schema; floor at 1 as a safety net.
|
||||
await app.register(rateLimit, {
|
||||
max: env.RATE_LIMIT_PER_MIN > 0 ? env.RATE_LIMIT_PER_MIN : 50000,
|
||||
max: Math.max(env.RATE_LIMIT_PER_MIN, 1),
|
||||
timeWindow: "1 minute",
|
||||
allowList: (request) => !request.url.startsWith("/api/"),
|
||||
});
|
||||
|
||||
@@ -2,6 +2,16 @@ const POSTHOG_ORIGINS = ["https://us.i.posthog.com", "https://us-assets.i.postho
|
||||
const SENTRY_ORIGINS = ["https://*.ingest.us.sentry.io"];
|
||||
const SCALAR_FONT_ORIGIN = "https://fonts.scalar.com";
|
||||
|
||||
/**
|
||||
* Build a Content-Security-Policy header value.
|
||||
*
|
||||
* Notes on 'unsafe-inline':
|
||||
* - style-src: Required because the React SPA uses inline styles extensively
|
||||
* (100+ occurrences across 45+ components). Removing it would break the UI.
|
||||
* - script-src (docs only): The @scalar/fastify-api-reference plugin injects
|
||||
* inline scripts for its interactive API reference UI. A nonce-based approach
|
||||
* would require forking the Scalar plugin, which is not practical.
|
||||
*/
|
||||
export function buildCsp(isDocs: boolean): string {
|
||||
const connectSrc = ["'self'", "data:", ...POSTHOG_ORIGINS, ...SENTRY_ORIGINS].join(" ");
|
||||
const fontSrc = isDocs ? `'self' data: ${SCALAR_FONT_ORIGIN}` : "'self' data:";
|
||||
|
||||
@@ -20,7 +20,7 @@ const envSchema = z.object({
|
||||
MAX_BATCH_SIZE: z.coerce.number().default(0),
|
||||
CONCURRENT_JOBS: z.coerce.number().default(0),
|
||||
MAX_MEGAPIXELS: z.coerce.number().default(0),
|
||||
RATE_LIMIT_PER_MIN: z.coerce.number().default(0),
|
||||
RATE_LIMIT_PER_MIN: z.coerce.number().default(1000),
|
||||
DB_PATH: z.string().default("./data/snapotter.db"),
|
||||
FILES_STORAGE_PATH: z.string().default("./data/files"),
|
||||
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
|
||||
@@ -35,9 +35,11 @@ const envSchema = z.object({
|
||||
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
|
||||
MAX_SVG_SIZE_MB: z.coerce.number().default(0),
|
||||
MAX_SPLIT_GRID: z.coerce.number().default(100),
|
||||
MAX_STORAGE_PER_USER_MB: z.coerce.number().default(5000),
|
||||
MAX_WORKSPACE_SIZE_GB: z.coerce.number().default(10),
|
||||
MAX_PDF_PAGES: z.coerce.number().default(0),
|
||||
SESSION_DURATION_HOURS: z.coerce.number().default(168),
|
||||
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(500),
|
||||
LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(30),
|
||||
TRUST_PROXY: z
|
||||
.enum(["true", "false"])
|
||||
.default("true")
|
||||
|
||||
@@ -5,3 +5,11 @@ export function formatZodErrors(issues: ZodIssue[]): string {
|
||||
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip internal filesystem paths from error messages to avoid
|
||||
* leaking server directory structure to API consumers.
|
||||
*/
|
||||
export function stripInternalPaths(message: string): string {
|
||||
return message.replace(/\/(tmp|data|app|opt|home|workspace)\b[^\s'")}]*/g, "[internal]");
|
||||
}
|
||||
|
||||
@@ -4,9 +4,43 @@ import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { extname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { stripInternalPaths } from "./errors.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Maximum character length for any single ExifTool tag value. */
|
||||
const MAX_TAG_VALUE_LENGTH = 10_000;
|
||||
|
||||
/** Allowed pattern for ExifTool tag names (alphanumeric, colon, underscore, hyphen). */
|
||||
const TAG_NAME_PATTERN = /^[a-zA-Z0-9:_-]+$/;
|
||||
|
||||
/**
|
||||
* Validate and sanitize a tag value: strip null bytes, enforce length limit.
|
||||
* Returns the sanitized value or throws if it exceeds the length limit.
|
||||
*/
|
||||
export function sanitizeTagValue(value: string, tagName: string): string {
|
||||
// Strip null bytes
|
||||
const cleaned = value.replace(/\0/g, "");
|
||||
if (cleaned.length > MAX_TAG_VALUE_LENGTH) {
|
||||
throw new Error(
|
||||
`Tag value for "${tagName}" exceeds maximum length of ${MAX_TAG_VALUE_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a tag name against the allowed pattern.
|
||||
* Throws if the name contains invalid characters.
|
||||
*/
|
||||
export function validateTagName(name: string): void {
|
||||
if (!TAG_NAME_PATTERN.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid tag name "${name}": only alphanumeric, colon, underscore, and hyphen are allowed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Grouped metadata returned by ExifTool -json -G */
|
||||
export interface ExifToolMetadata {
|
||||
[group: string]: Record<string, unknown>;
|
||||
@@ -102,6 +136,9 @@ export async function inspectMetadata(buffer: Buffer, filename: string): Promise
|
||||
gps: Object.keys(gps).length > 0 ? gps : null,
|
||||
keywords: uniqueKeywords,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(stripInternalPaths(message));
|
||||
} finally {
|
||||
await rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
@@ -130,6 +167,9 @@ export async function writeMetadata(
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return await readFile(tempPath);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(stripInternalPaths(message));
|
||||
} finally {
|
||||
await rm(tempPath, { force: true }).catch(() => {});
|
||||
}
|
||||
@@ -191,35 +231,42 @@ export interface EditMetadataSettings {
|
||||
|
||||
/**
|
||||
* Convert settings object into ExifTool CLI tag arguments.
|
||||
* All tag values are sanitized (null bytes stripped, length limited).
|
||||
* All tag names in fieldsToRemove are validated against an allowed pattern.
|
||||
*/
|
||||
export function buildTagArgs(settings: EditMetadataSettings): string[] {
|
||||
const args: string[] = [];
|
||||
|
||||
/** Helper: sanitize a string value before adding as a tag argument. */
|
||||
const s = (value: string, tagName: string): string => sanitizeTagValue(value, tagName);
|
||||
|
||||
// Common aliases
|
||||
const artist = settings.artist || settings.author;
|
||||
const description = settings.imageDescription || settings.title;
|
||||
|
||||
// Basic EXIF fields
|
||||
if (artist) args.push(`-Artist=${artist}`);
|
||||
if (settings.copyright) args.push(`-Copyright=${settings.copyright}`);
|
||||
if (description) args.push(`-ImageDescription=${description}`);
|
||||
if (settings.software) args.push(`-Software=${settings.software}`);
|
||||
if (settings.title) args.push(`-XMP:Title=${settings.title}`);
|
||||
if (artist) args.push(`-Artist=${s(artist, "Artist")}`);
|
||||
if (settings.copyright) args.push(`-Copyright=${s(settings.copyright, "Copyright")}`);
|
||||
if (description) args.push(`-ImageDescription=${s(description, "ImageDescription")}`);
|
||||
if (settings.software) args.push(`-Software=${s(settings.software, "Software")}`);
|
||||
if (settings.title) args.push(`-XMP:Title=${s(settings.title, "XMP:Title")}`);
|
||||
|
||||
// Date fields
|
||||
if (settings.dateTime) args.push(`-ModifyDate=${settings.dateTime}`);
|
||||
if (settings.dateTimeOriginal) args.push(`-DateTimeOriginal=${settings.dateTimeOriginal}`);
|
||||
if (settings.dateTime) args.push(`-ModifyDate=${s(settings.dateTime, "ModifyDate")}`);
|
||||
if (settings.dateTimeOriginal)
|
||||
args.push(`-DateTimeOriginal=${s(settings.dateTimeOriginal, "DateTimeOriginal")}`);
|
||||
|
||||
// Date shift (applies to all date fields)
|
||||
if (settings.dateShift) {
|
||||
const direction = settings.dateShift.startsWith("-") ? "-" : "+";
|
||||
const value = settings.dateShift.replace(/^[+-]/, "");
|
||||
const cleaned = s(settings.dateShift, "dateShift");
|
||||
const direction = cleaned.startsWith("-") ? "-" : "+";
|
||||
const value = cleaned.replace(/^[+-]/, "");
|
||||
args.push(`-AllDates${direction}=0:0:0 ${value}:0`);
|
||||
}
|
||||
|
||||
// Set all dates to a specific value
|
||||
if (settings.setAllDates) {
|
||||
args.push(`-AllDates=${settings.setAllDates}`);
|
||||
args.push(`-AllDates=${s(settings.setAllDates, "setAllDates")}`);
|
||||
}
|
||||
|
||||
// GPS coordinates
|
||||
@@ -248,26 +295,31 @@ export function buildTagArgs(settings: EditMetadataSettings): string[] {
|
||||
args.push("-XMP:Subject=");
|
||||
}
|
||||
for (const kw of settings.keywords) {
|
||||
if (kw.trim()) {
|
||||
args.push(`-IPTC:Keywords+=${kw.trim()}`);
|
||||
args.push(`-XMP:Subject+=${kw.trim()}`);
|
||||
const trimmed = s(kw, "keyword").trim();
|
||||
if (trimmed) {
|
||||
args.push(`-IPTC:Keywords+=${trimmed}`);
|
||||
args.push(`-XMP:Subject+=${trimmed}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IPTC fields
|
||||
if (settings.iptcTitle) args.push(`-IPTC:ObjectName=${settings.iptcTitle}`);
|
||||
if (settings.iptcHeadline) args.push(`-IPTC:Headline=${settings.iptcHeadline}`);
|
||||
if (settings.iptcCity) args.push(`-IPTC:City=${settings.iptcCity}`);
|
||||
if (settings.iptcState) args.push(`-IPTC:Province-State=${settings.iptcState}`);
|
||||
if (settings.iptcCountry) args.push(`-IPTC:Country-PrimaryLocationName=${settings.iptcCountry}`);
|
||||
if (settings.iptcTitle) args.push(`-IPTC:ObjectName=${s(settings.iptcTitle, "IPTC:ObjectName")}`);
|
||||
if (settings.iptcHeadline)
|
||||
args.push(`-IPTC:Headline=${s(settings.iptcHeadline, "IPTC:Headline")}`);
|
||||
if (settings.iptcCity) args.push(`-IPTC:City=${s(settings.iptcCity, "IPTC:City")}`);
|
||||
if (settings.iptcState)
|
||||
args.push(`-IPTC:Province-State=${s(settings.iptcState, "IPTC:Province-State")}`);
|
||||
if (settings.iptcCountry)
|
||||
args.push(
|
||||
`-IPTC:Country-PrimaryLocationName=${s(settings.iptcCountry, "IPTC:Country-PrimaryLocationName")}`,
|
||||
);
|
||||
|
||||
// Field removal -- only allow safe EXIF/IPTC/XMP tag names (alphanumeric, colon, hyphen)
|
||||
// Field removal -- validate tag names against allowed pattern
|
||||
if (settings.fieldsToRemove && settings.fieldsToRemove.length > 0) {
|
||||
for (const field of settings.fieldsToRemove) {
|
||||
if (/^[A-Za-z0-9:_-]+$/.test(field)) {
|
||||
args.push(`-${field}=`);
|
||||
}
|
||||
validateTagName(field);
|
||||
args.push(`-${field}=`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, statfs, unlink, writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { env } from "../config.js";
|
||||
|
||||
/** Minimum free disk space (100 MB) before refusing writes. */
|
||||
const MIN_FREE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Check available disk space and throw 507 if below threshold.
|
||||
*/
|
||||
async function assertDiskSpace(dir: string): Promise<void> {
|
||||
try {
|
||||
const stats = await statfs(dir);
|
||||
const freeBytes = stats.bfree * stats.bsize;
|
||||
if (freeBytes < MIN_FREE_BYTES) {
|
||||
const err = new Error("Insufficient disk space") as Error & { statusCode: number };
|
||||
err.statusCode = 507;
|
||||
throw err;
|
||||
}
|
||||
} catch (e) {
|
||||
// Re-throw our own 507 errors; swallow statfs failures (e.g. unsupported OS)
|
||||
if (e instanceof Error && (e as Error & { statusCode?: number }).statusCode === 507) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_STORAGE_EXTENSIONS = new Set([
|
||||
".jpg",
|
||||
".jpeg",
|
||||
@@ -41,6 +62,7 @@ export async function ensureStorageDir(): Promise<void> {
|
||||
|
||||
export async function saveFile(buffer: Buffer, originalName: string): Promise<string> {
|
||||
await ensureStorageDir();
|
||||
await assertDiskSpace(env.FILES_STORAGE_PATH);
|
||||
let ext = extname(originalName).toLowerCase() || ".bin";
|
||||
// Only allow known image extensions to be stored — reject dangerous extensions
|
||||
// even if they somehow pass upstream sanitization.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { open, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -8,6 +9,19 @@ import sharp from "sharp";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Write a buffer to a temp file exclusively (O_CREAT | O_EXCL | O_WRONLY).
|
||||
* Prevents symlink / race-condition attacks on predictable temp paths.
|
||||
*/
|
||||
async function writeTempExclusive(filePath: string, buffer: Buffer): Promise<void> {
|
||||
const fh = await open(filePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
||||
try {
|
||||
await fh.writeFile(buffer);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats that need external CLI tools (not decodable by Sharp). */
|
||||
const CLI_DECODED_FORMATS = new Set([
|
||||
"raw",
|
||||
@@ -102,7 +116,7 @@ export async function decodeAnyFormat(buffer: Buffer, format: string): Promise<B
|
||||
const outputPath = join(tmpdir(), `any-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(
|
||||
cmd,
|
||||
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
|
||||
@@ -146,7 +160,7 @@ async function decodeIco(buffer: Buffer): Promise<Buffer> {
|
||||
const outputPath = join(tmpdir(), `ico-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
// ICO contains multiple sizes; extract the largest by sorting
|
||||
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[-1]`, `png:${outputPath}`]), {
|
||||
timeout: 120_000,
|
||||
@@ -176,7 +190,7 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
|
||||
const outputPath = join(tmpdir(), `raw-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
|
||||
// Attempt 1: ExifTool embedded JPEG extraction (fast path)
|
||||
try {
|
||||
@@ -223,7 +237,7 @@ async function decodePsd(buffer: Buffer): Promise<Buffer> {
|
||||
const outputPath = join(tmpdir(), `psd-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), {
|
||||
timeout: 120_000,
|
||||
});
|
||||
@@ -244,7 +258,7 @@ async function decodeTga(buffer: Buffer): Promise<Buffer> {
|
||||
const outputPath = join(tmpdir(), `tga-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
|
||||
timeout: 120_000,
|
||||
});
|
||||
@@ -265,7 +279,7 @@ async function decodeExr(buffer: Buffer): Promise<Buffer> {
|
||||
const outputPath = join(tmpdir(), `exr-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
|
||||
// ImageMagick needs the OpenEXR delegate which is often missing on macOS
|
||||
try {
|
||||
@@ -302,7 +316,7 @@ async function decodeHdr(buffer: Buffer): Promise<Buffer> {
|
||||
const outputPath = join(tmpdir(), `hdr-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(
|
||||
cmd,
|
||||
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", "-depth", "8", `png:${outputPath}`]),
|
||||
@@ -322,7 +336,7 @@ async function decodeBmp(buffer: Buffer): Promise<Buffer> {
|
||||
const outputPath = join(tmpdir(), `bmp-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
|
||||
timeout: 120_000,
|
||||
});
|
||||
@@ -339,7 +353,7 @@ async function decodeJxl(buffer: Buffer): Promise<Buffer> {
|
||||
const outputPath = join(tmpdir(), `jxl-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
|
||||
// Try djxl first (from libjxl-tools) — works even when ImageMagick
|
||||
// lacks a JXL delegate (common on Ubuntu stock packages).
|
||||
@@ -368,7 +382,7 @@ async function decodeJp2(buffer: Buffer): Promise<Buffer> {
|
||||
const inputPath = join(tmpdir(), `jp2-in-${id}.jp2`);
|
||||
const outputPath = join(tmpdir(), `jp2-out-${id}.png`);
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
try {
|
||||
await execFileAsync("opj_decompress", ["-i", inputPath, "-o", outputPath], {
|
||||
timeout: 60_000,
|
||||
@@ -403,7 +417,7 @@ async function decodeEps(buffer: Buffer): Promise<Buffer> {
|
||||
const inputPath = join(tmpdir(), `eps-in-${id}.eps`);
|
||||
const outputPath = join(tmpdir(), `eps-out-${id}.png`);
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(
|
||||
cmd,
|
||||
magickArgs(cmd, [
|
||||
@@ -433,7 +447,7 @@ async function decodeDds(buffer: Buffer): Promise<Buffer> {
|
||||
const inputPath = join(tmpdir(), `dds-in-${id}.dds`);
|
||||
const outputPath = join(tmpdir(), `dds-out-${id}.png`);
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), {
|
||||
timeout: 120_000,
|
||||
});
|
||||
@@ -452,7 +466,7 @@ async function decodeDpx(buffer: Buffer): Promise<Buffer> {
|
||||
const inputPath = join(tmpdir(), `dpx-in-${id}.dpx`);
|
||||
const outputPath = join(tmpdir(), `dpx-out-${id}.png`);
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(
|
||||
cmd,
|
||||
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
|
||||
@@ -473,7 +487,7 @@ async function decodeFits(buffer: Buffer): Promise<Buffer> {
|
||||
const inputPath = join(tmpdir(), `fits-in-${id}.fits`);
|
||||
const outputPath = join(tmpdir(), `fits-out-${id}.png`);
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(
|
||||
cmd,
|
||||
magickArgs(cmd, [
|
||||
@@ -516,7 +530,7 @@ async function decodeNetpbm(buffer: Buffer, format: string): Promise<Buffer> {
|
||||
const inputPath = join(tmpdir(), `netpbm-in-${id}.${ext}`);
|
||||
const outputPath = join(tmpdir(), `netpbm-out-${id}.png`);
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { open, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Write a buffer to a temp file exclusively (O_CREAT | O_EXCL | O_WRONLY).
|
||||
* Prevents symlink / race-condition attacks on predictable temp paths.
|
||||
*/
|
||||
async function writeTempExclusive(filePath: string, buffer: Buffer): Promise<void> {
|
||||
const fh = await open(filePath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
|
||||
try {
|
||||
await fh.writeFile(buffer);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the HEIF decode command. Both heif-convert and heif-dec accept
|
||||
* `<input> <output>` positional arguments.
|
||||
@@ -44,7 +58,7 @@ export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
|
||||
const suffixedPath = outputPath.replace(/\.png$/, "-1.png");
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync(cmd, [inputPath, outputPath], { timeout: 120_000 });
|
||||
|
||||
// Single-image HEIF: exact filename. Multi-image: -1 suffix on first image.
|
||||
@@ -92,7 +106,7 @@ export async function encodeHeic(buffer: Buffer, quality = 80): Promise<Buffer>
|
||||
const outputPath = join(tmpdir(), `heic-out-${id}.heic`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await writeTempExclusive(inputPath, buffer);
|
||||
await execFileAsync("heif-enc", ["-q", String(quality), "-o", outputPath, inputPath], {
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
+112
-8
@@ -1,4 +1,6 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
@@ -25,6 +27,10 @@ function isPrivateIPv6(ip: string): boolean {
|
||||
if (normalized.startsWith("fe80:")) return true;
|
||||
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
|
||||
if (normalized.startsWith("2001:db8:")) return true;
|
||||
// 6to4 addresses can encapsulate private IPv4 addresses
|
||||
if (normalized.startsWith("2002:")) return true;
|
||||
// NAT64 prefix maps to IPv4 -- block to prevent SSRF via IPv4-mapped addresses
|
||||
if (normalized.startsWith("64:ff9b:")) return true;
|
||||
if (normalized.includes("::ffff:")) {
|
||||
const v4 = normalized.split("::ffff:")[1];
|
||||
if (v4 && isPrivateIPv4(v4)) return true;
|
||||
@@ -32,13 +38,18 @@ function isPrivateIPv6(ip: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveAndCheck(hostname: string): Promise<void> {
|
||||
/**
|
||||
* Resolve a hostname and validate all returned IPs are public.
|
||||
* Returns the first valid resolved IP so callers can pin it for the actual
|
||||
* connection, preventing DNS rebinding (TOCTOU) attacks.
|
||||
*/
|
||||
async function resolveAndCheck(hostname: string): Promise<string> {
|
||||
const bare = hostname.replace(/^\[|]$/g, "");
|
||||
if (isIP(bare)) {
|
||||
if (isPrivateIPv4(bare) || isPrivateIPv6(bare)) {
|
||||
throw new Error("URL resolves to a private or reserved IP address");
|
||||
}
|
||||
return;
|
||||
return bare;
|
||||
}
|
||||
|
||||
const result = await lookup(hostname, { all: true });
|
||||
@@ -49,9 +60,15 @@ async function resolveAndCheck(hostname: string): Promise<void> {
|
||||
throw new Error("URL resolves to a private or reserved IP address");
|
||||
}
|
||||
}
|
||||
return addresses[0].address;
|
||||
}
|
||||
|
||||
export async function validateFetchUrl(url: string): Promise<void> {
|
||||
/**
|
||||
* Validate that a URL points to a public address and return the pinned IP.
|
||||
* The resolved IP should be used for the actual connection to prevent DNS
|
||||
* rebinding between validation and fetch.
|
||||
*/
|
||||
export async function validateFetchUrl(url: string): Promise<{ resolvedIp: string }> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
@@ -63,7 +80,8 @@ export async function validateFetchUrl(url: string): Promise<void> {
|
||||
throw new Error("Only HTTP and HTTPS URLs are supported");
|
||||
}
|
||||
|
||||
await resolveAndCheck(parsed.hostname);
|
||||
const resolvedIp = await resolveAndCheck(parsed.hostname);
|
||||
return { resolvedIp };
|
||||
}
|
||||
|
||||
export const MAX_REDIRECTS = 5;
|
||||
@@ -72,21 +90,107 @@ export const MAX_URL_FETCH_SIZE = 50 * 1024 * 1024;
|
||||
export const MAX_URLS_PER_REQUEST = 50;
|
||||
export const URL_FETCH_CONCURRENCY = 4;
|
||||
|
||||
/**
|
||||
* Create an HTTP(S) agent that pins DNS resolution to a specific IP address.
|
||||
* This prevents DNS rebinding attacks where a hostname resolves to a different
|
||||
* (private) IP between our SSRF validation and the actual connection.
|
||||
*/
|
||||
function createPinnedAgent(resolvedIp: string, protocol: string): http.Agent | https.Agent {
|
||||
const pinnedLookup: (
|
||||
hostname: string,
|
||||
options: object,
|
||||
callback: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,
|
||||
) => void = (_hostname, _options, callback) => {
|
||||
const family = resolvedIp.includes(":") ? 6 : 4;
|
||||
callback(null, resolvedIp, family);
|
||||
};
|
||||
|
||||
if (protocol === "https:") {
|
||||
return new https.Agent({ lookup: pinnedLookup as never, maxSockets: 1 });
|
||||
}
|
||||
return new http.Agent({ lookup: pinnedLookup as never, maxSockets: 1 });
|
||||
}
|
||||
|
||||
export async function safeFetch(url: string, signal?: AbortSignal): Promise<Response> {
|
||||
let currentUrl = url;
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
await validateFetchUrl(currentUrl);
|
||||
const res = await fetch(currentUrl, {
|
||||
const { resolvedIp } = await validateFetchUrl(currentUrl);
|
||||
const parsed = new URL(currentUrl);
|
||||
const agent = createPinnedAgent(resolvedIp, parsed.protocol);
|
||||
|
||||
// Use the Node.js fetch dispatcher option for IP pinning.
|
||||
// Replace hostname with the resolved IP for HTTP; for HTTPS, use
|
||||
// the pinned agent to maintain SNI with the original hostname.
|
||||
let fetchUrl = currentUrl;
|
||||
if (parsed.protocol === "http:") {
|
||||
// For HTTP, replace hostname directly -- no TLS/SNI concerns
|
||||
const pinnedUrl = new URL(currentUrl);
|
||||
pinnedUrl.hostname = resolvedIp.includes(":") ? `[${resolvedIp}]` : resolvedIp;
|
||||
fetchUrl = pinnedUrl.href;
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit & { agent?: http.Agent | https.Agent } = {
|
||||
signal,
|
||||
redirect: "manual",
|
||||
headers: { "User-Agent": "SnapOtter/1.0 (image-fetch)" },
|
||||
});
|
||||
headers: {
|
||||
"User-Agent": "SnapOtter/1.0 (image-fetch)",
|
||||
Host: parsed.host,
|
||||
},
|
||||
};
|
||||
|
||||
// Node.js undici-based fetch does not support the `agent` option directly.
|
||||
// For HTTP we use the IP-replaced URL. For HTTPS we use the pinned agent
|
||||
// via the Node.js http/https request internals by importing from node:https.
|
||||
let res: Response;
|
||||
if (parsed.protocol === "https:") {
|
||||
// For HTTPS, use node:https with the pinned agent and original hostname for SNI
|
||||
res = await new Promise<Response>((resolve, reject) => {
|
||||
const req = https.request(
|
||||
currentUrl,
|
||||
{
|
||||
agent,
|
||||
signal: signal ?? undefined,
|
||||
headers: {
|
||||
"User-Agent": "SnapOtter/1.0 (image-fetch)",
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
(incomingMessage) => {
|
||||
const chunks: Buffer[] = [];
|
||||
incomingMessage.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
incomingMessage.on("end", () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(incomingMessage.headers)) {
|
||||
if (value) {
|
||||
const vals = Array.isArray(value) ? value : [value];
|
||||
for (const v of vals) headers.append(key, v);
|
||||
}
|
||||
}
|
||||
resolve(
|
||||
new Response(body, {
|
||||
status: incomingMessage.statusCode ?? 500,
|
||||
statusText: incomingMessage.statusMessage ?? "",
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
});
|
||||
incomingMessage.on("error", reject);
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
} else {
|
||||
res = await fetch(fetchUrl, fetchOptions);
|
||||
}
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const location = res.headers.get("location");
|
||||
if (!location) throw new Error("Redirect without Location header");
|
||||
await res.body?.cancel();
|
||||
currentUrl = new URL(location, currentUrl).href;
|
||||
agent.destroy();
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { env } from "../config.js";
|
||||
|
||||
/**
|
||||
* Decode common HTML/XML numeric character references (&#xNN; and &#NNN;)
|
||||
* so that obfuscated `javascript:` / `data:` URIs are caught by later regex passes.
|
||||
*/
|
||||
function decodeNumericEntities(input: string): string {
|
||||
return input
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_m, dec) => String.fromCharCode(Number.parseInt(dec, 10)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an SVG buffer to prevent XXE, SSRF, and script injection.
|
||||
* Throws if the SVG exceeds the maximum allowed size.
|
||||
@@ -11,6 +21,13 @@ export function sanitizeSvg(buffer: Buffer): Buffer {
|
||||
throw new Error(`SVG exceeds maximum size of ${env.MAX_SVG_SIZE_MB}MB`);
|
||||
}
|
||||
let svg = buffer.toString("utf-8");
|
||||
|
||||
// ── Pre-processing: strip CDATA sections and decode numeric entities ──
|
||||
// CDATA sections can hide script content from regex-based checks.
|
||||
svg = svg.replace(/<!\[CDATA\[[\s\S]*?\]\]>/gi, "");
|
||||
// Decode numeric entities so obfuscated URIs (e.g. javascript:) are visible.
|
||||
svg = decodeNumericEntities(svg);
|
||||
|
||||
// Remove DOCTYPE (XXE prevention, including internal subsets)
|
||||
svg = svg.replace(/<!DOCTYPE[^>[]*(?:\[[^\]]*\])?>/gi, "");
|
||||
// Remove XML processing instructions except <?xml version...?>
|
||||
@@ -18,22 +35,49 @@ export function sanitizeSvg(buffer: Buffer): Buffer {
|
||||
// Remove XInclude elements and namespace declarations
|
||||
svg = svg.replace(/<[^>]*xi:include[^>]*\/?>/gi, "");
|
||||
svg = svg.replace(/xmlns:xi\s*=\s*["'][^"']*["']/gi, "");
|
||||
// Remove script tags
|
||||
|
||||
// ── Strip dangerous elements ──
|
||||
// Remove script tags (including nested inside <svg>)
|
||||
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, "");
|
||||
svg = svg.replace(/<script[^>]*\/>/gi, "");
|
||||
// Remove foreignObject elements (can embed arbitrary HTML)
|
||||
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "");
|
||||
svg = svg.replace(/<foreignObject[^>]*\/>/gi, "");
|
||||
// Remove iframe elements (non-SVG, can load external content)
|
||||
svg = svg.replace(/<iframe[\s\S]*?<\/iframe>/gi, "");
|
||||
svg = svg.replace(/<iframe[^>]*\/>/gi, "");
|
||||
// Remove embed elements (non-SVG, can load external content)
|
||||
svg = svg.replace(/<embed[\s\S]*?<\/embed>/gi, "");
|
||||
svg = svg.replace(/<embed[^>]*\/>/gi, "");
|
||||
// Remove <set> elements (can inject attributes/URIs at runtime)
|
||||
svg = svg.replace(/<set[\s\S]*?<\/set>/gi, "");
|
||||
svg = svg.replace(/<set\b[^>]*\/>/gi, "");
|
||||
// Remove <animate> elements (can inject attributes/URIs at runtime)
|
||||
svg = svg.replace(/<animate[\s\S]*?<\/animate>/gi, "");
|
||||
svg = svg.replace(/<animate\b[^>]*\/>/gi, "");
|
||||
|
||||
// Remove event handlers (onload, onclick, onerror, etc.)
|
||||
svg = svg.replace(/\bon\w+\s*=/gi, "data-removed=");
|
||||
// Block dangerous URI schemes in href attributes
|
||||
// Replace both the attribute name and its value to prevent residual payloads.
|
||||
svg = svg.replace(/\bon\w+\s*=\s*["'][^"']*["']/gi, 'data-removed=""');
|
||||
svg = svg.replace(/\bon\w+\s*=\s*\S+/gi, 'data-removed=""');
|
||||
|
||||
// ── Block <use> with external href (before generic href blocking) ──
|
||||
svg = svg.replace(/<use\b[^>]*href\s*=\s*["']https?:\/\/[^"']*["'][^>]*\/?>/gi, "");
|
||||
svg = svg.replace(/<use\b[^>]*xlink:href\s*=\s*["']https?:\/\/[^"']*["'][^>]*\/?>/gi, "");
|
||||
|
||||
// ── Block dangerous URI schemes in href attributes ──
|
||||
svg = svg.replace(/xlink:href\s*=\s*["']https?:\/\//gi, 'xlink:href="data:,');
|
||||
svg = svg.replace(/href\s*=\s*["']https?:\/\//gi, 'href="data:,');
|
||||
svg = svg.replace(/href\s*=\s*["']javascript:/gi, 'href="data:,');
|
||||
svg = svg.replace(/href\s*=\s*["']data:text\/html/gi, 'href="data:,');
|
||||
// Block ALL data: URIs in href (not just data:text/html)
|
||||
svg = svg.replace(/href\s*=\s*["']data:/gi, 'href="data:,');
|
||||
svg = svg.replace(/href\s*=\s*["']file:/gi, 'href="data:,');
|
||||
// Block use elements referencing external resources
|
||||
|
||||
// ── Block dangerous schemes in url() values ──
|
||||
svg = svg.replace(/url\s*\(\s*["']?https?:\/\//gi, 'url("data:,');
|
||||
svg = svg.replace(/url\s*\(\s*["']?file:/gi, 'url("data:,');
|
||||
svg = svg.replace(/url\s*\(\s*["']?data:/gi, 'url("data:,');
|
||||
|
||||
return Buffer.from(svg, "utf-8");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,65 @@
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, rm, statfs } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { env } from "../config.js";
|
||||
|
||||
/**
|
||||
* Check available disk space before creating a workspace.
|
||||
* Triggers cleanup if free space is low, and rejects with 503 if
|
||||
* space remains critically low after cleanup.
|
||||
*/
|
||||
async function checkWorkspaceCapacity(workspaceRoot: string): Promise<void> {
|
||||
if (!existsSync(workspaceRoot)) return;
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = await statfs(workspaceRoot);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const freeBytes = stats.bavail * stats.bsize;
|
||||
const freeGB = freeBytes / 1024 ** 3;
|
||||
|
||||
if (freeGB < 1) {
|
||||
// Attempt to reclaim space by cleaning up old workspaces
|
||||
const { readdir, stat: fsStat } = await import("node:fs/promises");
|
||||
const entries = await readdir(workspaceRoot, { withFileTypes: true }).catch(() => []);
|
||||
const now = Date.now();
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(workspaceRoot, entry.name);
|
||||
try {
|
||||
const s = await fsStat(fullPath);
|
||||
// Remove workspaces older than 1 hour during emergency cleanup
|
||||
if (now - s.mtimeMs > 60 * 60 * 1000) {
|
||||
await rm(fullPath, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Skip entries that can't be stat'd
|
||||
}
|
||||
}
|
||||
|
||||
// Recheck after cleanup
|
||||
let stats2;
|
||||
try {
|
||||
stats2 = await statfs(workspaceRoot);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const freeGB2 = (stats2.bavail * stats2.bsize) / 1024 ** 3;
|
||||
if (freeGB2 < 0.5) {
|
||||
const error = new Error("Insufficient disk space for processing");
|
||||
(error as Error & { statusCode: number }).statusCode = 503;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace directory structure for a processing job.
|
||||
* Returns the workspace root path.
|
||||
*/
|
||||
export async function createWorkspace(jobId: string): Promise<string> {
|
||||
await checkWorkspaceCapacity(env.WORKSPACE_PATH);
|
||||
const root = getWorkspacePath(jobId);
|
||||
await mkdir(join(root, "input"), { recursive: true });
|
||||
await mkdir(join(root, "output"), { recursive: true });
|
||||
|
||||
@@ -72,19 +72,19 @@ function validateUsername(username: string): string | null {
|
||||
|
||||
// ── Zod schemas for auth request bodies ──────────────────────────
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, "Username is required").max(255, "Username too long"),
|
||||
password: z.string().min(1, "Password is required").max(1024, "Password too long"),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(1, "New password is required"),
|
||||
export const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required").max(1024, "Password too long"),
|
||||
newPassword: z.string().min(1, "New password is required").max(1024, "Password too long"),
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
export const registerSchema = z.object({
|
||||
username: z.string().min(1, "Username is required").max(255, "Username too long"),
|
||||
password: z.string().min(1, "Password is required").max(1024, "Password too long"),
|
||||
role: z.string().optional(),
|
||||
team: z.string().optional(),
|
||||
});
|
||||
@@ -94,8 +94,8 @@ const updateUserSchema = z.object({
|
||||
team: z.string().optional(),
|
||||
});
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
newPassword: z.string().min(1, "New password is required"),
|
||||
export const resetPasswordSchema = z.object({
|
||||
newPassword: z.string().min(1, "New password is required").max(1024, "Password too long"),
|
||||
});
|
||||
|
||||
// ── Request helpers ───────────────────────────────────────────────
|
||||
@@ -641,6 +641,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run();
|
||||
|
||||
// Invalidate all sessions when role changes to force re-login with new permissions
|
||||
if (updates.role && updates.role !== user.role) {
|
||||
db.delete(schema.sessions).where(eq(schema.sessions.userId, id)).run();
|
||||
request.log.info(
|
||||
{ targetUserId: id, oldRole: user.role, newRole: updates.role },
|
||||
"Sessions invalidated due to role change",
|
||||
);
|
||||
}
|
||||
|
||||
auditLog(request.log, "USER_UPDATED", {
|
||||
adminId: admin.id,
|
||||
targetUserId: id,
|
||||
@@ -813,15 +822,21 @@ export async function authMiddleware(app: FastifyInstance): Promise<void> {
|
||||
.from(schema.apiKeys)
|
||||
.where(eq(schema.apiKeys.keyPrefix, prefix))
|
||||
.all();
|
||||
// Fall back to full scan for legacy keys without a prefix
|
||||
const keysToCheck =
|
||||
candidates.length > 0
|
||||
? candidates
|
||||
: db
|
||||
.select()
|
||||
.from(schema.apiKeys)
|
||||
.all()
|
||||
.filter((k) => !k.keyPrefix);
|
||||
// Fall back to full scan for legacy keys without a prefix (bounded to 100)
|
||||
let keysToCheck: typeof candidates;
|
||||
if (candidates.length > 0) {
|
||||
keysToCheck = candidates;
|
||||
} else {
|
||||
request.log.warn(
|
||||
"Legacy API key lookup triggered (no keyPrefix match). Migrate keys to use prefix-based lookup.",
|
||||
);
|
||||
keysToCheck = db
|
||||
.select()
|
||||
.from(schema.apiKeys)
|
||||
.all()
|
||||
.filter((k) => !k.keyPrefix)
|
||||
.slice(0, 100);
|
||||
}
|
||||
for (const key of keysToCheck) {
|
||||
const matches = await verifyPassword(token, key.keyHash);
|
||||
if (matches) {
|
||||
|
||||
@@ -138,38 +138,42 @@ function getUniqueName(name: string, used: Set<string>): string {
|
||||
}
|
||||
|
||||
export async function registerFetchUrlsRoute(app: FastifyInstance): Promise<void> {
|
||||
app.post("/api/v1/fetch-urls", async (request, reply) => {
|
||||
// Validate body
|
||||
const parsed = fetchUrlsSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
const messages = parsed.error.issues.map((i) => i.message).join("; ");
|
||||
return reply.status(400).send({ error: messages });
|
||||
}
|
||||
app.post(
|
||||
"/api/v1/fetch-urls",
|
||||
{ config: { rateLimit: { max: 200, timeWindow: "1 hour" } } },
|
||||
async (request, reply) => {
|
||||
// Validate body
|
||||
const parsed = fetchUrlsSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
const messages = parsed.error.issues.map((i) => i.message).join("; ");
|
||||
return reply.status(400).send({ error: messages });
|
||||
}
|
||||
|
||||
const { urls } = parsed.data;
|
||||
const jobId = randomUUID();
|
||||
const workspace = await createWorkspace(jobId);
|
||||
const outputDir = join(workspace, "output");
|
||||
const { urls } = parsed.data;
|
||||
const jobId = randomUUID();
|
||||
const workspace = await createWorkspace(jobId);
|
||||
const outputDir = join(workspace, "output");
|
||||
|
||||
const queue = new PQueue({ concurrency: URL_FETCH_CONCURRENCY });
|
||||
const queue = new PQueue({ concurrency: URL_FETCH_CONCURRENCY });
|
||||
|
||||
// Track filenames to prevent collisions when multiple URLs resolve to the
|
||||
// same name (e.g. https://a.com/photo.jpg and https://b.com/photo.jpg).
|
||||
const usedFilenames = new Set<string>();
|
||||
// Track filenames to prevent collisions when multiple URLs resolve to the
|
||||
// same name (e.g. https://a.com/photo.jpg and https://b.com/photo.jpg).
|
||||
const usedFilenames = new Set<string>();
|
||||
|
||||
// Pre-allocate result slots to preserve order
|
||||
const resultSlots: FetchResult[] = new Array(urls.length);
|
||||
// Pre-allocate result slots to preserve order
|
||||
const resultSlots: FetchResult[] = new Array(urls.length);
|
||||
|
||||
await Promise.all(
|
||||
urls.map((url, index) =>
|
||||
queue.add(async () => {
|
||||
resultSlots[index] = await fetchSingleUrl(url, jobId, outputDir, usedFilenames);
|
||||
}),
|
||||
),
|
||||
);
|
||||
await Promise.all(
|
||||
urls.map((url, index) =>
|
||||
queue.add(async () => {
|
||||
resultSlots[index] = await fetchSingleUrl(url, jobId, outputDir, usedFilenames);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return reply.send({ results: resultSlots });
|
||||
});
|
||||
return reply.send({ results: resultSlots });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchSingleUrl(
|
||||
|
||||
@@ -25,67 +25,71 @@ function isPathTraversal(segment: string): boolean {
|
||||
|
||||
export async function fileRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ── POST /api/v1/upload ────────────────────────────────────────
|
||||
app.post("/api/v1/upload", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const inputDir = join(workspacePath, "input");
|
||||
app.post(
|
||||
"/api/v1/upload",
|
||||
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const inputDir = join(workspacePath, "input");
|
||||
|
||||
const uploadedFiles: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
format: string;
|
||||
}> = [];
|
||||
const uploadedFiles: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
format: string;
|
||||
}> = [];
|
||||
|
||||
const parts = request.parts();
|
||||
const parts = request.parts();
|
||||
|
||||
for await (const part of parts) {
|
||||
// Skip non-file fields
|
||||
if (part.type !== "file") continue;
|
||||
for await (const part of parts) {
|
||||
// Skip non-file fields
|
||||
if (part.type !== "file") continue;
|
||||
|
||||
// Consume buffer from the stream
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
// Consume buffer from the stream
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
// Skip empty parts (e.g. empty file field)
|
||||
if (buffer.length === 0) continue;
|
||||
// Skip empty parts (e.g. empty file field)
|
||||
if (buffer.length === 0) continue;
|
||||
|
||||
// Validate the image (pass filename for extension-based format detection)
|
||||
const validation = await validateImageBuffer(buffer, part.filename);
|
||||
if (!validation.valid) {
|
||||
return reply.status(400).send({
|
||||
error: `Invalid file "${part.filename}": ${validation.reason}`,
|
||||
// Validate the image (pass filename for extension-based format detection)
|
||||
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;
|
||||
|
||||
// Sanitize filename
|
||||
const safeName = sanitizeFilename(part.filename ?? "upload");
|
||||
|
||||
// Write to workspace input directory
|
||||
const filePath = join(inputDir, safeName);
|
||||
await writeFile(filePath, safeBuffer);
|
||||
|
||||
uploadedFiles.push({
|
||||
name: safeName,
|
||||
size: safeBuffer.length,
|
||||
format: validation.format,
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
|
||||
const safeBuffer = isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
|
||||
if (uploadedFiles.length === 0) {
|
||||
return reply.status(400).send({ error: "No valid files uploaded" });
|
||||
}
|
||||
|
||||
// Sanitize filename
|
||||
const safeName = sanitizeFilename(part.filename ?? "upload");
|
||||
|
||||
// Write to workspace input directory
|
||||
const filePath = join(inputDir, safeName);
|
||||
await writeFile(filePath, safeBuffer);
|
||||
|
||||
uploadedFiles.push({
|
||||
name: safeName,
|
||||
size: safeBuffer.length,
|
||||
format: validation.format,
|
||||
return reply.send({
|
||||
jobId,
|
||||
files: uploadedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
if (uploadedFiles.length === 0) {
|
||||
return reply.status(400).send({ error: "No valid files uploaded" });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
files: uploadedFiles,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ── GET /api/v1/download/:jobId/:filename ──────────────────────
|
||||
app.get(
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { z } from "zod";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { trackEvent } from "../lib/analytics.js";
|
||||
import { autoOrient } from "../lib/auto-orient.js";
|
||||
import { formatZodErrors } from "../lib/errors.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../lib/errors.js";
|
||||
import { isToolInstalled } from "../lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../lib/filename.js";
|
||||
@@ -153,7 +153,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -223,7 +223,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: `Failed to decode ${validation.format.toUpperCase()} file`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -259,7 +259,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
} catch (fallbackErr) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode AVIF file",
|
||||
details: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
|
||||
details: stripInternalPaths(
|
||||
fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -268,6 +270,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
reportProgress(15, "Preparing...");
|
||||
|
||||
// Parse and validate settings
|
||||
if (settingsRaw && settingsRaw.length > 65536) {
|
||||
return reply.status(400).send({ error: "Settings payload too large (max 64KB)" });
|
||||
}
|
||||
let settings: T;
|
||||
try {
|
||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
@@ -523,7 +528,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
});
|
||||
return reply.status(422).send({
|
||||
error: "Processing failed",
|
||||
details: message,
|
||||
details: stripInternalPaths(message),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { stripInternalPaths } from "../../lib/errors.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||
@@ -36,7 +37,7 @@ export function registerInfo(app: FastifyInstance) {
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse multipart request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,7 +110,7 @@ export function registerInfo(app: FastifyInstance) {
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to read image metadata",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 {
|
||||
@@ -81,6 +82,31 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
@@ -160,82 +186,94 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
* 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", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = getAuthUser(request);
|
||||
const userId = user?.id ?? null;
|
||||
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;
|
||||
|
||||
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();
|
||||
// Enforce per-user storage quota before accepting uploads
|
||||
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" });
|
||||
checkStorageQuota(userId);
|
||||
} catch (err) {
|
||||
const statusCode = (err as Error & { statusCode?: number }).statusCode ?? 413;
|
||||
return reply.status(statusCode).send({ error: (err as Error).message });
|
||||
}
|
||||
|
||||
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||
const created: ReturnType<typeof serializeFile>[] = [];
|
||||
|
||||
if (row) created.push(serializeFile(row));
|
||||
}
|
||||
const parts = request.parts();
|
||||
|
||||
if (created.length === 0) {
|
||||
return reply.status(400).send({ error: "No valid files uploaded" });
|
||||
}
|
||||
for await (const part of parts) {
|
||||
if (part.type !== "file") continue;
|
||||
|
||||
auditLog(request.log, "FILE_UPLOADED", {
|
||||
userId,
|
||||
count: created.length,
|
||||
files: created.map((f) => f.originalName),
|
||||
});
|
||||
// Consume the stream into a buffer
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
return reply.status(201).send({ files: created });
|
||||
});
|
||||
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
|
||||
@@ -503,6 +541,14 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user