feat: add URL-based image import (single + bulk)

Add a fourth image ingestion path: importing images by URL.

Backend:
- POST /api/v1/fetch-urls endpoint with SSRF protection, image validation,
  preview generation, and p-queue concurrency
- SSRF utility blocking private IPs, validating redirect hops, with
  comprehensive IPv4/IPv6 range coverage

Frontend:
- Always-visible URL input in the dropzone for quick single-image import
- Bulk URL import modal with smart URL parsing (lists, markdown, HTML),
  per-URL progress tracking, retry on failure, and batch add
- useUrlImport hook managing the full fetch lifecycle

Tests: 48 new tests (23 SSRF unit, 10 URL parser unit, 15 integration)
This commit is contained in:
SnapOtter
2026-05-11 22:41:47 +08:00
13 changed files with 1788 additions and 2 deletions
+25 -2
View File
@@ -283,6 +283,13 @@ export function ToolPage() {
[setFiles, reset],
);
const handleUrlImport = useCallback(
(file: File) => {
addFiles([file]);
},
[addFiles],
);
const handleUndo = useCallback(() => {
undoProcessing();
setEraserSliderInitPos(null);
@@ -434,7 +441,15 @@ export function ToolPage() {
// Custom results panel (find-duplicates, etc.)
if (displayMode === "custom-results" && registryEntry?.ResultsPanel) {
if (!hasFile)
return <Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />;
return (
<Dropzone
onFiles={handleFiles}
onUrlImport={handleUrlImport}
accept="image/*"
multiple
currentFiles={files}
/>
);
const ResultsPanel = registryEntry.ResultsPanel;
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
@@ -637,7 +652,15 @@ export function ToolPage() {
);
}
return <Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />;
return (
<Dropzone
onFiles={handleFiles}
onUrlImport={handleUrlImport}
accept="image/*"
multiple
currentFiles={files}
/>
);
}
// Navigation arrows (shared between mobile/desktop)