diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 424276fe..36ed3935 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -23,6 +23,7 @@ import { auditLogRoutes } from "./routes/audit-log.js";
import { registerBatchRoutes } from "./routes/batch.js";
import { docsRoutes } from "./routes/docs.js";
import { registerFeatureRoutes } from "./routes/features.js";
+import { registerFetchUrlsRoute } from "./routes/fetch-urls.js";
import { fileRoutes } from "./routes/files.js";
import { registerMemeTemplates } from "./routes/meme-templates.js";
import { registerPipelineRoutes } from "./routes/pipeline.js";
@@ -165,6 +166,9 @@ await registerToolRoutes(app);
// Batch processing routes (must be after tool routes so the registry is populated)
await registerBatchRoutes(app);
+// URL fetch routes (server-side image fetching with SSRF protection)
+await registerFetchUrlsRoute(app);
+
// Pipeline routes (must be after tool routes so the registry is populated)
await registerPipelineRoutes(app);
diff --git a/apps/api/src/lib/ssrf.ts b/apps/api/src/lib/ssrf.ts
new file mode 100644
index 00000000..e49a5c93
--- /dev/null
+++ b/apps/api/src/lib/ssrf.ts
@@ -0,0 +1,96 @@
+import { lookup } from "node:dns/promises";
+import { isIP } from "node:net";
+
+function isPrivateIPv4(ip: string): boolean {
+ const parts = ip.split(".").map(Number);
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return false;
+ const [a, b] = parts;
+ if (a === 10) return true;
+ if (a === 172 && b >= 16 && b <= 31) return true;
+ if (a === 192 && b === 168) return true;
+ if (a === 127) return true;
+ if (a === 169 && b === 254) return true;
+ if (a === 0) return true;
+ if (a === 100 && b >= 64 && b <= 127) return true;
+ if (a === 192 && b === 0 && parts[2] === 0) return true;
+ if (a === 198 && (b === 18 || b === 19)) return true;
+ if (a >= 240) return true;
+ return false;
+}
+
+function isPrivateIPv6(ip: string): boolean {
+ const normalized = ip.replace(/^\[|]$/g, "").toLowerCase();
+ if (normalized === "::1") return true;
+ if (normalized === "::") return true;
+ if (normalized.startsWith("fe80:")) return true;
+ if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
+ if (normalized.startsWith("2001:db8:")) return true;
+ if (normalized.includes("::ffff:")) {
+ const v4 = normalized.split("::ffff:")[1];
+ if (v4 && isPrivateIPv4(v4)) return true;
+ }
+ return false;
+}
+
+async function resolveAndCheck(hostname: string): Promise {
+ const bare = hostname.replace(/^\[|]$/g, "");
+ if (isIP(bare)) {
+ if (isPrivateIPv4(bare) || isPrivateIPv6(bare)) {
+ throw new Error("URL resolves to a private or reserved IP address");
+ }
+ return;
+ }
+
+ const result = await lookup(hostname, { all: true });
+ const addresses = Array.isArray(result) ? result : [result];
+ for (const entry of addresses) {
+ const addr = entry.address;
+ if (isPrivateIPv4(addr) || isPrivateIPv6(addr)) {
+ throw new Error("URL resolves to a private or reserved IP address");
+ }
+ }
+}
+
+export async function validateFetchUrl(url: string): Promise {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ throw new Error("Invalid URL");
+ }
+
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw new Error("Only HTTP and HTTPS URLs are supported");
+ }
+
+ await resolveAndCheck(parsed.hostname);
+}
+
+export const MAX_REDIRECTS = 5;
+export const FETCH_TIMEOUT_MS = 30_000;
+export const MAX_URL_FETCH_SIZE = 50 * 1024 * 1024;
+export const MAX_URLS_PER_REQUEST = 50;
+export const URL_FETCH_CONCURRENCY = 4;
+
+export async function safeFetch(url: string, signal?: AbortSignal): Promise {
+ let currentUrl = url;
+ for (let i = 0; i <= MAX_REDIRECTS; i++) {
+ await validateFetchUrl(currentUrl);
+ const res = await fetch(currentUrl, {
+ signal,
+ redirect: "manual",
+ headers: { "User-Agent": "SnapOtter/1.0 (image-fetch)" },
+ });
+
+ if (res.status >= 300 && res.status < 400) {
+ const location = res.headers.get("location");
+ if (!location) throw new Error("Redirect without Location header");
+ await res.body?.cancel();
+ currentUrl = new URL(location, currentUrl).href;
+ continue;
+ }
+
+ return res;
+ }
+ throw new Error("Too many redirects");
+}
diff --git a/apps/api/src/routes/fetch-urls.ts b/apps/api/src/routes/fetch-urls.ts
new file mode 100644
index 00000000..82362940
--- /dev/null
+++ b/apps/api/src/routes/fetch-urls.ts
@@ -0,0 +1,279 @@
+/**
+ * Fetch URLs route.
+ *
+ * POST /api/v1/fetch-urls
+ *
+ * Accepts a JSON body with { urls: string[] } (1-50 URLs).
+ * Fetches each URL server-side with SSRF protection, validates as an image,
+ * saves to a workspace, generates a preview for non-browser formats, and
+ * returns results with download URLs.
+ */
+import { randomUUID } from "node:crypto";
+import { writeFile } from "node:fs/promises";
+import { basename, join } from "node:path";
+import type { FastifyInstance } from "fastify";
+import PQueue from "p-queue";
+import sharp from "sharp";
+import { z } from "zod";
+import { validateImageBuffer } from "../lib/file-validation.js";
+import { sanitizeFilename } from "../lib/filename.js";
+import {
+ FETCH_TIMEOUT_MS,
+ MAX_URL_FETCH_SIZE,
+ MAX_URLS_PER_REQUEST,
+ safeFetch,
+ URL_FETCH_CONCURRENCY,
+} from "../lib/ssrf.js";
+import { createWorkspace } from "../lib/workspace.js";
+
+/** Formats browsers can display natively (no preview needed). */
+const BROWSER_PREVIEWABLE = new Set([
+ "image/jpeg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+ "image/svg+xml",
+ "image/bmp",
+ "image/avif",
+]);
+
+/** Map detected format string to MIME type. */
+const FORMAT_TO_MIME: Record = {
+ jpeg: "image/jpeg",
+ png: "image/png",
+ gif: "image/gif",
+ webp: "image/webp",
+ svg: "image/svg+xml",
+ bmp: "image/bmp",
+ avif: "image/avif",
+ tiff: "image/tiff",
+ heif: "image/heic",
+ jxl: "image/jxl",
+ ico: "image/x-icon",
+ psd: "image/vnd.adobe.photoshop",
+ raw: "image/x-dcraw",
+ tga: "image/x-tga",
+ exr: "image/x-exr",
+ hdr: "image/vnd.radiance",
+ jp2: "image/jp2",
+ qoi: "image/x-qoi",
+ eps: "application/postscript",
+ dds: "image/x-dds",
+ cur: "image/x-icon",
+ dpx: "image/x-dpx",
+ fits: "image/fits",
+ ppm: "image/x-portable-pixmap",
+ pgm: "image/x-portable-graymap",
+ pbm: "image/x-portable-bitmap",
+ pfm: "image/x-portable-floatmap",
+};
+
+const fetchUrlsSchema = z.object({
+ urls: z
+ .array(z.string().url("Each entry must be a valid URL"))
+ .min(1, "At least one URL is required")
+ .max(MAX_URLS_PER_REQUEST, `Maximum ${MAX_URLS_PER_REQUEST} URLs per request`),
+});
+
+interface SuccessResult {
+ success: true;
+ url: string;
+ filename: string;
+ contentType: string;
+ size: number;
+ width: number;
+ height: number;
+ downloadUrl: string;
+ previewUrl: string | null;
+}
+
+interface FailureResult {
+ success: false;
+ url: string;
+ error: string;
+}
+
+type FetchResult = SuccessResult | FailureResult;
+
+/**
+ * Extract a usable filename from a URL path, falling back to a UUID-based name.
+ */
+function filenameFromUrl(url: string): string {
+ try {
+ const pathname = new URL(url).pathname;
+ const base = basename(pathname);
+ // Decode percent-encoded characters
+ const decoded = decodeURIComponent(base);
+ // Only use it if it looks like a file with an extension
+ if (decoded?.includes(".") && decoded.length <= 255) {
+ return decoded;
+ }
+ } catch {
+ // ignore parse errors
+ }
+ return `image-${randomUUID().slice(0, 8)}`;
+}
+
+/**
+ * Return a filename that does not collide with any name already in `used`.
+ * Appends `_1`, `_2`, etc. before the extension when a collision is found.
+ * Mirrors the deduplication logic in batch.ts.
+ */
+function getUniqueName(name: string, used: Set): string {
+ if (!used.has(name)) {
+ used.add(name);
+ return name;
+ }
+ const dotIdx = name.lastIndexOf(".");
+ const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
+ const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
+ let counter = 1;
+ let candidate = `${base}_${counter}${ext}`;
+ while (used.has(candidate)) {
+ counter++;
+ candidate = `${base}_${counter}${ext}`;
+ }
+ used.add(candidate);
+ return candidate;
+}
+
+export async function registerFetchUrlsRoute(app: FastifyInstance): Promise {
+ app.post("/api/v1/fetch-urls", async (request, reply) => {
+ // Validate body
+ const parsed = fetchUrlsSchema.safeParse(request.body);
+ if (!parsed.success) {
+ const messages = parsed.error.issues.map((i) => i.message).join("; ");
+ return reply.status(400).send({ error: messages });
+ }
+
+ const { urls } = parsed.data;
+ const jobId = randomUUID();
+ const workspace = await createWorkspace(jobId);
+ const outputDir = join(workspace, "output");
+
+ const queue = new PQueue({ concurrency: URL_FETCH_CONCURRENCY });
+
+ // Track filenames to prevent collisions when multiple URLs resolve to the
+ // same name (e.g. https://a.com/photo.jpg and https://b.com/photo.jpg).
+ const usedFilenames = new Set();
+
+ // Pre-allocate result slots to preserve order
+ const resultSlots: FetchResult[] = new Array(urls.length);
+
+ await Promise.all(
+ urls.map((url, index) =>
+ queue.add(async () => {
+ resultSlots[index] = await fetchSingleUrl(url, jobId, outputDir, usedFilenames);
+ }),
+ ),
+ );
+
+ return reply.send({ results: resultSlots });
+ });
+}
+
+async function fetchSingleUrl(
+ url: string,
+ jobId: string,
+ outputDir: string,
+ usedFilenames: Set,
+): Promise {
+ try {
+ // Fetch with SSRF protection and timeout
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
+
+ let response: Response;
+ try {
+ response = await safeFetch(url, controller.signal);
+ } finally {
+ clearTimeout(timeout);
+ }
+
+ if (!response.ok) {
+ return {
+ success: false,
+ url,
+ error: `HTTP ${response.status} ${response.statusText}`,
+ };
+ }
+
+ // Read body with size limit
+ const chunks: Uint8Array[] = [];
+ let totalSize = 0;
+
+ if (!response.body) {
+ return { success: false, url, error: "Empty response body" };
+ }
+
+ const reader = response.body.getReader();
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ totalSize += value.byteLength;
+ if (totalSize > MAX_URL_FETCH_SIZE) {
+ reader.cancel();
+ return {
+ success: false,
+ url,
+ error: `File exceeds maximum size of ${MAX_URL_FETCH_SIZE / (1024 * 1024)}MB`,
+ };
+ }
+ chunks.push(value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+
+ const buffer = Buffer.concat(chunks);
+ if (buffer.length === 0) {
+ return { success: false, url, error: "Empty response body" };
+ }
+
+ // Derive filename from URL, deduplicating to prevent overwrites when
+ // multiple URLs resolve to the same name (all URLs share one workspace).
+ const rawFilename = filenameFromUrl(url);
+ const filename = getUniqueName(sanitizeFilename(rawFilename), usedFilenames);
+
+ // Validate as an image
+ const validation = await validateImageBuffer(buffer, filename);
+ if (!validation.valid) {
+ return { success: false, url, error: validation.reason };
+ }
+
+ // Save to output directory
+ await writeFile(join(outputDir, filename), buffer);
+
+ const contentType = FORMAT_TO_MIME[validation.format] ?? "application/octet-stream";
+ const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`;
+
+ // Generate preview for non-browser formats
+ let previewUrl: string | null = null;
+ if (!BROWSER_PREVIEWABLE.has(contentType)) {
+ try {
+ const previewBuffer = await sharp(buffer).webp({ quality: 80 }).toBuffer();
+ const previewFilename = `preview-${filename.replace(/\.[^.]+$/, "")}.webp`;
+ await writeFile(join(outputDir, previewFilename), previewBuffer);
+ previewUrl = `/api/v1/download/${jobId}/${encodeURIComponent(previewFilename)}`;
+ } catch {
+ // Preview generation failed -- non-fatal, skip preview
+ }
+ }
+
+ return {
+ success: true,
+ url,
+ filename,
+ contentType,
+ size: buffer.length,
+ width: validation.width,
+ height: validation.height,
+ downloadUrl,
+ previewUrl,
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ return { success: false, url, error: message };
+ }
+}
diff --git a/apps/web/src/components/common/dropzone.tsx b/apps/web/src/components/common/dropzone.tsx
index f99acc6e..aa5b68ec 100644
--- a/apps/web/src/components/common/dropzone.tsx
+++ b/apps/web/src/components/common/dropzone.tsx
@@ -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(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
+ {!compact && onUrlImport && (
+ <>
+
+
+ {
+ 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}
+ />
+
+
+ {urlError && {urlError}
}
+
+ >
+ )}
+
{hasMultipleFiles && (
@@ -244,6 +321,16 @@ export function Dropzone({
)}
+
+ {showBulkModal && (
+ setShowBulkModal(false)}
+ onImport={(files) => {
+ for (const file of files) onUrlImport?.(file);
+ setShowBulkModal(false);
+ }}
+ />
+ )}
);
}
diff --git a/apps/web/src/components/common/url-import-modal.tsx b/apps/web/src/components/common/url-import-modal.tsx
new file mode 100644
index 00000000..2913a89f
--- /dev/null
+++ b/apps/web/src/components/common/url-import-modal.tsx
@@ -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 ;
+ case "fetching":
+ return ;
+ case "ready":
+ return ;
+ case "failed":
+ return ;
+ }
+}
+
+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 (
+
+ {/* Overlay */}
+
+
+ {/* Modal card */}
+
+ {/* Header */}
+
+
+
Import from URLs
+
+
+
+ {/* Body */}
+
+
+ {/* Footer */}
+
+
+ {hasResults && !importing ? `${readyCount} of ${entries.length} ready` : ""}
+
+
+ {hasResults && !importing ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/hooks/use-url-import.ts b/apps/web/src/hooks/use-url-import.ts
new file mode 100644
index 00000000..a6dcb375
--- /dev/null
+++ b/apps/web/src/hooks/use-url-import.ts
@@ -0,0 +1,220 @@
+import { useCallback, useRef, useState } from "react";
+import { formatHeaders } from "@/lib/api";
+
+// ── Types ──────────────────────────────────────────────────────
+
+export interface UrlImportEntry {
+ url: string;
+ status: "pending" | "fetching" | "ready" | "failed";
+ filename?: string;
+ size?: number;
+ width?: number;
+ height?: number;
+ downloadUrl?: string;
+ previewUrl?: string | null;
+ error?: string;
+}
+
+interface FetchUrlResult {
+ success: boolean;
+ url: string;
+ filename?: string;
+ contentType?: string;
+ size?: number;
+ width?: number;
+ height?: number;
+ downloadUrl?: string;
+ previewUrl?: string | null;
+ error?: string;
+}
+
+interface FetchUrlsResponse {
+ results: FetchUrlResult[];
+}
+
+// ── Hook ───────────────────────────────────────────────────────
+
+export function useUrlImport() {
+ const [entries, setEntries] = useState([]);
+ const [importing, setImporting] = useState(false);
+ const abortRef = useRef(null);
+
+ // -- helpers --
+
+ const fetchUrls = useCallback(
+ async (urls: string[], signal?: AbortSignal): Promise => {
+ const headers = formatHeaders();
+ headers.set("Content-Type", "application/json");
+ const res = await fetch("/api/v1/fetch-urls", {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ urls }),
+ signal,
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error((body as Record).error || `Fetch failed: ${res.status}`);
+ }
+ return res.json();
+ },
+ [],
+ );
+
+ const resultToEntry = useCallback((result: FetchUrlResult): UrlImportEntry => {
+ if (result.success) {
+ return {
+ url: result.url,
+ status: "ready",
+ filename: result.filename,
+ size: result.size,
+ width: result.width,
+ height: result.height,
+ downloadUrl: result.downloadUrl,
+ previewUrl: result.previewUrl,
+ };
+ }
+ return {
+ url: result.url,
+ status: "failed",
+ error: result.error,
+ };
+ }, []);
+
+ const downloadAsFile = useCallback(
+ async (downloadUrl: string, filename: string, signal?: AbortSignal): Promise => {
+ const res = await fetch(downloadUrl, { headers: formatHeaders(), signal });
+ if (!res.ok) throw new Error(`Download failed: ${res.status}`);
+ const blob = await res.blob();
+ return new File([blob], filename, { type: blob.type });
+ },
+ [],
+ );
+
+ // -- public API --
+
+ const importUrls = useCallback(
+ async (urls: string[]) => {
+ if (urls.length === 0) return;
+
+ abortRef.current?.abort();
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ setEntries(urls.map((url) => ({ url, status: "fetching" })));
+ setImporting(true);
+
+ try {
+ const { results } = await fetchUrls(urls, controller.signal);
+
+ if (controller.signal.aborted) return;
+
+ setEntries(results.map(resultToEntry));
+ } catch (err) {
+ if ((err as Error).name === "AbortError") return;
+
+ setEntries(
+ urls.map((url) => ({
+ url,
+ status: "failed" as const,
+ error: (err as Error).message,
+ })),
+ );
+ } finally {
+ if (!controller.signal.aborted) {
+ setImporting(false);
+ }
+ }
+ },
+ [fetchUrls, resultToEntry],
+ );
+
+ const importSingleUrl = useCallback(
+ async (url: string): Promise => {
+ const controller = new AbortController();
+ abortRef.current = controller;
+ try {
+ const { results } = await fetchUrls([url], controller.signal);
+ const result = results[0];
+ if (!result?.success || !result.downloadUrl || !result.filename) return null;
+ return await downloadAsFile(result.downloadUrl, result.filename, controller.signal);
+ } catch {
+ return null;
+ }
+ },
+ [fetchUrls, downloadAsFile],
+ );
+
+ const addReadyFiles = useCallback(async (): Promise => {
+ const ready = entries.filter(
+ (e): e is UrlImportEntry & { downloadUrl: string; filename: string } =>
+ e.status === "ready" && !!e.downloadUrl && !!e.filename,
+ );
+
+ const settled = await Promise.allSettled(
+ ready.map((e) => downloadAsFile(e.downloadUrl, e.filename)),
+ );
+
+ return settled
+ .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled")
+ .map((r) => r.value);
+ }, [entries, downloadAsFile]);
+
+ const retryUrl = useCallback(
+ async (index: number) => {
+ let url: string | undefined;
+ setEntries((prev) => {
+ url = prev[index]?.url;
+ if (!url) return prev;
+ return prev.map((e, i) =>
+ i === index ? { ...e, status: "fetching" as const, error: undefined } : e,
+ );
+ });
+ if (!url) return;
+
+ try {
+ const { results } = await fetchUrls([url]);
+ const result = results[0];
+ if (!result) return;
+
+ setEntries((prev) => prev.map((e, i) => (i === index ? resultToEntry(result) : e)));
+ } catch (err) {
+ setEntries((prev) =>
+ prev.map((e, i) =>
+ i === index ? { ...e, status: "failed" as const, error: (err as Error).message } : e,
+ ),
+ );
+ }
+ },
+ [fetchUrls, resultToEntry],
+ );
+
+ const cancel = useCallback(() => {
+ abortRef.current?.abort();
+ abortRef.current = null;
+ setEntries([]);
+ setImporting(false);
+ }, []);
+
+ const reset = useCallback(() => {
+ setEntries([]);
+ setImporting(false);
+ }, []);
+
+ // -- derived counts --
+
+ const readyCount = entries.filter((e) => e.status === "ready").length;
+ const failedCount = entries.filter((e) => e.status === "failed").length;
+
+ return {
+ entries,
+ importing,
+ importUrls,
+ importSingleUrl,
+ addReadyFiles,
+ retryUrl,
+ cancel,
+ reset,
+ readyCount,
+ failedCount,
+ };
+}
diff --git a/apps/web/src/lib/url-parser.ts b/apps/web/src/lib/url-parser.ts
new file mode 100644
index 00000000..fc750ce9
--- /dev/null
+++ b/apps/web/src/lib/url-parser.ts
@@ -0,0 +1,42 @@
+function isValidHttpUrl(str: string): boolean {
+ try {
+ const url = new URL(str);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+export function extractUrls(input: string): string[] {
+ const urls: string[] = [];
+
+ for (const rawLine of input.split("\n")) {
+ let line = rawLine.trim();
+ if (!line) continue;
+
+ // Strip numbered list prefixes: "1. ", "2) ", "3 "
+ line = line.replace(/^\d+[.)]?\s+/, "");
+ // Strip bullet prefixes: "- ", "* ", "+ "
+ line = line.replace(/^[-*+]\s+/, "");
+
+ // Extract from markdown links: [text](url)
+ const mdMatch = line.match(/\[.*?]\((https?:\/\/[^)]+)\)/);
+ if (mdMatch) {
+ urls.push(mdMatch[1]);
+ continue;
+ }
+
+ // Extract from HTML img tags:
+ const imgMatch = line.match(/
]+src=["'](https?:\/\/[^"']+)["']/i);
+ if (imgMatch) {
+ urls.push(imgMatch[1]);
+ continue;
+ }
+
+ if (isValidHttpUrl(line)) {
+ urls.push(line);
+ }
+ }
+
+ return [...new Set(urls)];
+}
diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx
index 1a8f0af8..389f0e2a 100644
--- a/apps/web/src/pages/tool-page.tsx
+++ b/apps/web/src/pages/tool-page.tsx
@@ -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 ;
+ return (
+
+ );
const ResultsPanel = registryEntry.ResultsPanel;
return (
Loading...}>
@@ -637,7 +652,15 @@ export function ToolPage() {
);
}
- return ;
+ return (
+
+ );
}
// Navigation arrows (shared between mobile/desktop)
diff --git a/tests/e2e/url-import.spec.ts b/tests/e2e/url-import.spec.ts
new file mode 100644
index 00000000..3dc72c76
--- /dev/null
+++ b/tests/e2e/url-import.spec.ts
@@ -0,0 +1,25 @@
+import { expect, test } from "./helpers";
+
+test.describe("URL Image Import", () => {
+ test("inline URL input is visible on tool page", async ({ loggedInPage: page }) => {
+ await page.goto("/resize");
+
+ await expect(page.getByPlaceholder("Paste image URL...")).toBeVisible();
+ });
+
+ test("bulk import modal opens and closes", async ({ loggedInPage: page }) => {
+ await page.goto("/resize");
+
+ // Open the bulk import modal
+ await page.getByText("Import multiple URLs...").click();
+
+ // Assert the modal title is visible
+ await expect(page.getByText("Import from URLs")).toBeVisible();
+
+ // Close the modal via Cancel
+ await page.getByRole("button", { name: "Cancel" }).click();
+
+ // Assert the modal title is no longer visible
+ await expect(page.getByText("Import from URLs")).not.toBeVisible();
+ });
+});
diff --git a/tests/integration/fetch-urls.test.ts b/tests/integration/fetch-urls.test.ts
new file mode 100644
index 00000000..967ea13b
--- /dev/null
+++ b/tests/integration/fetch-urls.test.ts
@@ -0,0 +1,496 @@
+/**
+ * Integration tests for the fetch-urls route.
+ *
+ * Spins up a local HTTP server to serve test fixtures, and mocks the SSRF
+ * validation to allow localhost connections during tests.
+ */
+
+import { readFileSync } from "node:fs";
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+
+// Mock the SSRF validation to allow localhost in tests.
+// We keep the real safeFetch logic but skip the private-IP DNS check.
+vi.mock("../../apps/api/src/lib/ssrf.js", async (importOriginal) => {
+ const original = (await importOriginal()) as Record;
+ return {
+ ...original,
+ // validateFetchUrl that allows localhost for tests
+ validateFetchUrl: async (_url: string) => {
+ // No-op: allow all URLs in tests (including localhost)
+ },
+ // safeFetch that skips SSRF validation but still does the real fetch
+ safeFetch: async (url: string, signal?: AbortSignal) => {
+ const MAX_REDIRECTS = 5;
+ let currentUrl = url;
+ for (let i = 0; i <= MAX_REDIRECTS; i++) {
+ const res = await fetch(currentUrl, {
+ signal,
+ redirect: "manual",
+ headers: { "User-Agent": "SnapOtter/1.0 (image-fetch)" },
+ });
+ if (res.status >= 300 && res.status < 400) {
+ const location = res.headers.get("location");
+ if (!location) throw new Error("Redirect without Location header");
+ currentUrl = new URL(location, currentUrl).href;
+ if (i === MAX_REDIRECTS) throw new Error("Too many redirects");
+ continue;
+ }
+ return res;
+ }
+ throw new Error("Too many redirects");
+ },
+ };
+});
+
+import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
+
+const FIXTURES = join(__dirname, "..", "fixtures");
+const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
+const TIFF = readFileSync(join(FIXTURES, "formats", "sample.tiff"));
+
+let testApp: TestApp;
+let app: TestApp["app"];
+let adminToken: string;
+let mockServer: Server;
+let mockPort: number;
+
+function startMockServer(): Promise<{ server: Server; port: number }> {
+ return new Promise((resolve) => {
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
+ const url = req.url ?? "";
+
+ if (url === "/photo.jpg") {
+ res.writeHead(200, { "Content-Type": "image/jpeg" });
+ res.end(JPG);
+ return;
+ }
+
+ if (url === "/not-image.txt") {
+ res.writeHead(200, { "Content-Type": "text/plain" });
+ res.end("This is not an image");
+ return;
+ }
+
+ if (url === "/redirect") {
+ res.writeHead(302, { Location: "/photo.jpg" });
+ res.end();
+ return;
+ }
+
+ if (url === "/missing.jpg") {
+ res.writeHead(404);
+ res.end("Not Found");
+ return;
+ }
+
+ if (url === "/photo.tiff") {
+ res.writeHead(200, { "Content-Type": "image/tiff" });
+ res.end(TIFF);
+ return;
+ }
+
+ if (url === "/empty") {
+ res.writeHead(200, { "Content-Type": "image/jpeg" });
+ res.end();
+ return;
+ }
+
+ if (url === "/server-error") {
+ res.writeHead(500, { "Content-Type": "text/plain" });
+ res.end("Internal Server Error");
+ return;
+ }
+
+ if (url === "/slow-close") {
+ // Return a valid response with no body stream at all
+ res.writeHead(200, { "Content-Type": "image/jpeg", "Content-Length": "0" });
+ res.end();
+ return;
+ }
+
+ res.writeHead(404);
+ res.end("Not Found");
+ });
+
+ server.listen(0, "127.0.0.1", () => {
+ const addr = server.address();
+ const port = typeof addr === "object" && addr ? addr.port : 0;
+ resolve({ server, port });
+ });
+ });
+}
+
+beforeAll(async () => {
+ const mock = await startMockServer();
+ mockServer = mock.server;
+ mockPort = mock.port;
+
+ testApp = await buildTestApp();
+ app = testApp.app;
+ adminToken = await loginAsAdmin(app);
+}, 30_000);
+
+afterAll(async () => {
+ await testApp.cleanup();
+ await new Promise((resolve) => mockServer.close(() => resolve()));
+}, 10_000);
+
+describe("POST /api/v1/fetch-urls", () => {
+ it("fetches a valid image URL and returns metadata + download URL", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [`http://127.0.0.1:${mockPort}/photo.jpg`],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(1);
+
+ const result = body.results[0];
+ expect(result.success).toBe(true);
+ expect(result.url).toBe(`http://127.0.0.1:${mockPort}/photo.jpg`);
+ expect(result.filename).toBe("photo.jpg");
+ expect(result.contentType).toBe("image/jpeg");
+ expect(result.size).toBeGreaterThan(0);
+ expect(result.width).toBe(100);
+ expect(result.height).toBe(100);
+ expect(result.downloadUrl).toMatch(/^\/api\/v1\/download\/.+\/photo\.jpg$/);
+ expect(result.previewUrl).toBeNull(); // JPEG is browser-previewable
+ });
+
+ it("returns failure for a 404 URL", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [`http://127.0.0.1:${mockPort}/missing.jpg`],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(1);
+
+ const result = body.results[0];
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("404");
+ });
+
+ it("returns failure for non-image content", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [`http://127.0.0.1:${mockPort}/not-image.txt`],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(1);
+
+ const result = body.results[0];
+ expect(result.success).toBe(false);
+ expect(result.error).toBeTruthy();
+ });
+
+ it("handles mixed batch with successes and failures", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [
+ `http://127.0.0.1:${mockPort}/photo.jpg`,
+ `http://127.0.0.1:${mockPort}/missing.jpg`,
+ `http://127.0.0.1:${mockPort}/not-image.txt`,
+ ],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(3);
+
+ // Results preserve order
+ expect(body.results[0].success).toBe(true);
+ expect(body.results[0].filename).toBe("photo.jpg");
+
+ expect(body.results[1].success).toBe(false);
+ expect(body.results[1].error).toContain("404");
+
+ expect(body.results[2].success).toBe(false);
+ });
+
+ it("returns 400 for an empty URL array", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [],
+ },
+ });
+
+ expect(res.statusCode).toBe(400);
+ const body = JSON.parse(res.body);
+ expect(body.error).toBeTruthy();
+ });
+
+ it("returns 400 for more than 50 URLs", async () => {
+ const urls = Array.from({ length: 51 }, (_, i) => `http://example.com/img${i}.jpg`);
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: { urls },
+ });
+
+ expect(res.statusCode).toBe(400);
+ const body = JSON.parse(res.body);
+ expect(body.error).toBeTruthy();
+ });
+
+ it("follows redirects to fetch the final image", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [`http://127.0.0.1:${mockPort}/redirect`],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(1);
+
+ const result = body.results[0];
+ expect(result.success).toBe(true);
+ expect(result.contentType).toBe("image/jpeg");
+ expect(result.size).toBe(JPG.length);
+ });
+
+ it("download URL serves the actual image", async () => {
+ // First, fetch the URL to get a downloadUrl
+ const fetchRes = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [`http://127.0.0.1:${mockPort}/photo.jpg`],
+ },
+ });
+
+ const body = JSON.parse(fetchRes.body);
+ const downloadUrl = body.results[0].downloadUrl;
+ expect(downloadUrl).toBeTruthy();
+
+ // Now download the file
+ const downloadRes = await app.inject({
+ method: "GET",
+ url: downloadUrl,
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ });
+
+ expect(downloadRes.statusCode).toBe(200);
+ expect(downloadRes.headers["content-type"]).toBe("image/jpeg");
+ // The downloaded buffer should match the original fixture
+ expect(downloadRes.rawPayload.length).toBe(JPG.length);
+ });
+
+ it("deduplicates filenames when multiple URLs resolve to the same name", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [`http://127.0.0.1:${mockPort}/photo.jpg`, `http://127.0.0.1:${mockPort}/photo.jpg`],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(2);
+
+ expect(body.results[0].success).toBe(true);
+ expect(body.results[1].success).toBe(true);
+
+ // Filenames must differ so one does not overwrite the other
+ const names = [body.results[0].filename, body.results[1].filename];
+ expect(new Set(names).size).toBe(2);
+ expect(names).toContain("photo.jpg");
+ expect(names).toContain("photo_1.jpg");
+
+ // Download URLs must also differ
+ expect(body.results[0].downloadUrl).not.toBe(body.results[1].downloadUrl);
+
+ // Both download URLs should serve valid content
+ for (const result of body.results) {
+ const dl = await app.inject({
+ method: "GET",
+ url: result.downloadUrl,
+ headers: { authorization: `Bearer ${adminToken}` },
+ });
+ expect(dl.statusCode).toBe(200);
+ expect(dl.rawPayload.length).toBe(JPG.length);
+ }
+ });
+
+ it("returns 400 for invalid URL format", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: ["not-a-valid-url"],
+ },
+ });
+
+ expect(res.statusCode).toBe(400);
+ const body = JSON.parse(res.body);
+ expect(body.error).toBeTruthy();
+ });
+
+ it("generates a preview for non-browser-previewable formats", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [`http://127.0.0.1:${mockPort}/photo.tiff`],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(1);
+
+ const result = body.results[0];
+ expect(result.success).toBe(true);
+ expect(result.contentType).toBe("image/tiff");
+ expect(result.previewUrl).toBeTruthy();
+ expect(result.previewUrl).toContain("preview-");
+ expect(result.previewUrl).toContain(".webp");
+
+ // Preview URL should serve a valid webp image
+ const previewRes = await app.inject({
+ method: "GET",
+ url: result.previewUrl,
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ });
+ expect(previewRes.statusCode).toBe(200);
+ });
+
+ it("returns failure for empty response body", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [`http://127.0.0.1:${mockPort}/empty`],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(1);
+ expect(body.results[0].success).toBe(false);
+ expect(body.results[0].error).toContain("Empty");
+ });
+
+ it("returns failure for 500 server error", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [`http://127.0.0.1:${mockPort}/server-error`],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(1);
+ expect(body.results[0].success).toBe(false);
+ expect(body.results[0].error).toContain("500");
+ });
+
+ it("returns failure when fetch throws a network error", async () => {
+ // Port 1 is almost guaranteed to refuse connections, triggering the outer
+ // catch block (lines 275-278 in fetch-urls.ts).
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: ["http://127.0.0.1:1/unreachable.jpg"],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(1);
+ expect(body.results[0].success).toBe(false);
+ expect(body.results[0].error).toBeTruthy();
+ });
+
+ it("returns failure for zero-length content", async () => {
+ const res = await app.inject({
+ method: "POST",
+ url: "/api/v1/fetch-urls",
+ headers: {
+ authorization: `Bearer ${adminToken}`,
+ },
+ payload: {
+ urls: [`http://127.0.0.1:${mockPort}/slow-close`],
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.results).toHaveLength(1);
+ expect(body.results[0].success).toBe(false);
+ expect(body.results[0].error).toContain("Empty");
+ });
+});
diff --git a/tests/integration/test-server.ts b/tests/integration/test-server.ts
index d3a24349..5e2d0464 100644
--- a/tests/integration/test-server.ts
+++ b/tests/integration/test-server.ts
@@ -37,6 +37,7 @@ import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js";
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
import { docsRoutes } from "../../apps/api/src/routes/docs.js";
+import { registerFetchUrlsRoute } from "../../apps/api/src/routes/fetch-urls.js";
import { fileRoutes } from "../../apps/api/src/routes/files.js";
import { registerMemeTemplates } from "../../apps/api/src/routes/meme-templates.js";
import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js";
@@ -100,6 +101,9 @@ export async function buildTestApp(): Promise {
// Batch processing routes
await registerBatchRoutes(app);
+ // URL fetch routes
+ await registerFetchUrlsRoute(app);
+
// Pipeline routes
await registerPipelineRoutes(app);
diff --git a/tests/unit/api/ssrf.test.ts b/tests/unit/api/ssrf.test.ts
new file mode 100644
index 00000000..e1eb4097
--- /dev/null
+++ b/tests/unit/api/ssrf.test.ts
@@ -0,0 +1,210 @@
+import { beforeEach, describe, expect, it, type Mock, vi } from "vitest";
+import { MAX_REDIRECTS, safeFetch, validateFetchUrl } from "../../../apps/api/src/lib/ssrf.js";
+
+describe("validateFetchUrl", () => {
+ it("allows valid public HTTP URL", async () => {
+ await expect(
+ validateFetchUrl("https://images.unsplash.com/photo.jpg"),
+ ).resolves.toBeUndefined();
+ });
+
+ it("allows valid public HTTP URL without TLS", async () => {
+ await expect(validateFetchUrl("http://example.com/image.png")).resolves.toBeUndefined();
+ });
+
+ it("rejects non-HTTP schemes", async () => {
+ await expect(validateFetchUrl("ftp://example.com/image.jpg")).rejects.toThrow(
+ "Only HTTP and HTTPS",
+ );
+ await expect(validateFetchUrl("file:///etc/passwd")).rejects.toThrow("Only HTTP and HTTPS");
+ await expect(validateFetchUrl("data:image/png;base64,abc")).rejects.toThrow(
+ "Only HTTP and HTTPS",
+ );
+ });
+
+ it("rejects localhost and loopback", async () => {
+ await expect(validateFetchUrl("http://127.0.0.1/image.jpg")).rejects.toThrow("private");
+ await expect(validateFetchUrl("http://localhost/image.jpg")).rejects.toThrow("private");
+ await expect(validateFetchUrl("http://[::1]/image.jpg")).rejects.toThrow("private");
+ });
+
+ it("rejects private network ranges", async () => {
+ await expect(validateFetchUrl("http://10.0.0.1/image.jpg")).rejects.toThrow("private");
+ await expect(validateFetchUrl("http://172.16.0.1/image.jpg")).rejects.toThrow("private");
+ await expect(validateFetchUrl("http://192.168.1.1/image.jpg")).rejects.toThrow("private");
+ });
+
+ it("rejects link-local addresses", async () => {
+ await expect(validateFetchUrl("http://169.254.169.254/latest/meta-data/")).rejects.toThrow(
+ "private",
+ );
+ });
+
+ it("rejects CG-NAT range (100.64.0.0/10)", async () => {
+ await expect(validateFetchUrl("http://100.64.0.1/image.jpg")).rejects.toThrow("private");
+ await expect(validateFetchUrl("http://100.127.255.255/image.jpg")).rejects.toThrow("private");
+ });
+
+ it("rejects IETF protocol assignments (192.0.0.0/24)", async () => {
+ await expect(validateFetchUrl("http://192.0.0.1/image.jpg")).rejects.toThrow("private");
+ });
+
+ it("rejects benchmarking range (198.18.0.0/15)", async () => {
+ await expect(validateFetchUrl("http://198.18.0.1/image.jpg")).rejects.toThrow("private");
+ await expect(validateFetchUrl("http://198.19.255.255/image.jpg")).rejects.toThrow("private");
+ });
+
+ it("rejects reserved/class E range (240.0.0.0/4)", async () => {
+ await expect(validateFetchUrl("http://240.0.0.1/image.jpg")).rejects.toThrow("private");
+ await expect(validateFetchUrl("http://255.255.255.255/image.jpg")).rejects.toThrow("private");
+ });
+
+ it("rejects IPv6 unspecified address", async () => {
+ await expect(validateFetchUrl("http://[::]/image.jpg")).rejects.toThrow("private");
+ });
+
+ it("rejects IPv6 documentation range (2001:db8::/32)", async () => {
+ await expect(validateFetchUrl("http://[2001:db8::1]/image.jpg")).rejects.toThrow("private");
+ await expect(validateFetchUrl("http://[2001:DB8::1]/image.jpg")).rejects.toThrow("private");
+ });
+
+ it("allows a public IP address directly in URL", async () => {
+ // Exercises the early-return path in resolveAndCheck when hostname is a
+ // non-private IP literal (covers the `return` after the isIP check).
+ await expect(validateFetchUrl("http://8.8.8.8/image.jpg")).resolves.toBeUndefined();
+ });
+
+ it("rejects invalid URLs", async () => {
+ await expect(validateFetchUrl("not-a-url")).rejects.toThrow();
+ await expect(validateFetchUrl("")).rejects.toThrow();
+ });
+});
+
+/**
+ * Tests that require DNS mocking to exercise resolveAndCheck paths that only
+ * trigger when the hostname is a non-IP string and lookup returns results.
+ */
+describe("validateFetchUrl with DNS mocking", () => {
+ const originalLookup = vi.hoisted(() => {
+ return { fn: null as null | ((...args: unknown[]) => unknown) };
+ });
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ vi.mock("node:dns/promises", async (importOriginal) => {
+ const orig = (await importOriginal()) as Record;
+ originalLookup.fn = orig.lookup as (...args: unknown[]) => unknown;
+ return {
+ ...orig,
+ lookup: vi.fn((...args: unknown[]) => originalLookup.fn?.(...args)),
+ };
+ });
+
+ it("rejects hostname that resolves to IPv4-mapped IPv6 with private IPv4", async () => {
+ // Covers isPrivateIPv6 lines 28-31 (::ffff: mapped address path)
+ const dns = await import("node:dns/promises");
+ vi.mocked(dns.lookup).mockResolvedValueOnce([
+ { address: "::ffff:127.0.0.1", family: 6 },
+ ] as never);
+ await expect(validateFetchUrl("http://mapped-v6.example.com/image.jpg")).rejects.toThrow(
+ "private",
+ );
+ });
+
+ it("rejects hostname resolving to IPv4-mapped IPv6 with 10.x private", async () => {
+ const dns = await import("node:dns/promises");
+ vi.mocked(dns.lookup).mockResolvedValueOnce([
+ { address: "::ffff:10.0.0.1", family: 6 },
+ ] as never);
+ await expect(validateFetchUrl("http://mapped-ten.example.com/image.jpg")).rejects.toThrow(
+ "private",
+ );
+ });
+
+ it("handles DNS lookup returning a single result object", async () => {
+ // Covers the Array.isArray fallback branch (line 45: wrapping non-array in [])
+ const dns = await import("node:dns/promises");
+ vi.mocked(dns.lookup).mockResolvedValueOnce({
+ address: "203.0.113.1",
+ family: 4,
+ } as never);
+ await expect(
+ validateFetchUrl("http://single-result.example.com/image.jpg"),
+ ).resolves.toBeUndefined();
+ });
+
+ it("rejects when DNS returns multiple addresses with one private", async () => {
+ const dns = await import("node:dns/promises");
+ vi.mocked(dns.lookup).mockResolvedValueOnce([
+ { address: "203.0.113.1", family: 4 },
+ { address: "10.0.0.1", family: 4 },
+ ] as never);
+ await expect(validateFetchUrl("http://dual-addr.example.com/image.jpg")).rejects.toThrow(
+ "private",
+ );
+ });
+});
+
+describe("safeFetch", () => {
+ let mockFetch: Mock;
+
+ beforeEach(() => {
+ mockFetch = vi.fn();
+ vi.stubGlobal("fetch", mockFetch);
+ });
+
+ function mockResponse(status: number, headers?: Record): Response {
+ return {
+ status,
+ headers: new Headers(headers),
+ body: { cancel: vi.fn() },
+ } as unknown as Response;
+ }
+
+ it("returns response for a direct (non-redirect) fetch", async () => {
+ mockFetch.mockResolvedValueOnce(mockResponse(200));
+ const res = await safeFetch("https://example.com/image.jpg");
+ expect(res.status).toBe(200);
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("follows a redirect chain within MAX_REDIRECTS", async () => {
+ // 3 redirects then a 200
+ mockFetch
+ .mockResolvedValueOnce(mockResponse(302, { location: "https://example.com/hop1" }))
+ .mockResolvedValueOnce(mockResponse(301, { location: "https://example.com/hop2" }))
+ .mockResolvedValueOnce(mockResponse(307, { location: "https://example.com/final" }))
+ .mockResolvedValueOnce(mockResponse(200));
+
+ const res = await safeFetch("https://example.com/start");
+ expect(res.status).toBe(200);
+ expect(mockFetch).toHaveBeenCalledTimes(4);
+ });
+
+ it("throws when redirect chain exceeds MAX_REDIRECTS", async () => {
+ // Return redirects for every call (MAX_REDIRECTS + 1 iterations, all redirects)
+ for (let i = 0; i <= MAX_REDIRECTS; i++) {
+ mockFetch.mockResolvedValueOnce(
+ mockResponse(302, { location: `https://example.com/hop${i + 1}` }),
+ );
+ }
+
+ await expect(safeFetch("https://example.com/start")).rejects.toThrow("Too many redirects");
+ });
+
+ it("rejects a redirect to a private IP", async () => {
+ mockFetch.mockResolvedValueOnce(mockResponse(302, { location: "http://127.0.0.1/evil" }));
+
+ await expect(safeFetch("https://example.com/image.jpg")).rejects.toThrow("private");
+ });
+
+ it("throws when redirect has no Location header", async () => {
+ mockFetch.mockResolvedValueOnce(mockResponse(302));
+
+ await expect(safeFetch("https://example.com/image.jpg")).rejects.toThrow(
+ "Redirect without Location header",
+ );
+ });
+});
diff --git a/tests/unit/web/url-parser.test.ts b/tests/unit/web/url-parser.test.ts
new file mode 100644
index 00000000..215c3861
--- /dev/null
+++ b/tests/unit/web/url-parser.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { extractUrls } from "../../../apps/web/src/lib/url-parser.js";
+
+describe("extractUrls", () => {
+ it("extracts plain URLs one per line", () => {
+ const input = "https://example.com/a.jpg\nhttps://example.com/b.png";
+ expect(extractUrls(input)).toEqual(["https://example.com/a.jpg", "https://example.com/b.png"]);
+ });
+
+ it("strips numbered list prefixes", () => {
+ const input =
+ "1. https://example.com/a.jpg\n2) https://example.com/b.png\n3 https://example.com/c.webp";
+ expect(extractUrls(input)).toEqual([
+ "https://example.com/a.jpg",
+ "https://example.com/b.png",
+ "https://example.com/c.webp",
+ ]);
+ });
+
+ it("strips bullet prefixes", () => {
+ const input =
+ "- https://example.com/a.jpg\n* https://example.com/b.png\n+ https://example.com/c.webp";
+ expect(extractUrls(input)).toEqual([
+ "https://example.com/a.jpg",
+ "https://example.com/b.png",
+ "https://example.com/c.webp",
+ ]);
+ });
+
+ it("extracts URLs from markdown links", () => {
+ const input = "[Photo 1](https://example.com/a.jpg)\n[Photo 2](https://example.com/b.png)";
+ expect(extractUrls(input)).toEqual(["https://example.com/a.jpg", "https://example.com/b.png"]);
+ });
+
+ it("extracts URLs from HTML img tags", () => {
+ const input = '
\n
';
+ expect(extractUrls(input)).toEqual(["https://example.com/a.jpg", "https://example.com/b.png"]);
+ });
+
+ it("handles mixed formats", () => {
+ const input = `1. https://example.com/a.jpg
+- [Photo](https://example.com/b.png)
+
+https://example.com/d.avif`;
+ expect(extractUrls(input)).toEqual([
+ "https://example.com/a.jpg",
+ "https://example.com/b.png",
+ "https://example.com/c.webp",
+ "https://example.com/d.avif",
+ ]);
+ });
+
+ it("deduplicates URLs", () => {
+ const input = "https://example.com/a.jpg\nhttps://example.com/a.jpg";
+ expect(extractUrls(input)).toEqual(["https://example.com/a.jpg"]);
+ });
+
+ it("filters out non-HTTP URLs", () => {
+ const input = "ftp://example.com/a.jpg\nhttps://example.com/b.png\nnot-a-url";
+ expect(extractUrls(input)).toEqual(["https://example.com/b.png"]);
+ });
+
+ it("returns empty array for empty input", () => {
+ expect(extractUrls("")).toEqual([]);
+ expect(extractUrls(" \n \n ")).toEqual([]);
+ });
+
+ it("preserves URLs with query parameters", () => {
+ const input = "https://example.com/photo?id=123&size=large";
+ expect(extractUrls(input)).toEqual(["https://example.com/photo?id=123&size=large"]);
+ });
+});