116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
// 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 `<root>/<area>`, 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<string | null> {
|
|
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<string, string> = {
|
|
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<string, string>
|
|
): Promise<Response> {
|
|
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}` } : {})
|
|
}
|
|
});
|
|
}
|