Files
dashboard/backend/src/services/bookstack.service.ts
Matthias Hochmeister 215528a521 update
2026-03-16 14:41:08 +01:00

247 lines
7.4 KiB
TypeScript

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<string, string> {
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<number, string>; expiresAt: number } | null = null;
const BOOK_SLUG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
async function getBookSlugMap(): Promise<Map<number, string>> {
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<BookStackPage[]> {
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<BookStackSearchResult[]> {
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<BookStackPageDetail> {
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 };