mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
159 lines
4.8 KiB
TypeScript
159 lines
4.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { sanitizeFilename } from "../../../apps/api/src/lib/filename.js";
|
|
|
|
describe("sanitizeFilename", () => {
|
|
it("passes through a simple filename", () => {
|
|
expect(sanitizeFilename("photo.png")).toBe("photo.png");
|
|
});
|
|
|
|
it("strips directory path and returns basename only", () => {
|
|
expect(sanitizeFilename("/usr/local/bin/image.png")).toBe("image.png");
|
|
});
|
|
|
|
it("strips relative directory path", () => {
|
|
expect(sanitizeFilename("some/nested/dir/file.jpg")).toBe("file.jpg");
|
|
});
|
|
|
|
it("removes .. sequences", () => {
|
|
const result = sanitizeFilename("../../etc/passwd");
|
|
expect(result).toBe("passwd");
|
|
expect(result).not.toContain("..");
|
|
});
|
|
|
|
it("removes embedded .. sequences in filename", () => {
|
|
expect(sanitizeFilename("my..file..name.png")).toBe("myfilename.png");
|
|
});
|
|
|
|
it("removes null bytes", () => {
|
|
expect(sanitizeFilename("image\0.png")).toBe("image.png");
|
|
});
|
|
|
|
it("falls back to upload for empty string", () => {
|
|
expect(sanitizeFilename("")).toBe("upload");
|
|
});
|
|
|
|
it("falls back to upload for single dot", () => {
|
|
expect(sanitizeFilename(".")).toBe("upload");
|
|
});
|
|
|
|
it("falls back to upload for double dot", () => {
|
|
expect(sanitizeFilename("..")).toBe("upload");
|
|
});
|
|
|
|
it("falls back to upload for triple dots (.. removal leaves .)", () => {
|
|
expect(sanitizeFilename("...")).toBe("upload");
|
|
});
|
|
|
|
it("falls back to upload for four dots (.. removal leaves empty)", () => {
|
|
expect(sanitizeFilename("....")).toBe("upload");
|
|
});
|
|
|
|
it("truncates after first safe image extension (photo.png.php)", () => {
|
|
expect(sanitizeFilename("photo.png.php")).toBe("photo.png");
|
|
});
|
|
|
|
it("truncates after first safe image extension (report.jpg.exe)", () => {
|
|
expect(sanitizeFilename("report.jpg.exe")).toBe("report.jpg");
|
|
});
|
|
|
|
it("truncates after first safe image extension with multiple unsafe parts", () => {
|
|
expect(sanitizeFilename("evil.webp.php.sh")).toBe("evil.webp");
|
|
});
|
|
|
|
it("handles no extension", () => {
|
|
expect(sanitizeFilename("README")).toBe("README");
|
|
});
|
|
|
|
it("handles unknown extensions without truncation", () => {
|
|
expect(sanitizeFilename("archive.tar.gz")).toBe("archive.tar.gz");
|
|
});
|
|
|
|
it("handles filename with only unknown extensions", () => {
|
|
expect(sanitizeFilename("data.xyz")).toBe("data.xyz");
|
|
});
|
|
|
|
it("truncates very long filenames over 200 bytes", () => {
|
|
const longName = `${"a".repeat(300)}.png`;
|
|
const result = sanitizeFilename(longName);
|
|
expect(new TextEncoder().encode(result).length).toBeLessThanOrEqual(200);
|
|
expect(result).toMatch(/\.png$/);
|
|
});
|
|
|
|
it("truncates long filename without extension", () => {
|
|
const longName = "b".repeat(300);
|
|
const result = sanitizeFilename(longName);
|
|
expect(new TextEncoder().encode(result).length).toBeLessThanOrEqual(200);
|
|
});
|
|
|
|
it("handles unicode filenames", () => {
|
|
expect(sanitizeFilename("写真.png")).toBe("写真.png");
|
|
});
|
|
|
|
it("handles emoji filenames", () => {
|
|
expect(sanitizeFilename("\u{1F600}photo.jpg")).toBe("\u{1F600}photo.jpg");
|
|
});
|
|
|
|
it("handles filenames with spaces", () => {
|
|
expect(sanitizeFilename("my photo 2024.jpg")).toBe("my photo 2024.jpg");
|
|
});
|
|
|
|
it("handles filenames with dashes and underscores", () => {
|
|
expect(sanitizeFilename("my-photo_v2.webp")).toBe("my-photo_v2.webp");
|
|
});
|
|
|
|
it("preserves dotfiles", () => {
|
|
expect(sanitizeFilename(".gitignore")).toBe(".gitignore");
|
|
});
|
|
|
|
it("handles trailing slash in path", () => {
|
|
expect(sanitizeFilename("/foo/bar/")).toBe("bar");
|
|
});
|
|
|
|
it("handles only null bytes falling back to upload", () => {
|
|
expect(sanitizeFilename("\0\0\0")).toBe("upload");
|
|
});
|
|
|
|
it("truncates long unicode filenames correctly", () => {
|
|
const longUnicode = `${"\u{1F600}".repeat(100)}.png`;
|
|
const result = sanitizeFilename(longUnicode);
|
|
expect(new TextEncoder().encode(result).length).toBeLessThanOrEqual(200);
|
|
expect(result).toMatch(/\.png$/);
|
|
});
|
|
|
|
it("recognizes all safe image extensions", () => {
|
|
const extensions = [
|
|
"jpg",
|
|
"jpeg",
|
|
"png",
|
|
"webp",
|
|
"gif",
|
|
"bmp",
|
|
"tiff",
|
|
"tif",
|
|
"avif",
|
|
"svg",
|
|
"pdf",
|
|
];
|
|
for (const ext of extensions) {
|
|
const result = sanitizeFilename(`file.${ext}.evil`);
|
|
expect(result).toBe(`file.${ext}`);
|
|
}
|
|
});
|
|
|
|
it("recognizes safe extensions across all modalities", () => {
|
|
const cases: Record<string, string> = {
|
|
"clip.mp4.exe": "clip.mp4",
|
|
"song.mp3.php": "song.mp3",
|
|
"report.csv.php": "report.csv",
|
|
"doc.docx.exe": "doc.docx",
|
|
"data.json.sh": "data.json",
|
|
"book.epub.php": "book.epub",
|
|
"sheet.xlsx.exe": "sheet.xlsx",
|
|
"subs.srt.php": "subs.srt",
|
|
};
|
|
for (const [input, expected] of Object.entries(cases)) {
|
|
expect(sanitizeFilename(input)).toBe(expected);
|
|
}
|
|
});
|
|
});
|