import { Buffer } from 'node:buffer'; import { parseString } from 'xml2js'; import { z } from 'zod'; import { type OpmlOutline, OpmlSchema } from '../types/schemas'; import { readResponseTextWithinLimit } from '../utils/bounded-response'; import { DEFAULT_TIMEOUT, MAX_OPML_OUTLINE_NODES, MAX_OPML_RECURSION_DEPTH, MAX_RESPONSE_SIZE, } from '../utils/constants'; import { parseHttpUrl } from '../utils/validation'; export interface ParsedOpmlFeed { title: string; xmlUrl: string; htmlUrl?: string; description?: string; category?: string; } export interface OpmlParseResult { title?: string; feeds: ParsedOpmlFeed[]; errors: Array<{ outline: string; error: string }>; totalOutlines: number; validFeeds: number; } /** * Flatten nested outlines into a flat array with inherited categories */ function flattenOutlines( outlines: OpmlOutline[], parentCategory?: string, depth = 0, ): Array & { xmlUrl?: string }> { if (depth > MAX_OPML_RECURSION_DEPTH) { throw new Error(`OPML nesting too deep (>${MAX_OPML_RECURSION_DEPTH} levels)`); } const result: Array & { xmlUrl?: string }> = []; for (const outline of outlines) { const category = outline.category || parentCategory; // Keep every traversed outline in the flattened representation so // totalOutlines includes category and empty nodes as its name promises. // Feed validation below continues to skip entries without xmlUrl. result.push({ title: outline.title || outline.text, xmlUrl: outline.xmlUrl, htmlUrl: outline.htmlUrl, description: outline.description, category, }); // Process nested outlines (could be category groups) if (outline.outline && outline.outline.length > 0) { // If this outline has text but no xmlUrl, it might be a category const categoryForChildren = outline.xmlUrl ? category : outline.text || category; result.push(...flattenOutlines(outline.outline, categoryForChildren, depth + 1)); } } return result; } /** * Parse XML string to OPML object. * XXE safety: xml2js 0.6.x does not process external entities or DTDs by default, * so no additional XXE mitigation options are needed. */ async function parseXml(xml: string): Promise { return new Promise((resolve, reject) => { parseString( xml, { explicitArray: false, mergeAttrs: false, xmlns: false, // Do not parse xmlns attributes }, (err, result) => { if (err) reject(err); else resolve(result); }, ); }); } /** Represents the shape of xml2js output for attributes */ interface Xml2jsAttrs { [key: string]: string | undefined; } /** Represents a generic xml2js parsed node */ interface Xml2jsNode { $?: Xml2jsAttrs; [key: string]: unknown; } function asNode(value: unknown): Xml2jsNode | undefined { return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Xml2jsNode) : undefined; } /** * Reject unsupported roots and hostile outline trees before recursive * normalization and Zod validation can consume the structure. */ function getBoundedOpmlRoot(parsed: unknown): Xml2jsNode { const root = asNode(parsed); const opml = root && asNode(root.opml); if (!opml) { throw new Error('Invalid OPML: root element must be '); } const body = asNode(opml.body); const firstOutlines = body?.outline ? (Array.isArray(body.outline) ? body.outline : [body.outline]) : []; const pending = firstOutlines.map((outline) => ({ outline, depth: 0 })); let nodes = 0; while (pending.length > 0) { const current = pending.pop(); if (!current) continue; if (current.depth > MAX_OPML_RECURSION_DEPTH) { throw new Error(`OPML nesting too deep (>${MAX_OPML_RECURSION_DEPTH} levels)`); } if (!asNode(current.outline)) continue; nodes++; if (nodes > MAX_OPML_OUTLINE_NODES) { throw new Error(`OPML has too many outlines (>${MAX_OPML_OUTLINE_NODES})`); } const nested = current.outline.outline; if (nested) { const children = Array.isArray(nested) ? nested : [nested]; for (const child of children) pending.push({ outline: child, depth: current.depth + 1 }); } } return opml; } /** * Normalize parsed XML to match our schema */ export function normalizeOpml(parsed: unknown): z.infer { const opml = getBoundedOpmlRoot(parsed); // Extract version (could be in $ property) const version = (opml.$?.version || opml.version || '2.0') as string; // Extract head const opmlHead = opml.head as Xml2jsNode | undefined; let head: Record = {}; if (opmlHead) { head = { title: (opmlHead.title || opmlHead.$?.title) as string | undefined, dateCreated: (opmlHead.dateCreated || opmlHead.$?.dateCreated) as string | undefined, dateModified: (opmlHead.dateModified || opmlHead.$?.dateModified) as string | undefined, ownerName: (opmlHead.ownerName || opmlHead.$?.ownerName) as string | undefined, ownerEmail: (opmlHead.ownerEmail || opmlHead.$?.ownerEmail) as string | undefined, ownerId: (opmlHead.ownerId || opmlHead.$?.ownerId) as string | undefined, docs: (opmlHead.docs || opmlHead.$?.docs) as string | undefined, }; } // Extract and normalize body const opmlBody = opml.body as Xml2jsNode | undefined; let outlines: Xml2jsNode[] = []; if (opmlBody?.outline) { outlines = Array.isArray(opmlBody.outline) ? (opmlBody.outline as Xml2jsNode[]) : [opmlBody.outline as Xml2jsNode]; } // Recursively normalize outlines function normalizeOutline(outline: Xml2jsNode): OpmlOutline { // xml2js stores attributes in $ property const attrs = outline.$ || {}; // Handle nested outlines let nestedOutlines: OpmlOutline[] | undefined; if (outline.outline) { const nested = Array.isArray(outline.outline) ? (outline.outline as Xml2jsNode[]) : [outline.outline as Xml2jsNode]; nestedOutlines = nested.map(normalizeOutline); } const normalized: OpmlOutline = { text: (attrs.text || attrs.title || outline.text || outline.title || '') as string, title: (attrs.title || outline.title) as string | undefined, type: (attrs.type || outline.type) as string | undefined, xmlUrl: (attrs.xmlUrl || outline.xmlUrl) as string | undefined, htmlUrl: (attrs.htmlUrl || outline.htmlUrl) as string | undefined, description: (attrs.description || outline.description) as string | undefined, category: (attrs.category || outline.category) as string | undefined, outline: nestedOutlines, }; return normalized; } const body = { outline: outlines.map(normalizeOutline), }; return { version, head, body }; } /** * Parse OPML file from path */ export async function parseOpmlFile(filePath: string): Promise { const file = Bun.file(filePath); if (!(await file.exists())) { throw new Error(`OPML file not found: ${filePath}`); } if (file.size > MAX_RESPONSE_SIZE) { throw new Error(`OPML file too large: ${file.size} bytes (max: ${MAX_RESPONSE_SIZE})`); } const content = await file.text(); return parseOpmlString(content); } /** * Parse OPML from string content */ export async function parseOpmlString(content: string): Promise { const result: OpmlParseResult = { feeds: [], errors: [], totalOutlines: 0, validFeeds: 0, }; try { const contentBytes = Buffer.byteLength(content, 'utf8'); if (contentBytes > MAX_RESPONSE_SIZE) { throw new Error(`OPML content too large: ${contentBytes} bytes (max: ${MAX_RESPONSE_SIZE})`); } // Parse XML const parsed = await parseXml(content); // Normalize and validate const normalized = normalizeOpml(parsed); // Validate with Zod const opml = OpmlSchema.parse(normalized); // Extract title from head result.title = opml.head?.title; // Flatten and extract feeds const flatOutlines = flattenOutlines(opml.body.outline); result.totalOutlines = flatOutlines.length; // Validate each feed for (const outline of flatOutlines) { if (!outline.xmlUrl) { // Skip outlines without xmlUrl (might be category headers) continue; } try { // Apply the same network URL policy as the Feed persistence seam. Bad // outlines remain per-outline import errors rather than aborting OPML. const parsed = parseHttpUrl(outline.xmlUrl); if (!parsed.valid) throw new Error(parsed.error); result.feeds.push({ title: outline.title || outline.xmlUrl, xmlUrl: parsed.value, htmlUrl: outline.htmlUrl, description: outline.description, category: outline.category, }); result.validFeeds++; } catch (error) { let errorMessage: string; if (error instanceof z.ZodError) { // Zod 4 has the error info in message property errorMessage = error.message; } else if (error instanceof Error) { errorMessage = error.message; } else { errorMessage = String(error); } result.errors.push({ outline: outline.title || outline.xmlUrl || 'Unknown', error: errorMessage, }); } } return result; } catch (error) { if (error instanceof z.ZodError) { throw new Error(`OPML validation failed: ${error.message}`); } throw error; } } /** * Parse OPML from URL */ export async function parseOpmlFromUrl(url: string): Promise { const parsedUrl = parseHttpUrl(url); if (!parsedUrl.valid) throw new Error(parsedUrl.error); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT); try { const response = await fetch(parsedUrl.value, { signal: controller.signal }); if (!response.ok) { try { await response.body?.cancel(); } catch { // Preserve the HTTP error when connection teardown also fails. } throw new Error(`Failed to fetch OPML from URL: ${response.status} ${response.statusText}`); } const content = await readResponseTextWithinLimit(response, { maxBytes: MAX_RESPONSE_SIZE, label: 'OPML response', }); return parseOpmlString(content); } finally { clearTimeout(timeoutId); } } /** * Validate OPML structure without full parsing */ export function validateOpmlStructure(content: string): { valid: boolean; error?: string } { try { // Basic XML structure check if (!content.includes(' root element' }; } if (!content.includes('')) { return { valid: false, error: 'Missing element' }; } if (!content.includes(' elements found' }; } // Check for unclosed tags (basic check) const openTags = (content.match(/<[a-zA-Z][^>]*[^/]>|<[a-zA-Z][^/>]*>/g) || []).length; const closeTags = (content.match(/<\/[a-zA-Z]+>/g) || []).length; const selfClosing = (content.match(/<[a-zA-Z][^>]*\/>/g) || []).length; // This is a rough check, not perfect if (openTags - selfClosing > closeTags + 10) { return { valid: false, error: 'Potentially unclosed XML tags' }; } return { valid: true }; } catch (error) { return { valid: false, error: String(error) }; } }