fix: harden foliate streaming EPUB resolution

This commit is contained in:
neon
2026-03-06 17:21:49 -06:00
parent fd21741e08
commit f911c552a7
4 changed files with 116 additions and 35 deletions
+21 -7
View File
@@ -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
}
+38 -3
View File
@@ -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}`)
+47 -25
View File
@@ -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();