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",
|
||||
},
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -113,7 +113,10 @@ export function Dropzone({
|
||||
acceptDescription,
|
||||
}: DropzoneProps) {
|
||||
const { t } = useTranslation();
|
||||
const checkFile = fileFilter ?? isImageFile;
|
||||
// When no explicit filter is given, accept any file: the file picker still
|
||||
// restricts via `accept`, and the server validates per modality. Image tools
|
||||
// pass an explicit fileFilter, so they are unaffected.
|
||||
const checkFile = fileFilter ?? (() => true);
|
||||
const resolvedAccept = expandAccept(accept);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -168,7 +171,7 @@ export function Dropzone({
|
||||
if (validFiles.length > 0) {
|
||||
onFiles?.(validFiles);
|
||||
} else if (droppedFiles.length > 0) {
|
||||
setError(acceptDescription || `This tool accepts ${accept || "image files"}`);
|
||||
setError(acceptDescription || `This tool accepts ${accept || "the supported file types"}`);
|
||||
}
|
||||
},
|
||||
[onFiles, checkFile, acceptDescription, accept],
|
||||
@@ -186,7 +189,7 @@ export function Dropzone({
|
||||
if (validFiles.length > 0) {
|
||||
onFiles?.(validFiles);
|
||||
} else if (picked.length > 0) {
|
||||
setError(acceptDescription || `This tool accepts ${accept || "image files"}`);
|
||||
setError(acceptDescription || `This tool accepts ${accept || "the supported file types"}`);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
|
||||
@@ -136,7 +136,7 @@ export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={
|
||||
"https://example.com/photo1.jpg\nhttps://example.com/photo2.png\n- https://example.com/photo3.webp\n[My image](https://example.com/photo4.jpg)"
|
||||
"https://example.com/photo.jpg\nhttps://example.com/report.pdf\n- https://example.com/clip.mp4\n[My file](https://example.com/recording.mp3)"
|
||||
}
|
||||
className="w-full min-h-[120px] max-h-[240px] resize-y rounded-lg border border-border bg-muted px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
|
||||
@@ -502,7 +502,7 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
|
||||
<DetailRow label={t.files.name} value={details.originalName} />
|
||||
<DetailRow
|
||||
label={t.files.format}
|
||||
value={details.mimeType.replace("image/", "").toUpperCase()}
|
||||
value={details.mimeType.split("/").pop()?.toUpperCase() ?? ""}
|
||||
/>
|
||||
<DetailRow label={t.files.size} value={formatSize(details.size)} />
|
||||
<DetailRow
|
||||
|
||||
@@ -80,8 +80,8 @@ export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||
<h3 className="text-sm font-semibold">{t.help.gettingStarted.heading}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Select a tool from the sidebar or search for one with <Kbd keys="mod+k" />. Upload an
|
||||
image by dragging it onto the page or clicking the upload area. Adjust settings and
|
||||
Select a tool from the sidebar or search for one with <Kbd keys="mod+k" />. Upload a
|
||||
file by dragging it onto the page or clicking the upload area. Adjust settings and
|
||||
download your result.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useFileStore } from "@/stores/file-store";
|
||||
export function BulkRenameSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [pattern, setPattern] = useState("image-{{index}}");
|
||||
const [pattern, setPattern] = useState("file-{{index}}");
|
||||
const [startIndex, setStartIndex] = useState(1);
|
||||
const [downloadReady, setDownloadReady] = useState(false);
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ export function FindDuplicatesSettings() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "unique-images.zip";
|
||||
a.download = "unique-files.zip";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [files, results, bestOverrides]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Download,
|
||||
FileImage,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
Play,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { Dropzone } from "@/components/common/dropzone";
|
||||
@@ -37,6 +38,13 @@ import { cn } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store";
|
||||
|
||||
const MediaPlayerView = lazy(() =>
|
||||
import("@/components/tools/media-player-view").then((m) => ({ default: m.MediaPlayerView })),
|
||||
);
|
||||
const WaveformPlayer = lazy(() =>
|
||||
import("@/components/common/waveform-player").then((m) => ({ default: m.WaveformPlayer })),
|
||||
);
|
||||
|
||||
export function AutomatePage() {
|
||||
const { t } = useTranslation();
|
||||
usePageTitle(t.sidebar.automate);
|
||||
@@ -167,7 +175,6 @@ export function AutomatePage() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = "image/*,.avif,.heic,.heif,.hif";
|
||||
input.onchange = (e) => {
|
||||
const picked = Array.from((e.target as HTMLInputElement).files || []);
|
||||
if (picked.length > 0) addFiles(picked);
|
||||
@@ -361,6 +368,102 @@ export function AutomatePage() {
|
||||
[addStep, isMobile],
|
||||
);
|
||||
|
||||
/**
|
||||
* Render modality-aware preview for the pipeline result or source file.
|
||||
* Mirrors the previewKind switching in tool-page.tsx: images get
|
||||
* BeforeAfterSlider / ImageViewer, video gets MediaPlayerView, audio
|
||||
* gets WaveformPlayer, and document/data get a static info card.
|
||||
*/
|
||||
function renderPipelinePreview(mode: "result" | "original") {
|
||||
const kind = currentEntry?.previewKind ?? "image";
|
||||
|
||||
if (mode === "result") {
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={originalBlobUrl!}
|
||||
afterSrc={processedUrl as string}
|
||||
beforeSize={originalSize ?? undefined}
|
||||
afterSize={processedSize ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (kind === "video") {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||
<MediaPlayerView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (kind === "audio") {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||
<WaveformPlayer src={processedUrl as string} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
// document / data: success card with filename + size
|
||||
const fname = currentEntry?.processedFilename ?? selectedFileName ?? files[0]?.name ?? "file";
|
||||
const fsize = processedSize ?? 0;
|
||||
const ext = fname.split(".").pop()?.toUpperCase() ?? "";
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center p-6 max-w-xs">
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl bg-emerald-50 dark:bg-emerald-950/30 flex items-center justify-center mb-3">
|
||||
<CheckCircle2 className="h-7 w-7 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<p className="font-medium text-foreground mb-1">{fname}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ext} · {formatFileSize(fsize)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// mode === "original"
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<ImageViewer
|
||||
src={originalBlobUrl!}
|
||||
filename={selectedFileName ?? files[0].name}
|
||||
fileSize={selectedFileSize ?? files[0].size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (kind === "video") {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||
<MediaPlayerView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (kind === "audio") {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
|
||||
<WaveformPlayer src={originalBlobUrl!} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
// document / data: placeholder card
|
||||
const fname = selectedFileName ?? files[0]?.name ?? "file";
|
||||
const fsize = selectedFileSize ?? files[0]?.size ?? 0;
|
||||
const ext = fname.split(".").pop()?.toUpperCase() ?? "";
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center p-6 max-w-xs">
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl bg-muted flex items-center justify-center mb-3">
|
||||
<FileImage className="h-7 w-7 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="font-medium text-foreground mb-1">{fname}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ext} · {formatFileSize(fsize)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mobile Layout */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -383,7 +486,12 @@ export function AutomatePage() {
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{!hasFile && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
multiple
|
||||
currentFiles={files}
|
||||
fileFilter={() => true}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLibraryModalOpen(true)}
|
||||
@@ -425,18 +533,11 @@ export function AutomatePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile image preview / result */}
|
||||
{/* Mobile preview / result */}
|
||||
{hasFile && hasProcessed && originalBlobUrl && (
|
||||
<div className="mb-3 rounded-lg border border-border overflow-hidden">
|
||||
<div className="relative h-48">
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={originalBlobUrl}
|
||||
afterSrc={processedUrl as string}
|
||||
beforeSize={originalSize ?? undefined}
|
||||
afterSize={processedSize ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
{processedSize != null && (
|
||||
<div className="relative h-48">{renderPipelinePreview("result")}</div>
|
||||
{processedSize != null && currentEntry?.previewKind === "image" && (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground">
|
||||
<span className="truncate">{selectedFileName ?? files[0].name}</span>
|
||||
<span>
|
||||
@@ -449,11 +550,7 @@ export function AutomatePage() {
|
||||
|
||||
{hasFile && !hasProcessed && originalBlobUrl && currentEntry?.status !== "failed" && (
|
||||
<div className="mb-3 rounded-lg border border-border overflow-hidden h-40 flex items-center justify-center bg-muted/20">
|
||||
<ImageViewer
|
||||
src={originalBlobUrl}
|
||||
filename={selectedFileName ?? files[0].name}
|
||||
fileSize={selectedFileSize ?? files[0].size}
|
||||
/>
|
||||
{renderPipelinePreview("original")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -949,10 +1046,10 @@ export function AutomatePage() {
|
||||
<div className="w-full max-h-[120px] overflow-hidden">
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
accept="image/*"
|
||||
multiple
|
||||
currentFiles={files}
|
||||
compact
|
||||
fileFilter={() => true}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -974,25 +1071,13 @@ export function AutomatePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFile && hasProcessed && originalBlobUrl && (
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={originalBlobUrl}
|
||||
afterSrc={processedUrl as string}
|
||||
beforeSize={originalSize ?? undefined}
|
||||
afterSize={processedSize ?? undefined}
|
||||
/>
|
||||
)}
|
||||
{hasFile && hasProcessed && originalBlobUrl && renderPipelinePreview("result")}
|
||||
|
||||
{hasFile &&
|
||||
!hasProcessed &&
|
||||
originalBlobUrl &&
|
||||
currentEntry?.status !== "failed" && (
|
||||
<ImageViewer
|
||||
src={originalBlobUrl}
|
||||
filename={selectedFileName ?? files[0].name}
|
||||
fileSize={selectedFileSize ?? files[0].size}
|
||||
/>
|
||||
)}
|
||||
currentEntry?.status !== "failed" &&
|
||||
renderPipelinePreview("original")}
|
||||
</div>
|
||||
|
||||
{hasMultiple && (
|
||||
|
||||
Reference in New Issue
Block a user