Files
Epicure/apps/web/lib/ai/features/import-url.ts
T
2026-07-01 11:10:37 +02:00

124 lines
3.9 KiB
TypeScript

import { generateObject } from "ai";
import dns from "node:dns/promises";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
/**
* Resolves the hostname in `rawUrl` and returns an error string if the
* URL targets a private/reserved address range (SSRF guard), or null if safe.
*/
async function validateImportUrl(rawUrl: string): Promise<string | null> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return "Invalid URL";
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
return "URL must use http or https";
}
let addresses: string[];
try {
const results = await dns.lookup(url.hostname, { all: true, family: 0 });
addresses = results.map((r) => r.address);
} catch {
return "Unable to resolve hostname";
}
for (const addr of addresses) {
if (isPrivateAddress(addr)) {
return "URL must not point to a private or reserved address";
}
}
return null;
}
function isPrivateAddress(ip: string): boolean {
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number];
if (a === 127) return true;
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a >= 224) return true;
return false;
}
const lower = ip.toLowerCase();
if (lower === "::1") return true;
if (lower === "::") return true;
if (lower.startsWith("fe80:")) return true;
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4mapped) return isPrivateAddress(v4mapped[1]!);
return false;
}
const ImportedRecipeSchema = z.object({
title: z.string(),
description: z.string().optional(),
baseServings: z.number().int().min(1).optional(),
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.string().optional(),
unit: z.string().optional(),
note: z.string().optional(),
})),
steps: z.array(z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
})),
});
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
const ssrfError = await validateImportUrl(url);
if (ssrfError) throw new Error(ssrfError);
const res = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; Epicure/1.0; recipe importer)" },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`Failed to fetch URL: ${res.status}`);
const html = await res.text();
// strip scripts/styles, take first 30k chars to stay within context
const cleaned = html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.slice(0, 30000);
const model = resolveModel(config);
const { object } = await generateObject({
model,
schema: ImportedRecipeSchema,
system:
"You are an expert at extracting structured recipe data from web page text. Extract all recipe information accurately. For ingredients, separate quantity, unit, and name. For steps, extract timer durations in seconds when mentioned.",
prompt: `Extract the recipe from this web page:\n\nURL: ${url}\n\nContent:\n${cleaned}`,
});
return object;
}