From 488ed956861eb191e9f6e5912ec85e9184015ccc Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:18:23 +0800 Subject: [PATCH 01/13] feat: add SSRF validation utility for URL fetch --- apps/api/src/lib/ssrf.ts | 90 +++++++++++++++++++++++++++++++++++++ tests/unit/api/ssrf.test.ts | 47 +++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 apps/api/src/lib/ssrf.ts create mode 100644 tests/unit/api/ssrf.test.ts diff --git a/apps/api/src/lib/ssrf.ts b/apps/api/src/lib/ssrf.ts new file mode 100644 index 00000000..1772e149 --- /dev/null +++ b/apps/api/src/lib/ssrf.ts @@ -0,0 +1,90 @@ +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; + return false; +} + +function isPrivateIPv6(ip: string): boolean { + const normalized = ip.replace(/^\[|]$/g, ""); + if (normalized === "::1") return true; + if (normalized.startsWith("fe80:")) return true; + if (normalized.startsWith("fc") || normalized.startsWith("fd")) 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"); + 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"); +} diff --git a/tests/unit/api/ssrf.test.ts b/tests/unit/api/ssrf.test.ts new file mode 100644 index 00000000..9ae971ca --- /dev/null +++ b/tests/unit/api/ssrf.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { 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 invalid URLs", async () => { + await expect(validateFetchUrl("not-a-url")).rejects.toThrow(); + await expect(validateFetchUrl("")).rejects.toThrow(); + }); +}); From a2be47bd68a17edca532be566973cbff1679ed5c Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:18:45 +0800 Subject: [PATCH 02/13] feat: add smart URL parser for bulk import --- apps/web/src/lib/url-parser.ts | 43 ++++++++++++++++++ tests/unit/web/url-parser.test.ts | 72 +++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 apps/web/src/lib/url-parser.ts create mode 100644 tests/unit/web/url-parser.test.ts diff --git a/apps/web/src/lib/url-parser.ts b/apps/web/src/lib/url-parser.ts new file mode 100644 index 00000000..1a491510 --- /dev/null +++ b/apps/web/src/lib/url-parser.ts @@ -0,0 +1,43 @@ +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; + } + + line = line.trim(); + if (isValidHttpUrl(line)) { + urls.push(line); + } + } + + return [...new Set(urls)]; +} 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"]); + }); +}); From d38101b8eaa83f7f3e5100a29e67dc24a140b7f6 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:24:56 +0800 Subject: [PATCH 03/13] feat: add POST /api/v1/fetch-urls endpoint for server-side URL import Accepts { urls: string[] } (1-50), fetches each URL with SSRF protection via safeFetch, validates as image, saves to workspace, generates WebP preview for non-browser formats, and returns results with download URLs. Uses p-queue with concurrency 4 to parallelize fetches. --- apps/api/src/index.ts | 4 + apps/api/src/routes/fetch-urls.ts | 246 +++++++++++++++++++++ tests/integration/fetch-urls.test.ts | 318 +++++++++++++++++++++++++++ tests/integration/test-server.ts | 4 + 4 files changed, 572 insertions(+) create mode 100644 apps/api/src/routes/fetch-urls.ts create mode 100644 tests/integration/fetch-urls.test.ts 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/routes/fetch-urls.ts b/apps/api/src/routes/fetch-urls.ts new file mode 100644 index 00000000..e394d6c5 --- /dev/null +++ b/apps/api/src/routes/fetch-urls.ts @@ -0,0 +1,246 @@ +/** + * 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)}`; +} + +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 }); + + // 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); + }), + ), + ); + + return reply.send({ results: resultSlots }); + }); +} + +async function fetchSingleUrl(url: string, jobId: string, outputDir: string): 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 + const rawFilename = filenameFromUrl(url); + const filename = sanitizeFilename(rawFilename); + + // 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/tests/integration/fetch-urls.test.ts b/tests/integration/fetch-urls.test.ts new file mode 100644 index 00000000..a91916e5 --- /dev/null +++ b/tests/integration/fetch-urls.test.ts @@ -0,0 +1,318 @@ +/** + * 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")); + +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; + } + + 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("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(); + }); +}); 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); From 4a430abf2f758f73503b9935329e4fe72fd7f4be Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:27:43 +0800 Subject: [PATCH 04/13] feat: add useUrlImport hook for URL fetch lifecycle --- apps/web/src/hooks/use-url-import.ts | 215 +++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 apps/web/src/hooks/use-url-import.ts 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..8271845e --- /dev/null +++ b/apps/web/src/hooks/use-url-import.ts @@ -0,0 +1,215 @@ +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): Promise => { + const res = await fetch(downloadUrl, { headers: formatHeaders() }); + 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 => { + try { + const { results } = await fetchUrls([url]); + const result = results[0]; + if (!result?.success || !result.downloadUrl || !result.filename) return null; + return await downloadAsFile(result.downloadUrl, result.filename); + } 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 files = await Promise.all(ready.map((e) => downloadAsFile(e.downloadUrl, e.filename))); + + return files; + }, [entries, downloadAsFile]); + + const retryUrl = useCallback( + async (index: number) => { + const entry = entries[index]; + if (!entry) return; + + setEntries((prev) => + prev.map((e, i) => + i === index ? { ...e, status: "fetching" as const, error: undefined } : e, + ), + ); + + try { + const { results } = await fetchUrls([entry.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, + ), + ); + } + }, + [entries, fetchUrls, resultToEntry], + ); + + const cancel = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setEntries([]); + setImporting(false); + }, []); + + const reset = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + 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, + }; +} From a85ca54feb4d850717623dd361d54fc76eee79cd Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:28:24 +0800 Subject: [PATCH 05/13] refactor: remove redundant trim in URL parser --- apps/web/src/lib/url-parser.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/lib/url-parser.ts b/apps/web/src/lib/url-parser.ts index 1a491510..fc750ce9 100644 --- a/apps/web/src/lib/url-parser.ts +++ b/apps/web/src/lib/url-parser.ts @@ -33,7 +33,6 @@ export function extractUrls(input: string): string[] { continue; } - line = line.trim(); if (isValidHttpUrl(line)) { urls.push(line); } From 485a2d72d370a80624b36daf2cca496a7e84ca05 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:29:58 +0800 Subject: [PATCH 06/13] fix: harden SSRF validation with missing IP ranges and safeFetch tests --- apps/api/src/lib/ssrf.ts | 10 +++- tests/unit/api/ssrf.test.ts | 94 ++++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/apps/api/src/lib/ssrf.ts b/apps/api/src/lib/ssrf.ts index 1772e149..e49a5c93 100644 --- a/apps/api/src/lib/ssrf.ts +++ b/apps/api/src/lib/ssrf.ts @@ -11,14 +11,20 @@ function isPrivateIPv4(ip: string): boolean { 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, ""); + 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; @@ -79,8 +85,8 @@ export async function safeFetch(url: string, signal?: AbortSignal): Promise= 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; - if (i === MAX_REDIRECTS) throw new Error("Too many redirects"); continue; } diff --git a/tests/unit/api/ssrf.test.ts b/tests/unit/api/ssrf.test.ts index 9ae971ca..378f7f0d 100644 --- a/tests/unit/api/ssrf.test.ts +++ b/tests/unit/api/ssrf.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { validateFetchUrl } from "../../../apps/api/src/lib/ssrf.js"; +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 () => { @@ -40,8 +40,98 @@ describe("validateFetchUrl", () => { ); }); + 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("rejects invalid URLs", async () => { await expect(validateFetchUrl("not-a-url")).rejects.toThrow(); await expect(validateFetchUrl("")).rejects.toThrow(); }); }); + +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", + ); + }); +}); From 4a9cc715f22cac9e8c810dc4872b5c4e6a7d5ec4 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:32:27 +0800 Subject: [PATCH 07/13] fix: deduplicate filenames in fetch-urls to prevent overwrites All URLs in a batch share a single workspace directory. When multiple URLs resolve to the same filename (e.g. two different domains both serving photo.jpg), the second writeFile silently overwrites the first. Track used filenames in a Set and append _1, _2, etc. on collision, mirroring the existing getUniqueName pattern from batch.ts. --- apps/api/src/routes/fetch-urls.ts | 41 +++++++++++++++++++++++++--- tests/integration/fetch-urls.test.ts | 40 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/fetch-urls.ts b/apps/api/src/routes/fetch-urls.ts index e394d6c5..82362940 100644 --- a/apps/api/src/routes/fetch-urls.ts +++ b/apps/api/src/routes/fetch-urls.ts @@ -114,6 +114,29 @@ function filenameFromUrl(url: string): string { 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 @@ -130,13 +153,17 @@ export async function registerFetchUrlsRoute(app: FastifyInstance): Promise(); + // 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); + resultSlots[index] = await fetchSingleUrl(url, jobId, outputDir, usedFilenames); }), ), ); @@ -145,7 +172,12 @@ export async function registerFetchUrlsRoute(app: FastifyInstance): Promise { +async function fetchSingleUrl( + url: string, + jobId: string, + outputDir: string, + usedFilenames: Set, +): Promise { try { // Fetch with SSRF protection and timeout const controller = new AbortController(); @@ -199,9 +231,10 @@ async function fetchSingleUrl(url: string, jobId: string, outputDir: string): Pr return { success: false, url, error: "Empty response body" }; } - // Derive filename from URL + // 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 = sanitizeFilename(rawFilename); + const filename = getUniqueName(sanitizeFilename(rawFilename), usedFilenames); // Validate as an image const validation = await validateImageBuffer(buffer, filename); diff --git a/tests/integration/fetch-urls.test.ts b/tests/integration/fetch-urls.test.ts index a91916e5..09e26eb4 100644 --- a/tests/integration/fetch-urls.test.ts +++ b/tests/integration/fetch-urls.test.ts @@ -299,6 +299,46 @@ describe("POST /api/v1/fetch-urls", () => { 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", From d8941a2bc8d018aeae65aa5955c89753fbde0416 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:32:40 +0800 Subject: [PATCH 08/13] feat: add bulk URL import modal component --- .../components/common/url-import-modal.tsx | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 apps/web/src/components/common/url-import-modal.tsx 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..3c6a7f98 --- /dev/null +++ b/apps/web/src/components/common/url-import-modal.tsx @@ -0,0 +1,215 @@ +import { AlertCircle, Check, Clock, Link, Loader2, RotateCw, X } from "lucide-react"; +import { useCallback, 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]); + + return ( +
+ {/* Overlay */} +