// Book files and covers on disk. Files are owner-only and only ever streamed through an // authenticated endpoint; nothing here is under static/. // // BOOKS_ROOT holds library/ (book files, books.files.path is relative to it) and covers/ // (books.works.cover_path is relative to it). In prod the container gets the host's books // directory as a read-only bind mount at /books (see scripts/deploy-remote.sh). import { createReadStream, existsSync } from 'node:fs'; import { realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import path from 'node:path'; import { Readable } from 'node:stream'; export function booksRoot(): string { if (process.env.BOOKS_ROOT) return process.env.BOOKS_ROOT; return existsSync('/books/library') ? '/books' : path.join(homedir(), 'books'); } /** * Resolves `relative` inside `/`, following symlinks, and refuses anything that * escapes it (`..`, absolute paths, symlinks out). Returns null when missing or unsafe. */ export async function safeResolve( area: 'library' | 'covers', relative: string, root = booksRoot() ): Promise { if (!relative || relative.includes('\0') || path.isAbsolute(relative)) return null; const base = path.join(root, area); try { const realBase = await realpath(base); const real = await realpath(path.join(base, relative)); if (real !== realBase && !real.startsWith(realBase + path.sep)) return null; return (await stat(real)).isFile() ? real : null; } catch { return null; } } const contentTypes: Record = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw: 'application/vnd.amazon.ebook', azw3: 'application/vnd.amazon.ebook', fb2: 'application/x-fictionbook+xml', cbz: 'application/vnd.comicbook+zip', djvu: 'image/vnd.djvu', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif' }; export function contentType(file: string): string { return contentTypes[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream'; } /** Parses a single `bytes=a-b` range. Multi-range requests get the whole file. */ export function parseRange( header: string | null, size: number ): { start: number; end: number } | 'invalid' | null { if (!header) return null; const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); if (!match || (!match[1] && !match[2])) return null; let start: number; let end: number; if (!match[1]) { const suffix = Number(match[2]); if (suffix === 0) return 'invalid'; start = Math.max(0, size - suffix); end = size - 1; } else { start = Number(match[1]); end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1; } if (start > end || start >= size) return 'invalid'; return { start, end }; } /** Streams a file with Range support (pdf.js fetches large PDFs in chunks). */ export async function streamFile( file: string, request: Request, headers: Record ): Promise { const { size, mtime } = await stat(file); const base = { 'content-type': contentType(file), 'accept-ranges': 'bytes', 'last-modified': mtime.toUTCString(), ...headers }; const range = parseRange(request.headers.get('range'), size); if (range === 'invalid') return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${size}` } }); const { start, end } = range ?? { start: 0, end: size - 1 }; const length = size === 0 ? 0 : end - start + 1; const body = request.method === 'HEAD' || size === 0 ? null : (Readable.toWeb(createReadStream(file, { start, end })) as unknown as ReadableStream); return new Response(body, { status: range ? 206 : 200, headers: { ...base, 'content-length': String(length), ...(range ? { 'content-range': `bytes ${start}-${end}/${size}` } : {}) } }); }