import { createFileRoute } from "@tanstack/react-router"; import { zipSync, strToU8 } from "fflate"; import { z } from "zod"; const PostSchema = z.object({ id: z.string(), fileUrl: z.string().url(), ext: z.string().default("jpg"), previewUrl: z.string().optional(), sampleUrl: z.string().optional(), tags: z.array(z.string()).default([]), source: z.string().optional(), rating: z.string().optional(), score: z.number().optional(), fileSize: z.number().optional(), width: z.number().optional(), height: z.number().optional(), md5: z.string().optional(), uploader: z.union([z.string(), z.number()]).optional(), createdAt: z.string().optional(), rawJson: z.string().optional(), }); const BodySchema = z.object({ siteName: z.string().default("grabber"), posts: z.array(PostSchema).min(1).max(200), excludeTags: z.array(z.string()).default([]), }); function sanitize(s: string) { return s.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 60); } function humanSize(n?: number): string { if (n == null || !Number.isFinite(n)) return ""; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`; return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; } function buildMarkdown(post: z.infer, excludeSet: Set): string { const dims = post.width && post.height ? `${post.width} x ${post.height}` : ""; const rows: [string, string][] = [ ["Source", post.source ?? ""], ["Rating", post.rating ?? ""], ["Score", post.score != null ? String(post.score) : ""], ["Dimensions", dims], ["File size", humanSize(post.fileSize)], ["MD5", post.md5 ?? ""], ["Uploader", post.uploader != null ? String(post.uploader) : ""], ["Created at", post.createdAt ?? ""], ["Original file", post.fileUrl], ["Sample", post.sampleUrl ?? ""], ["Preview", post.previewUrl ?? ""], ["Ext", post.ext], ]; const meta = rows.map(([k, v]) => `- **${k}:** ${v}`).join("\n"); const filteredTags = post.tags.filter((t) => !excludeSet.has(t.toLowerCase())); const tags = filteredTags.length ? filteredTags.join(" ") : ""; let raw = "{}"; if (post.rawJson) { try { raw = JSON.stringify(JSON.parse(post.rawJson), null, 2); } catch { raw = post.rawJson; } } return `# Post ${post.id} ${meta} ## Tags ${tags} ## Raw metadata \`\`\`json ${raw} \`\`\` `; } export const Route = createFileRoute("/api/download-zip")({ server: { handlers: { POST: async ({ request }) => { let body: z.infer; try { body = BodySchema.parse(await request.json()); } catch (err) { return new Response( JSON.stringify({ error: err instanceof Error ? err.message : "Invalid body" }), { status: 400, headers: { "Content-Type": "application/json" } }, ); } const files: Record = {}; const errors: string[] = []; const siteName = sanitize(body.siteName); const excludeSet = new Set((body.excludeTags || []).map((t) => t.toLowerCase())); await Promise.all( body.posts.map(async (post) => { const idSafe = sanitize(post.id); const extSafe = sanitize(post.ext || "jpg"); const base = `${siteName}_${idSafe}`; // Filter out tags that user specified to be missing const filteredTags = post.tags.filter((t) => !excludeSet.has(t.toLowerCase())); // tags .txt — same basename as the image files[`${base}.txt`] = strToU8(filteredTags.join(" ")); // per-post markdown metadata files[`${idSafe}.md`] = strToU8(buildMarkdown(post, excludeSet)); // fetch the image itself try { const res = await fetch(post.fileUrl, { headers: { "User-Agent": "LovableGrabber/1.0" }, }); if (!res.ok) { errors.push(`${post.id}: HTTP ${res.status}`); return; } const buf = new Uint8Array(await res.arrayBuffer()); files[`${base}.${extSafe}`] = buf; } catch (err) { errors.push(`${post.id}: ${err instanceof Error ? err.message : "fetch failed"}`); } }), ); if (errors.length) { files["_errors.txt"] = strToU8(errors.join("\n")); } if (Object.keys(files).length === 0) { return new Response(JSON.stringify({ error: "No files downloaded" }), { status: 502, headers: { "Content-Type": "application/json" }, }); } const zipped = zipSync(files, { level: 0 }); const ts = new Date().toISOString().replace(/[:.]/g, "-"); return new Response(zipped, { status: 200, headers: { "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="grabber-${ts}.zip"`, "Cache-Control": "no-store", }, }); }, }, }, });