diff --git a/app/api/batch-upload/presign/route.ts b/app/api/batch-upload/presign/route.ts index b633950..4e97527 100644 --- a/app/api/batch-upload/presign/route.ts +++ b/app/api/batch-upload/presign/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; -import { generateHetznerPresignedUploadUrl } from "@/lib/storage"; +import { generateHetznerPresignedUploadUrl, sanitizeFileName } from "@/lib/storage"; import { randomUUID } from "crypto"; export const maxDuration = 10; @@ -42,7 +42,7 @@ export async function POST(req: NextRequest) { ext === "mp4" ? "video/mp4" : "application/octet-stream"; - const key = `highres/${randomUUID()}-${fileName}`; + const key = `highres/${randomUUID()}-${sanitizeFileName(fileName)}`; try { const presignedUrl = await generateHetznerPresignedUploadUrl(key, contentType); diff --git a/lib/storage.ts b/lib/storage.ts index 4556c2a..9a92ba9 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -90,6 +90,31 @@ function getBucketName(): string { return map[provider] ?? "vfx-review"; } +/** + * Sanitize a user-supplied filename before it becomes (part of) an S3 object key. + * + * Reserved/special URL characters in a key — especially `#` (fragment) and + * `?`/`%` (query / percent-encoding) — break S3 presigned-URL signatures: + * the signature is computed over the raw key, but browsers/HTTP clients + * strip or re-encode those characters differently when the URL is actually + * fetched, producing a signature mismatch. That surfaces to users as a 400 + * when the image is later loaded. Common real-world filenames like + * "Take #3.png" or "Ref #12 (final).jpg" are common enough in VFX pipelines + * to hit this regularly, so every key built from a filename must go through + * this sanitizer. + */ +export function sanitizeFileName(fileName: string): string { + const ext = path.extname(fileName); + const base = path.basename(fileName, ext); + const safeBase = base + .normalize("NFKD") + .replace(/[^\w.-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + const safeExt = ext.replace(/[^\w.-]+/g, ""); + return `${safeBase || "file"}${safeExt}`; +} + // ── Public API ─────────────────────────────────────────────────────────────── export interface UploadResult { @@ -109,7 +134,7 @@ export async function uploadFile( folder: string = "uploads" ): Promise { const provider = getProvider(); - const key = `${folder}/${randomUUID()}-${fileName}`; + const key = `${folder}/${randomUUID()}-${sanitizeFileName(fileName)}`; if (provider === "local") { return uploadLocal(buffer, key); @@ -266,7 +291,7 @@ export async function uploadToHetzner( contentType: string, folder: string = "highres" ): Promise<{ key: string }> { - const key = `${folder}/${randomUUID()}-${fileName}`; + const key = `${folder}/${randomUUID()}-${sanitizeFileName(fileName)}`; const { client, bucket } = await buildHetznerClient(); await client.send( new PutObjectCommand({