import axios from 'axios'; import httpClient from '../config/httpClient'; 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', }; } /** * Fetches all BookStack books and returns a map of book_id → book_slug. * The /api/pages list endpoint does not reliably include book_slug, so we * look it up separately and use it when constructing page URLs. * Cached for 5 minutes to avoid hammering the API on every dashboard load. */ let bookSlugMapCache: { map: Map; expiresAt: number } | null = null; const BOOK_SLUG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes async function getBookSlugMap(): Promise> { if (bookSlugMapCache && Date.now() < bookSlugMapCache.expiresAt) { return bookSlugMapCache.map; } const { bookstack } = environment; try { const response = await httpClient.get( `${bookstack.url}/api/books`, { params: { count: 500 }, headers: buildHeaders() }, ); const books: Array<{ id: number; slug: string }> = response.data?.data ?? []; const map = new Map(books.map((b) => [b.id, b.slug])); bookSlugMapCache = { map, expiresAt: Date.now() + BOOK_SLUG_CACHE_TTL_MS }; return map; } catch { return bookSlugMapCache?.map ?? new Map(); } } 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, bookSlugMap] = await Promise.all([ httpClient.get( `${bookstack.url}/api/pages`, { params: { sort: '-updated_at', count: 20 }, headers: buildHeaders(), }, ), getBookSlugMap(), ]); const pages: BookStackPage[] = response.data?.data ?? []; return pages.map((p) => ({ ...p, url: `${bookstack.url}/books/${bookSlugMap.get(p.book_id) || p.book_slug || p.book_id}/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 httpClient.get( `${bookstack.url}/api/search`, { params: { query, count: 50 }, headers: buildHeaders(), }, ); const bookSlugMap = await getBookSlugMap(); 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/${bookSlugMap.get(item.book_id) || item.book_slug || item.book_id}/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 interface BookStackPageDetail { id: number; name: string; slug: string; book_id: number; book_slug: string; chapter_id: number; html: string; created_at: string; updated_at: string; url: string; book?: { name: string }; createdBy?: { name: string }; updatedBy?: { name: string }; } async function getPageById(id: number): 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, bookSlugMap] = await Promise.all([ httpClient.get( `${bookstack.url}/api/pages/${id}`, { headers: buildHeaders() }, ), getBookSlugMap(), ]); const page = response.data; const bookSlug = bookSlugMap.get(page.book_id) || page.book?.slug || page.book_slug || page.book_id; return { id: page.id, name: page.name, slug: page.slug, book_id: page.book_id, book_slug: page.book_slug ?? '', chapter_id: page.chapter_id ?? 0, html: page.html ?? '', created_at: page.created_at, updated_at: page.updated_at, url: `${bookstack.url}/books/${bookSlug}/page/${page.slug}`, book: page.book, createdBy: page.created_by, updatedBy: page.updated_by, }; } catch (error) { if (axios.isAxiosError(error)) { logger.error('BookStack getPageById failed', { status: error.response?.status, statusText: error.response?.statusText, }); } logger.error('BookStackService.getPageById failed', { error }); throw new Error('Failed to fetch BookStack page'); } } export default { getRecentPages, searchPages, getPageById };