mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user