import axios from 'axios'; import environment from '../config/environment'; import logger from '../utils/logger'; export interface BookStackPage { id: number; name: string; slug: string; book_id: number; book_slug: string; chapter_id: number; draft: boolean; template: boolean; created_at: string; updated_at: string; url: string; preview_html?: { content: string }; book?: { name: string }; tags?: { name: string; value: string; order: number }[]; createdBy?: { name: string }; updatedBy?: { name: string }; } export interface BookStackSearchResult { id: number; name: string; slug: string; book_id: number; book_slug: string; url: string; preview_html: { content: string }; tags: { name: string; value: string; order: number }[]; } /** * Validates that a URL is safe to use as an outbound service endpoint. * Rejects non-http(s) protocols and private/loopback IP ranges to prevent SSRF. */ function isValidServiceUrl(raw: string): boolean { let parsed: URL; try { parsed = new URL(raw); } catch { return false; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return false; } const hostname = parsed.hostname.toLowerCase(); // Reject plain loopback / localhost names if (hostname === 'localhost' || hostname === '::1') { return false; } // Reject numeric IPv4 private / loopback / link-local ranges const ipv4Parts = hostname.split('.'); if (ipv4Parts.length === 4) { const [a, b] = ipv4Parts.map(Number); if ( a === 127 || // 127.0.0.0/8 loopback a === 10 || // 10.0.0.0/8 private (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private (a === 192 && b === 168) || // 192.168.0.0/16 private (a === 169 && b === 254) // 169.254.0.0/16 link-local ) { return false; } } return true; } function buildHeaders(): Record { const { bookstack } = environment; return { 'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`, 'Content-Type': 'application/json', }; } async function getRecentPages(): Promise { const { bookstack } = environment; if (!bookstack.url || !isValidServiceUrl(bookstack.url)) { throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL'); } try { const response = await axios.get( `${bookstack.url}/api/pages`, { params: { sort: '-updated_at', count: 5 }, headers: buildHeaders(), }, ); const pages: BookStackPage[] = response.data?.data ?? []; return pages.map((p) => ({ ...p, url: `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`, })); } catch (error) { if (axios.isAxiosError(error)) { logger.error('BookStack getRecentPages failed', { status: error.response?.status, statusText: error.response?.statusText, }); } logger.error('BookStackService.getRecentPages failed', { error }); throw new Error('Failed to fetch BookStack recent pages'); } } async function searchPages(query: string): Promise { const { bookstack } = environment; if (!bookstack.url || !isValidServiceUrl(bookstack.url)) { throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL'); } try { const response = await axios.get( `${bookstack.url}/api/search`, { params: { query, count: 8 }, headers: buildHeaders(), }, ); const results: BookStackSearchResult[] = (response.data?.data ?? []) .filter((item: any) => item.type === 'page') .map((item: any) => ({ id: item.id, name: item.name, slug: item.slug, book_id: item.book_id ?? 0, book_slug: item.book_slug ?? '', url: `${bookstack.url}/books/${item.book_slug}/page/${item.slug}`, preview_html: item.preview_html ?? { content: '' }, tags: item.tags ?? [], })); return results; } catch (error) { if (axios.isAxiosError(error)) { logger.error('BookStack searchPages failed', { status: error.response?.status, statusText: error.response?.statusText, }); } logger.error('BookStackService.searchPages failed', { error }); throw new Error('Failed to search BookStack pages'); } } export default { getRecentPages, searchPages };