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
@@ -1,6 +1,8 @@
import { FileImage, ImageUp, Upload } from "lucide-react";
import { type DragEvent, useCallback, useEffect, useState } from "react";
import { useUrlImport } from "@/hooks/use-url-import";
import { cn } from "@/lib/utils";
import { UrlImportModal } from "./url-import-modal";
const IMAGE_EXTENSIONS = new Set([
"jpg",
@@ -79,6 +81,7 @@ export function isImageFile(file: File): boolean {
interface DropzoneProps {
onFiles?: (files: File[]) => void;
onUrlImport?: (file: File) => void;
accept?: string;
multiple?: boolean;
/** Files that have already been dropped (for showing count & list). */
@@ -96,6 +99,7 @@ function expandAccept(accept?: string): string | undefined {
export function Dropzone({
onFiles,
onUrlImport,
accept,
multiple = true,
currentFiles = [],
@@ -103,6 +107,27 @@ export function Dropzone({
}: DropzoneProps) {
const resolvedAccept = expandAccept(accept);
const [isDragging, setIsDragging] = useState(false);
const [urlInput, setUrlInput] = useState("");
const [urlLoading, setUrlLoading] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null);
const [showBulkModal, setShowBulkModal] = useState(false);
const { importSingleUrl } = useUrlImport();
const handleUrlSubmit = useCallback(async () => {
const url = urlInput.trim();
if (!url) return;
setUrlLoading(true);
setUrlError(null);
const file = await importSingleUrl(url);
if (file) {
setUrlInput("");
onUrlImport?.(file);
} else {
setUrlError("Could not fetch image from URL");
}
setUrlLoading(false);
}, [urlInput, importSingleUrl, onUrlImport]);
const handleDrag = useCallback((e: DragEvent) => {
e.preventDefault();
@@ -224,6 +249,58 @@ export function Dropzone({
PNG, JPG, WebP, HEIC, RAW, PSD, and 65+ formats
</p>
{!compact && onUrlImport && (
<>
<div className="flex items-center gap-2 w-full max-w-xs">
<div className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">or</span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex gap-2 w-full max-w-sm">
<input
type="url"
value={urlInput}
onChange={(e) => {
setUrlInput(e.target.value);
setUrlError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUrlSubmit();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="Paste image URL..."
className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
disabled={urlLoading}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleUrlSubmit();
}}
disabled={urlLoading || !urlInput.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{urlLoading ? "..." : "Add"}
</button>
</div>
{urlError && <p className="text-xs text-destructive">{urlError}</p>}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowBulkModal(true);
}}
className="text-xs text-primary hover:text-primary/80"
>
Import multiple URLs...
</button>
</>
)}
{hasMultipleFiles && (
<div className="flex flex-col items-center gap-2 mt-1">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium">
@@ -244,6 +321,16 @@ export function Dropzone({
</div>
)}
</div>
{showBulkModal && (
<UrlImportModal
onClose={() => setShowBulkModal(false)}
onImport={(files) => {
for (const file of files) onUrlImport?.(file);
setShowBulkModal(false);
}}
/>
)}
</section>
);
}
@@ -0,0 +1,228 @@
import { AlertCircle, Check, Clock, Link, Loader2, RotateCw, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { type UrlImportEntry, useUrlImport } from "@/hooks/use-url-import";
import { extractUrls } from "@/lib/url-parser";
// ── Types ──────────────────────────────────────────────────────
interface UrlImportModalProps {
onClose: () => void;
onImport: (files: File[]) => void;
}
// ── Helpers ────────────────────────────────────────────────────
function StatusIcon({ status }: { status: UrlImportEntry["status"] }) {
switch (status) {
case "pending":
return <Clock className="h-4 w-4 text-muted-foreground" />;
case "fetching":
return <Loader2 className="h-4 w-4 text-primary animate-spin" />;
case "ready":
return <Check className="h-4 w-4 text-emerald-500" />;
case "failed":
return <AlertCircle className="h-4 w-4 text-destructive" />;
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function filenameFromUrl(url: string): string {
try {
return new URL(url).pathname.split("/").pop() || url;
} catch {
return url;
}
}
// ── Component ──────────────────────────────────────────────────
export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
const [text, setText] = useState("");
const [adding, setAdding] = useState(false);
const { entries, importing, importUrls, addReadyFiles, retryUrl, cancel, reset, readyCount } =
useUrlImport();
const hasResults = entries.length > 0;
const handleImport = useCallback(() => {
const urls = extractUrls(text);
if (urls.length === 0) return;
importUrls(urls);
}, [text, importUrls]);
const handleAdd = useCallback(async () => {
if (readyCount === 0) return;
setAdding(true);
try {
const files = await addReadyFiles();
if (files.length > 0) {
onImport(files);
onClose();
}
} finally {
setAdding(false);
}
}, [readyCount, addReadyFiles, onImport, onClose]);
const handleBack = useCallback(() => {
reset();
}, [reset]);
const handleClose = useCallback(() => {
cancel();
onClose();
}, [cancel, onClose]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") handleClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Overlay */}
<div
aria-hidden="true"
className="absolute inset-0 bg-black/60 cursor-default"
onClick={handleClose}
/>
{/* Modal card */}
<div
role="dialog"
aria-modal="true"
className="relative z-10 w-full max-w-lg bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4"
>
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
<Link className="h-5 w-5 text-primary" />
<h2 className="text-sm font-semibold text-foreground flex-1">Import from URLs</h2>
<button
type="button"
onClick={handleClose}
aria-label="Close"
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="px-4 py-3 flex flex-col gap-3">
{/* Textarea */}
<textarea
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)"
}
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"
/>
<p className="text-xs text-muted-foreground">
Supports plain URLs, bulleted lists, numbered lists, and markdown links
</p>
{/* Progress list */}
{hasResults && (
<div className="max-h-[200px] overflow-y-auto rounded-lg border border-border divide-y divide-border">
{entries.map((entry, i) => (
<div key={entry.url} className="flex items-center gap-2.5 px-3 py-2 text-sm">
<StatusIcon status={entry.status} />
<span className="flex-1 truncate text-foreground">
{entry.filename || filenameFromUrl(entry.url)}
</span>
{entry.status === "failed" && (
<button
type="button"
onClick={() => retryUrl(i)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
title="Retry"
>
<RotateCw className="h-3.5 w-3.5" />
</button>
)}
{entry.status === "ready" && entry.size != null && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatSize(entry.size)}
</span>
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border shrink-0">
<span className="text-xs text-muted-foreground">
{hasResults && !importing ? `${readyCount} of ${entries.length} ready` : ""}
</span>
<div className="flex items-center gap-2">
{hasResults && !importing ? (
<>
<button
type="button"
onClick={handleBack}
className="px-4 py-2 text-sm rounded-lg border border-border text-foreground hover:bg-muted"
>
Back
</button>
<button
type="button"
onClick={handleAdd}
disabled={readyCount === 0 || adding}
className="px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{adding ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Adding...
</>
) : (
<>
Add {readyCount} Image{readyCount !== 1 ? "s" : ""}
</>
)}
</button>
</>
) : (
<>
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm rounded-lg border border-border text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="button"
onClick={handleImport}
disabled={text.trim().length === 0 || importing}
className="px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{importing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Importing...
</>
) : (
"Import"
)}
</button>
</>
)}
</div>
</div>
</div>
</div>
);
}