mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix: harden foliate streaming EPUB resolution
This commit is contained in:
@@ -84,6 +84,12 @@ const getAttributes =
|
||||
const getElementText = (el) => normalizeWhitespace(el?.textContent)
|
||||
|
||||
const childGetter = (doc, ns) => {
|
||||
if (!doc?.documentElement)
|
||||
return {
|
||||
$: () => null,
|
||||
$$: () => [],
|
||||
$$$: () => [],
|
||||
}
|
||||
// ignore the namespace if it doesn't appear in document at all
|
||||
const useNS = doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns)
|
||||
const f = useNS ? (el, name) => (el) => el.namespaceURI === ns && el.localName === name : (el, name) => (el) => el.localName === name
|
||||
@@ -348,6 +354,7 @@ const parseNav = (doc, resolve = (f) => f) => {
|
||||
}
|
||||
|
||||
const parseNCX = (doc, resolve = (f) => f) => {
|
||||
if (!doc?.documentElement) return { toc: null, pageList: null, others: [] }
|
||||
const { $, $$ } = childGetter(doc, NS.NCX)
|
||||
const resolveHref = (href) => (href ? decodeURI(resolve(href)) : null)
|
||||
const parseItem = (el) => {
|
||||
@@ -1083,19 +1090,25 @@ ${doc.querySelector('parsererror').innerText}`)
|
||||
if (navPath)
|
||||
try {
|
||||
const resolve = (url) => resolveURL(url, navPath)
|
||||
const nav = parseNav(await this.#loadXML(navPath), resolve)
|
||||
this.toc = nav.toc
|
||||
this.pageList = nav.pageList
|
||||
this.landmarks = nav.landmarks
|
||||
const navDoc = await this.#loadXML(navPath)
|
||||
if (navDoc) {
|
||||
const nav = parseNav(navDoc, resolve)
|
||||
this.toc = nav.toc
|
||||
this.pageList = nav.pageList
|
||||
this.landmarks = nav.landmarks
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
if (!this.toc && ncxPath)
|
||||
try {
|
||||
const resolve = (url) => resolveURL(url, ncxPath)
|
||||
const ncx = parseNCX(await this.#loadXML(ncxPath), resolve)
|
||||
this.toc = ncx.toc
|
||||
this.pageList = ncx.pageList
|
||||
const ncxDoc = await this.#loadXML(ncxPath)
|
||||
if (ncxDoc) {
|
||||
const ncx = parseNCX(ncxDoc, resolve)
|
||||
this.toc = ncx.toc
|
||||
this.pageList = ncx.pageList
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
@@ -1118,6 +1131,7 @@ ${doc.querySelector('parsererror').innerText}`)
|
||||
}
|
||||
async loadDocument(item) {
|
||||
const str = await this.loadText(item.href)
|
||||
if (!str) throw new Error(`Failed to load section document: ${item.href}`)
|
||||
return this.parser.parseFromString(str, item.mediaType)
|
||||
}
|
||||
getMediaOverlay() {
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
* @returns {Object} Loader interface compatible with Foliate's EPUB class
|
||||
*/
|
||||
export const makeStreamingLoader = (bookId, baseUrl, bookInfo, authToken = null, bookType = null) => {
|
||||
const OPTIONAL_TEXT_FILES = new Set([
|
||||
'META-INF/encryption.xml',
|
||||
'META-INF/com.apple.ibooks.display-options.xml',
|
||||
'META-INF/com.kobobooks.display-options.xml',
|
||||
'META-INF/calibre_bookmarks.txt',
|
||||
])
|
||||
const isOptionalTextPath = (name) => OPTIONAL_TEXT_FILES.has(name) || /(^|\/)toc\.ncx$/i.test(name)
|
||||
|
||||
// Build a map of file paths to their manifest info for quick lookup
|
||||
const manifestMap = new Map(bookInfo.manifest.map((item) => [item.href, item]))
|
||||
|
||||
@@ -44,6 +52,8 @@ export const makeStreamingLoader = (bookId, baseUrl, bookInfo, authToken = null,
|
||||
const url = getFileUrl(name)
|
||||
const response = await fetch(url, getFetchOptions())
|
||||
if (!response.ok) {
|
||||
if (response.status === 404 && !manifestMap.has(name)) return null
|
||||
if (response.status === 404 && isOptionalTextPath(name)) return null
|
||||
console.warn(`Failed to load text: ${name}`, response.status)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -535,10 +535,45 @@ export class View extends HTMLElement {
|
||||
|
||||
async goTo(target) {
|
||||
const resolved = this.resolveNavigation(target)
|
||||
const hasContent = () => (this.renderer.getContents?.()?.length ?? 0) > 0
|
||||
const tryGoTo = async (nextResolved) => {
|
||||
try {
|
||||
await this.renderer.goTo(nextResolved)
|
||||
return hasContent()
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.renderer.goTo(resolved)
|
||||
this.history.pushState(target)
|
||||
return resolved
|
||||
let opened = await tryGoTo(resolved)
|
||||
let finalTarget = target
|
||||
let finalResolved = resolved
|
||||
if (!opened && typeof resolved?.index === 'number') {
|
||||
const total = this.book?.sections?.length ?? 0
|
||||
for (let index = resolved.index + 1; index < total; index++) {
|
||||
opened = await tryGoTo({ index })
|
||||
if (opened) {
|
||||
finalTarget = index
|
||||
finalResolved = { index }
|
||||
console.warn(`Skipped unreadable section ${resolved.index}, opened section ${index}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!opened)
|
||||
for (let index = resolved.index - 1; index >= 0; index--) {
|
||||
opened = await tryGoTo({ index })
|
||||
if (opened) {
|
||||
finalTarget = index
|
||||
finalResolved = { index }
|
||||
console.warn(`Skipped unreadable section ${resolved.index}, opened section ${index}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!opened) throw new Error(`Could not open section for target ${target}`)
|
||||
this.history.pushState(finalTarget)
|
||||
return finalResolved
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
console.error(`Could not go to ${target}`)
|
||||
|
||||
@@ -66,24 +66,47 @@ function getText(v: unknown): string | null {
|
||||
}
|
||||
|
||||
function findInZip(files: unzipper.File[], path: string): unzipper.File | undefined {
|
||||
const clean = path.startsWith('/') ? path.slice(1) : path;
|
||||
return files.find((f) => {
|
||||
const fp = f.path.startsWith('/') ? f.path.slice(1) : f.path;
|
||||
return fp === clean;
|
||||
});
|
||||
const clean = normalizeZipPath(path);
|
||||
const cleanLower = clean.toLowerCase();
|
||||
let ciMatch: unzipper.File | undefined;
|
||||
for (const file of files) {
|
||||
const fp = normalizeZipPath(file.path);
|
||||
if (fp === clean) return file;
|
||||
if (!ciMatch && fp.toLowerCase() === cleanLower) ciMatch = file;
|
||||
}
|
||||
return ciMatch;
|
||||
}
|
||||
|
||||
function safeDecodeURI(path: string): string {
|
||||
try {
|
||||
return decodeURI(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeZipPath(path: string): string {
|
||||
const clean = safeDecodeURI((path ?? '').replace(/\\/g, '/')).replace(/^\/+/, '');
|
||||
const parts = clean.split('/');
|
||||
const resolved: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
if (resolved.length > 0) resolved.pop();
|
||||
continue;
|
||||
}
|
||||
resolved.push(part);
|
||||
}
|
||||
return resolved.join('/');
|
||||
}
|
||||
|
||||
function resolveHref(href: string, basePath: string): string {
|
||||
if (!href || href.startsWith('http://') || href.startsWith('https://')) return href;
|
||||
if (href.startsWith('/')) return href.slice(1);
|
||||
let resolved = basePath + href;
|
||||
while (resolved.includes('/../')) {
|
||||
const idx = resolved.indexOf('/../');
|
||||
const prev = resolved.lastIndexOf('/', idx - 1);
|
||||
if (prev >= 0) resolved = resolved.slice(0, prev + 1) + resolved.slice(idx + 4);
|
||||
else break;
|
||||
}
|
||||
return resolved;
|
||||
if (!href || /^[a-z][a-z\d+.-]*:/i.test(href)) return href;
|
||||
const decoded = safeDecodeURI(href);
|
||||
const [pathWithQuery, fragment] = decoded.split('#');
|
||||
const path = pathWithQuery.split('?')[0];
|
||||
const resolvedPath = path.startsWith('/') ? normalizeZipPath(path) : normalizeZipPath(basePath + path);
|
||||
return fragment ? `${resolvedPath}#${fragment}` : resolvedPath;
|
||||
}
|
||||
|
||||
function guessContentType(path: string): string {
|
||||
@@ -162,7 +185,7 @@ async function parseEpub(epubPath: string): Promise<EpubBookInfo> {
|
||||
const mediaType = item['@_media-type'] ?? 'application/octet-stream';
|
||||
const propertiesStr = item['@_properties'];
|
||||
const properties = propertiesStr ? propertiesStr.split(/\s+/) : undefined;
|
||||
const fullHref = rootPath + relHref;
|
||||
const fullHref = normalizeZipPath(resolveHref(relHref, rootPath).split('#')[0]);
|
||||
const size = findInZip(zip.files, fullHref)?.uncompressedSize ?? 0;
|
||||
const manifestItem: EpubManifestItem = { id, href: fullHref, mediaType, size, ...(properties ? { properties } : {}) };
|
||||
manifestById.set(id, manifestItem);
|
||||
@@ -266,18 +289,17 @@ export class EpubService {
|
||||
user: RequestUser,
|
||||
): Promise<{ stream: NodeJS.ReadableStream; contentType: string; size: number }> {
|
||||
if (filePath.includes('..')) throw new ForbiddenException('Invalid path');
|
||||
const normalizedPath = normalizeZipPath(filePath);
|
||||
if (!normalizedPath) throw new ForbiddenException('Invalid path');
|
||||
|
||||
const epubPath = await this.resolveEpubPath(bookId, user);
|
||||
const cached = await this.getCachedEntry(epubPath);
|
||||
|
||||
if (!cached.validPaths.has(filePath)) throw new NotFoundException(`File not found in EPUB: ${filePath}`);
|
||||
|
||||
const zip = await unzipper.Open.file(epubPath);
|
||||
const entry = findInZip(zip.files, filePath);
|
||||
if (!entry) throw new NotFoundException(`Entry not in archive: ${filePath}`);
|
||||
const entry = findInZip(zip.files, normalizedPath);
|
||||
if (!entry) throw new NotFoundException(`Entry not in archive: ${normalizedPath}`);
|
||||
|
||||
const manifestItem = cached.info.manifest.find((m) => m.href === filePath);
|
||||
const contentType = manifestItem?.mediaType ?? guessContentType(filePath);
|
||||
const manifestItem = cached.info.manifest.find((m) => m.href === normalizedPath);
|
||||
const contentType = manifestItem?.mediaType ?? guessContentType(normalizedPath);
|
||||
|
||||
return { stream: entry.stream(), contentType, size: entry.uncompressedSize };
|
||||
}
|
||||
@@ -307,8 +329,8 @@ export class EpubService {
|
||||
this.logger.debug(`Parsing EPUB: ${epubPath}`);
|
||||
const info = await parseEpub(epubPath);
|
||||
|
||||
const validPaths = new Set<string>(['META-INF/container.xml', info.containerPath]);
|
||||
for (const item of info.manifest) validPaths.add(item.href);
|
||||
const validPaths = new Set<string>(['META-INF/container.xml', normalizeZipPath(info.containerPath)]);
|
||||
for (const item of info.manifest) validPaths.add(normalizeZipPath(item.href));
|
||||
|
||||
const entry: CacheEntry = { info, mtime: mtimeMs, validPaths, lastAccessed: Date.now() };
|
||||
this.evict();
|
||||
|
||||
Reference in New Issue
Block a user