155 lines
4.4 KiB
TypeScript
155 lines
4.4 KiB
TypeScript
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<string, string> {
|
|
const { bookstack } = environment;
|
|
return {
|
|
'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
}
|
|
|
|
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 = 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<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 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 };
|