books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests
This commit is contained in:
115
src/lib/server/books/files.ts
Normal file
115
src/lib/server/books/files.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// 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}` } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user