fix: handle non-image modalities across uploads, previews, and filenames (#255)

SnapOtter spans five modalities now, but several code paths still assumed image input.

- dropzone: default to accept-all when no fileFilter is given (image tools still pass one); neutral "supported file types" error text instead of "image files"
- automate (pipelines): accept any modality in the file pickers and dropzones; render modality-aware previews (video player, audio waveform, document/data card) instead of always using ImageViewer/BeforeAfterSlider
- filename sanitizer: extend the double-extension allowlist beyond image extensions to video/audio/document/data so e.g. "report.csv.php" becomes "report.csv"; add tests
- thumbnail route: return 422 for non-rasterisable files (audio, data, non-PDF docs) instead of attempting a doomed Sharp decode
- pool: unknown tools fall back to the "system" pool, not the image pool
- a11y labels: "Previous/Next image", "Image viewer/area/controls/drop zone" are now modality-neutral, across all 21 locales
- copy: bulk-rename default, find-duplicates ZIP name, SSRF user-agent, fetch-urls fallback name, file-details MIME label, URL-import placeholder, help dialog
This commit is contained in:
SnapOtter
2026-06-16 18:04:48 +08:00
committed by GitHub
parent 622c9f98a5
commit 8eee17aeea
29 changed files with 420 additions and 246 deletions
+68 -6
View File
@@ -1,6 +1,10 @@
import { basename } from "node:path";
const SAFE_IMAGE_EXTENSIONS = new Set([
// Recognised, safe file extensions across all five modalities (image, video,
// audio, document, data). Used to truncate double-extension attacks after the
// first known-good extension, e.g. "report.csv.php" becomes "report.csv".
const SAFE_EXTENSIONS = new Set([
// Image
".jpg",
".jpeg",
".png",
@@ -11,7 +15,65 @@ const SAFE_IMAGE_EXTENSIONS = new Set([
".tif",
".avif",
".svg",
".heic",
".heif",
".jxl",
".ico",
".jp2",
".qoi",
".psd",
".dng",
// Video
".mp4",
".webm",
".mov",
".mkv",
".avi",
".m4v",
".mpg",
".mpeg",
".wmv",
".flv",
".ogv",
// Audio
".mp3",
".wav",
".flac",
".ogg",
".oga",
".aac",
".m4a",
".opus",
".wma",
".aiff",
".aif",
// Document
".pdf",
".doc",
".docx",
".odt",
".rtf",
".txt",
".md",
".markdown",
".html",
".htm",
".epub",
".ppt",
".pptx",
".odp",
".xls",
".xlsx",
".ods",
// Data
".csv",
".tsv",
".json",
".xml",
".yaml",
".yml",
".zip",
".srt",
]);
/**
@@ -19,8 +81,8 @@ const SAFE_IMAGE_EXTENSIONS = new Set([
*
* 1. Strips directory separators (basename only).
* 2. Removes ".." sequences and null bytes.
* 3. Truncates after the first recognised image extension so that
* "photo.png.php" becomes "photo.png".
* 3. Truncates after the first recognised file extension so that
* "report.csv.php" becomes "report.csv".
*/
export function sanitizeFilename(raw: string): string {
let name = basename(raw);
@@ -30,14 +92,14 @@ export function sanitizeFilename(raw: string): string {
name = "upload";
}
// Guard against double-extension attacks (e.g. "image.png.php").
// Walk the dot-separated parts and truncate after the first safe image extension.
// Guard against double-extension attacks (e.g. "report.csv.php").
// Walk the dot-separated parts and truncate after the first safe extension.
const dotIndex = name.indexOf(".");
if (dotIndex !== -1) {
const parts = name.split(".");
for (let i = 1; i < parts.length; i++) {
const ext = `.${parts[i].toLowerCase()}`;
if (SAFE_IMAGE_EXTENSIONS.has(ext)) {
if (SAFE_EXTENSIONS.has(ext)) {
// Keep everything up to and including this extension, drop the rest
name = parts.slice(0, i + 1).join(".");
break;
+3 -1
View File
@@ -6,7 +6,9 @@ import type { Pool } from "../jobs/types.js";
export function resolveToolPool(toolId: string): Pool {
if (hasAiJobHandler(toolId) || TOOL_BUNDLE_MAP[toolId]) return "ai";
const tool = TOOLS.find((t) => t.id === toolId);
return tool ? MODALITY_POOL[tool.modality] : "image";
// Unknown tools fall back to the general-purpose "system" pool rather than
// assuming the image pool (a legacy image-only default).
return tool ? MODALITY_POOL[tool.modality] : "system";
}
export function shouldSkipSyncWindow(executionHint: "fast" | "long" | undefined): boolean {
+2 -2
View File
@@ -158,7 +158,7 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
signal,
redirect: "manual",
headers: {
"User-Agent": "SnapOtter/1.0 (image-fetch)",
"User-Agent": "SnapOtter/2.0 (file-fetch)",
Host: parsed.host,
},
};
@@ -176,7 +176,7 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise<Resp
agent,
signal: signal ?? undefined,
headers: {
"User-Agent": "SnapOtter/1.0 (image-fetch)",
"User-Agent": "SnapOtter/2.0 (file-fetch)",
},
method: "GET",
},
+1 -1
View File
@@ -110,7 +110,7 @@ function filenameFromUrl(url: string): string {
} catch {
// ignore parse errors
}
return `image-${randomUUID().slice(0, 8)}`;
return `file-${randomUUID().slice(0, 8)}`;
}
/**
+6
View File
@@ -524,6 +524,12 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
// Image thumbnail (existing path)
const validation = await validateImageBuffer(rawBuffer, file.originalName);
if (!validation.valid) {
// Audio, data, and non-PDF document files have no raster thumbnail.
// Return 422 (not 204) so the client's <img> onError falls back to a
// modality icon instead of attempting a doomed Sharp decode below.
return reply.status(422).send({ error: "No thumbnail available for this file type" });
}
let decoded: Buffer<ArrayBuffer> = Buffer.from(rawBuffer);
if (validation.valid && validation.format === "heif") {
decoded = Buffer.from(await decodeHeic(rawBuffer));