From c247a929a2f66fd110be2188fe82761e5e2095e2 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Fri, 23 Jan 2026 17:38:01 +0100 Subject: [PATCH 01/11] Init Injection API --- navigator/src/epub/EpubNavigator.ts | 19 +- navigator/src/epub/frame/FrameBlobBuilder.ts | 43 ++- navigator/src/epub/frame/FramePoolManager.ts | 39 ++- navigator/src/epub/fxl/FXLFramePoolManager.ts | 22 +- navigator/src/index.ts | 3 +- navigator/src/injection/Injectable.ts | 65 +++++ navigator/src/injection/Injector.ts | 268 ++++++++++++++++++ navigator/src/injection/index.ts | 2 + navigator/src/webpub/WebPubBlobBuilder.ts | 22 +- .../src/webpub/WebPubFramePoolManager.ts | 33 ++- navigator/src/webpub/WebPubNavigator.ts | 8 +- 11 files changed, 499 insertions(+), 25 deletions(-) create mode 100644 navigator/src/injection/Injectable.ts create mode 100644 navigator/src/injection/Injector.ts create mode 100644 navigator/src/injection/index.ts diff --git a/navigator/src/epub/EpubNavigator.ts b/navigator/src/epub/EpubNavigator.ts index ef8a3a81..56fba736 100644 --- a/navigator/src/epub/EpubNavigator.ts +++ b/navigator/src/epub/EpubNavigator.ts @@ -14,12 +14,15 @@ import { EpubPreferencesEditor } from "./preferences/EpubPreferencesEditor"; import { ReadiumCSS } from "./css/ReadiumCSS"; import { RSProperties, UserProperties } from "./css/Properties"; import { getContentWidth } from "../helpers/dimensions"; +import { Injector } from "../injection/Injector"; +import { IInjectablesConfig } from "../injection/Injectable"; export type ManagerEventKey = "zoom"; export interface EpubNavigatorConfiguration { preferences: IEpubPreferences; defaults: IEpubDefaults; + injectables?: IInjectablesConfig; } export interface EpubNavigatorListeners { @@ -65,6 +68,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable { this.eventListener(key, data); } } else { await this.updateCSS(false); const cssProperties = this.compileCSSProperties(this._css); - this.framePool = new FramePoolManager(this.container, this.positions, cssProperties); + this.framePool = new FramePoolManager( + this.container, + this.positions, + cssProperties, + this._injector + ); } if(this.currentLocation === undefined) diff --git a/navigator/src/epub/frame/FrameBlobBuilder.ts b/navigator/src/epub/frame/FrameBlobBuilder.ts index d931691e..63d15ebb 100644 --- a/navigator/src/epub/frame/FrameBlobBuilder.ts +++ b/navigator/src/epub/frame/FrameBlobBuilder.ts @@ -1,5 +1,6 @@ import { MediaType } from "@readium/shared"; import { Link, Publication } from "@readium/shared"; +import { Injector } from "../../injection/Injector"; // Readium CSS imports // The "?inline" query is to prevent some bundlers from injecting these into the page (e.g. vite) @@ -104,12 +105,22 @@ export default class FrameBlobBuider { private readonly burl: string; private readonly pub: Publication; private readonly cssProperties?: { [key: string]: string }; + private readonly injector: Injector | null = null; - constructor(pub: Publication, baseURL: string, item: Link, cssProperties?: { [key: string]: string }) { + constructor( + pub: Publication, + baseURL: string, + item: Link, + options: { + cssProperties?: { [key: string]: string }; + injector?: Injector | null; + } + ) { this.pub = pub; this.item = item; this.burl = item.toURL(baseURL) || ""; - this.cssProperties = cssProperties; + this.cssProperties = options.cssProperties; + this.injector = options.injector ?? null; } public async build(fxl = false): Promise { @@ -127,15 +138,23 @@ export default class FrameBlobBuider { // Load the HTML resource const txt = await this.pub.get(this.item).readAsString(); if(!txt) throw new Error(`Failed reading item ${this.item.href}`); + const doc = new DOMParser().parseFromString( txt, this.item.mediaType.string as DOMParserSupportedType ); + const perror = doc.querySelector("parsererror"); - if(perror) { + if (perror) { const details = perror.querySelector("div"); throw new Error(`Failed parsing item ${this.item.href}: ${details?.textContent || perror.textContent}`); } + + // Apply resource injections if injection service is provided + if (this.injector) { + await this.injector.injectForDocument(doc, this.item); + } + return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, fxl, this.cssProperties); } @@ -183,6 +202,15 @@ export default class FrameBlobBuider { private finalizeDOM(doc: Document, root: string | undefined, base: string | undefined, mediaType: MediaType, fxl = false, cssProperties?: { [key: string]: string }): string { if(!doc) return ""; + // Get allowed domains from injector if it exists + const allowedDomains = this.injector?.getAllowedDomains?.() || []; + + // Always include the root domain if provided + const domains = [...new Set([ + ...(root ? [root] : []), + ...allowedDomains + ])].filter(Boolean); + // Inject styles if(!fxl) { // Readium CSS Before @@ -190,8 +218,9 @@ export default class FrameBlobBuider { doc.head.firstChild ? doc.head.firstChild.before(rcssBefore) : doc.head.appendChild(rcssBefore); // Readium CSS defaults - if(!this.hasStyle(doc)) - rcssBefore.after(styleify(doc, cached("ReadiumCSS-default", () => blobify(stripCSS(readiumCSSDefault), "text/css")))) + if(!this.hasStyle(doc)) { + rcssBefore.after(styleify(doc, cached("ReadiumCSS-default", () => blobify(stripCSS(readiumCSSDefault), "text/css")))); + } // Readium CSS After doc.head.appendChild(styleify(doc, cached("ReadiumCSS-after", () => blobify(stripCSS(readiumCSSAfter), "text/css")))); @@ -269,10 +298,10 @@ export default class FrameBlobBuider { doc.head.firstChild!.before(cssSelectorGenerator(doc)); // CSS selector utility if (hasExecutable) doc.head.appendChild(rAfter(doc)); // Another execution prevention script - // Add CSP + // Add CSP with allowed domains const meta = doc.createElement("meta"); meta.httpEquiv = "Content-Security-Policy"; - meta.content = csp(root ? [root] : []); + meta.content = csp(domains); meta.dataset.readium = "true"; doc.head.firstChild!.before(meta); diff --git a/navigator/src/epub/frame/FramePoolManager.ts b/navigator/src/epub/frame/FramePoolManager.ts index 71dbddcf..45db057d 100644 --- a/navigator/src/epub/frame/FramePoolManager.ts +++ b/navigator/src/epub/frame/FramePoolManager.ts @@ -2,6 +2,7 @@ import { ModuleName } from "@readium/navigator-html-injectables"; import { Locator, Publication } from "@readium/shared"; import FrameBlobBuider from "./FrameBlobBuilder"; import { FrameManager } from "./FrameManager"; +import { Injector } from "../../injection/Injector"; const UPPER_BOUNDARY = 5; const LOWER_BOUNDARY = 3; @@ -16,11 +17,18 @@ export class FramePoolManager { private readonly inprogress: Map> = new Map(); private pendingUpdates: Map = new Map(); private currentBaseURL: string | undefined; + private readonly injector: Injector | null = null; - constructor(container: HTMLElement, positions: Locator[], cssProperties?: { [key: string]: string }) { + constructor( + container: HTMLElement, + positions: Locator[], + cssProperties?: { [key: string]: string }, + injector?: Injector | null + ) { this.container = container; this.positions = positions; this.currentCssProperties = cssProperties; + this.injector = injector ?? null; } async destroy() { @@ -47,7 +55,13 @@ export class FramePoolManager { this.pool.clear(); // Revoke all blobs - this.blobs.forEach(v => URL.revokeObjectURL(v)); + this.blobs.forEach(v => { + this.injector?.releaseBlobUrl?.(v); + URL.revokeObjectURL(v); + }); + + // Clean up injector if it exists + this.injector?.dispose(); // Empty container of elements this.container.childNodes.forEach(v => { @@ -90,7 +104,10 @@ export class FramePoolManager { // Check if base URL of publication has changed if(this.currentBaseURL !== undefined && pub.baseURL !== this.currentBaseURL) { // Revoke all blobs - this.blobs.forEach(v => URL.revokeObjectURL(v)); + this.blobs.forEach(v => { + this.injector?.releaseBlobUrl?.(v); + URL.revokeObjectURL(v); + }); this.blobs.clear(); } this.currentBaseURL = pub.baseURL; @@ -103,13 +120,17 @@ export class FramePoolManager { // when navigating backwards, where paginated will go the // start of the resource instead of the end due to the // corrupted width ColumnSnapper (injectables) gets on init - this.blobs.forEach(v => URL.revokeObjectURL(v)); + this.blobs.forEach(v => { + this.injector?.releaseBlobUrl?.(v); + URL.revokeObjectURL(v); + }); this.blobs.clear(); this.pendingUpdates.clear(); } if(this.pendingUpdates.has(href) && this.pendingUpdates.get(href)?.inPool === false) { const url = this.blobs.get(href); if(url) { + this.injector?.releaseBlobUrl?.(url); URL.revokeObjectURL(url); this.blobs.delete(href); this.pendingUpdates.delete(href); @@ -129,7 +150,15 @@ export class FramePoolManager { const itm = pub.readingOrder.findWithHref(href); if(!itm) return; // TODO throw? if(!this.blobs.has(href)) { - const blobBuilder = new FrameBlobBuider(pub, this.currentBaseURL || "", itm, this.currentCssProperties); + const blobBuilder = new FrameBlobBuider( + pub, + this.currentBaseURL || "", + itm, + { + cssProperties: this.currentCssProperties, + injector: this.injector + } + ); const blobURL = await blobBuilder.build(); this.blobs.set(href, blobURL); } diff --git a/navigator/src/epub/fxl/FXLFramePoolManager.ts b/navigator/src/epub/fxl/FXLFramePoolManager.ts index f180529e..c3dc5a40 100644 --- a/navigator/src/epub/fxl/FXLFramePoolManager.ts +++ b/navigator/src/epub/fxl/FXLFramePoolManager.ts @@ -6,6 +6,7 @@ import { FXLFrameManager } from "./FXLFrameManager"; import { FXLPeripherals } from "./FXLPeripherals"; import { FXLSpreader, Orientation, Spread } from "./FXLSpreader"; import { VisualNavigatorViewport } from "../../Navigator"; +import { Injector } from "../../injection/Injector"; const UPPER_BOUNDARY = 8; const LOWER_BOUNDARY = 5; @@ -26,6 +27,7 @@ export class FXLFramePoolManager { private readonly delayedTimeout: Map = new Map(); private currentBaseURL: string | undefined; private previousFrames: FXLFrameManager[] = []; + private readonly injector: Injector | null = null; // NEW private readonly bookElement: HTMLDivElement; @@ -44,10 +46,16 @@ export class FXLFramePoolManager { // private readonly pages: FXLFrameManager[] = []; public readonly peripherals: FXLPeripherals; - constructor(container: HTMLElement, positions: Locator[], pub: Publication) { + constructor( + container: HTMLElement, + positions: Locator[], + pub: Publication, + injector?: Injector | null + ) { this.container = container; this.positions = positions; this.pub = pub; + this.injector = injector ?? null; this.spreadPresentation = pub.metadata.otherMetadata?.spread || Spread.auto; if(this.pub.metadata.effectiveReadingProgression !== ReadingProgression.rtl && this.pub.metadata.effectiveReadingProgression !== ReadingProgression.ltr) @@ -393,6 +401,9 @@ export class FXLFramePoolManager { // Revoke all blobs this.blobs.forEach(v => URL.revokeObjectURL(v)); + // Clean up injector if it exists + this.injector?.dispose(); + // Empty container of elements this.container.childNodes.forEach(v => { if(v.nodeType === Node.ELEMENT_NODE || v.nodeType === Node.TEXT_NODE) v.remove(); @@ -495,7 +506,14 @@ export class FXLFramePoolManager { const itm = pub.readingOrder.items[index]; if(!itm) return; // TODO throw? if(!this.blobs.has(href)) { - const blobBuilder = new FrameBlobBuider(pub, this.currentBaseURL || "", itm); + const blobBuilder = new FrameBlobBuider( + pub, + this.currentBaseURL || "", + itm, + { + injector: this.injector + } + ); const blobURL = await blobBuilder.build(true); this.blobs.set(href, blobURL); } diff --git a/navigator/src/index.ts b/navigator/src/index.ts index 0d1387aa..f4a71e72 100644 --- a/navigator/src/index.ts +++ b/navigator/src/index.ts @@ -4,4 +4,5 @@ export * from './epub'; export * from './audio'; export * from './helpers'; export * from './preferences'; -export * from './css'; \ No newline at end of file +export * from './css'; +export * from './injection'; \ No newline at end of file diff --git a/navigator/src/injection/Injectable.ts b/navigator/src/injection/Injectable.ts new file mode 100644 index 00000000..f58b13de --- /dev/null +++ b/navigator/src/injection/Injectable.ts @@ -0,0 +1,65 @@ +import { Link } from "@readium/shared"; + +export interface IBaseInjectable { + type: "script" | "link"; + target: "head" | "body"; + insertion: "prepend" | "append"; + attributes?: { + [key: string]: string | undefined; + type?: string; + rel?: string; + href?: string; + src?: string; + crossorigin?: string; + }; +} + +export interface IUrlInjectable extends IBaseInjectable { + url: string; // Must be absolute HTTPS URL +} + +export interface IBlobInjectable extends IBaseInjectable { + blob: Blob; // Raw Blob object +} + +export type IInjectable = IUrlInjectable | IBlobInjectable; + +/** + * Defines a rule for resource injection, specifying which resources to inject into which documents. + */ +export interface IInjectableRule { + /** + * List of resource URLs or patterns that this rule applies to. + * Can be exact URLs or patterns with wildcards. + */ + resources: Array; + + /** + * Resources to inject into matching documents. + */ + injectables: IInjectable[]; +} + +export interface IInjectablesConfig { + rules: IInjectableRule[]; + allowedDomains?: string[]; +} + +export interface IInjector { + /** + * Injects resources into a document based on matching rules + * @param doc The document to inject resources into + * @param link The link being loaded, used to match against injection rules + */ + injectForDocument(doc: Document, link: Link): Promise; + + /** + * Cleans up any resources used by the injector + */ + dispose(): void; + + /** + * Get the list of allowed domains + */ + getAllowedDomains(): string[] +} diff --git a/navigator/src/injection/Injector.ts b/navigator/src/injection/Injector.ts new file mode 100644 index 00000000..95c4295c --- /dev/null +++ b/navigator/src/injection/Injector.ts @@ -0,0 +1,268 @@ +import { IInjectableRule, IInjectable, IInjector, IInjectablesConfig, IUrlInjectable, IBlobInjectable } from "./Injectable"; +import { Link } from "@readium/shared"; + +const scriptify = (doc: Document, resource: IUrlInjectable | IBlobInjectable, source: string): HTMLScriptElement => { + const s = doc.createElement("script"); + s.dataset.readium = "true"; + + // Create attributes object with source URL + const attributes = { + ...(resource.attributes || {}), + src: source // Always set src to the processed source URL (could be blob: or https:) + }; + + // Apply all attributes + Object.entries(attributes).forEach(([key, value]) => { + if (value !== undefined) { + s.setAttribute(key, value); + } + }); + + return s; +}; + +const linkify = (doc: Document, resource: IUrlInjectable | IBlobInjectable, source: string): HTMLLinkElement => { + const s = doc.createElement("link"); + s.dataset.readium = "true"; + + // Apply all attributes from the resource, including href + const attributes = { + ...(resource.attributes || {}), + href: source // Use the processed source URL as href + }; + + Object.entries(attributes).forEach(([key, value]) => { + if (value !== undefined) { + s.setAttribute(key, value); + } + }); + + return s; +}; + +export class Injector implements IInjector { + private readonly blobStore: Map = new Map(); + private readonly createdBlobUrls: Set = new Set(); + private readonly rules: IInjectableRule[]; + private readonly allowedDomains: string[] = []; + + // Store the first chunk of each blob (16 bytes) for content-based identification + private blobContentCache = new Map(); + private blobCounter = 0; + + constructor(config: IInjectablesConfig) { + this.rules = config.rules; + this.allowedDomains = config.allowedDomains || []; + } + + public dispose(): void { + // Cleanup any created blob URLs + for (const url of this.createdBlobUrls) { + try { + URL.revokeObjectURL(url); + } catch (error) { + console.warn("Failed to revoke blob URL:", url, error); + } + } + this.createdBlobUrls.clear(); + } + + public getAllowedDomains(): string[] { + return [...this.allowedDomains]; // Return a copy to prevent external modification + } + + public async injectForDocument(doc: Document, link: Link): Promise { + for (const rule of this.rules) { + if (this.matchesRule(rule, link)) { + await this.applyRule(doc, rule); + } + } + } + + private matchesRule(rule: IInjectableRule, link: Link): boolean { + // Use the original href from the publication, not the resolved blob URL + const originalHref = link.href; + + return rule.resources.some(pattern => { + if (pattern instanceof RegExp) { + return pattern.test(originalHref); + } + return originalHref === pattern; + }); + } + + private async getBlobKey(blob: Blob): Promise { + // For small blobs, we can use the entire content as a key + if (blob.size <= 64) { + const content = await blob.text(); + return `blob-${content.length}-${content}`; + } + + // For larger blobs, use the first and last 32 bytes as a fingerprint + const firstChunk = await blob.slice(0, 32).text(); + const lastChunk = blob.size > 32 ? await blob.slice(-32).text() : ""; + const contentKey = `${blob.size}-${firstChunk}-${lastChunk}`; + + // Check if we've seen this content before + if (this.blobContentCache.has(contentKey)) { + return this.blobContentCache.get(contentKey)!; + } + + // If not, generate a new key and store it + const key = `blob-${this.blobCounter++}`; + this.blobContentCache.set(contentKey, key); + return key; + } + + private async getOrCreateBlobUrl(blob: Blob): Promise { + const key = await this.getBlobKey(blob); + + if (this.blobStore.has(key)) { + const entry = this.blobStore.get(key)!; + entry.refCount++; + return entry.url; + } + + const url = URL.createObjectURL(blob); + this.blobStore.set(key, { url, refCount: 1 }); + this.createdBlobUrls.add(url); + return url; + } + + public async releaseBlobUrl(url: string): Promise { + if (!this.createdBlobUrls.has(url)) return; + + const entry = Array.from(this.blobStore.values()) + .find(entry => entry.url === url); + + if (entry) { + entry.refCount--; + if (entry.refCount <= 0) { + URL.revokeObjectURL(url); + this.createdBlobUrls.delete(url); + // Remove from blobStore + for (const [key, value] of this.blobStore.entries()) { + if (value.url === url) { + this.blobStore.delete(key); + break; + } + } + } + } + } + + private async getResourceUrl(resource: IInjectable, doc: Document): Promise { + if ("url" in resource) { + const resolvedUrl = new URL(resource.url, doc.baseURI).toString(); + if (!this.isValidUrl(resolvedUrl, doc)) { + throw new Error(`Invalid URL: Only HTTPS, data:, blob:, or localhost HTTP URLs are allowed. Got: ${resource.url}`); + } + return resolvedUrl; + } else { + return this.getOrCreateBlobUrl(resource.blob); + } + } + + private createPreloadLink(doc: Document, resource: IUrlInjectable, url: string): void { + if (!resource.attributes?.rel?.includes("preload")) return; + + // Create a new resource object with preload attributes + const preloadResource: IUrlInjectable = { + ...resource, + attributes: { + ...resource.attributes, + rel: "preload", + as: resource.type === "script" ? "script" : "style" + } + }; + + const preloadLink = linkify(doc, preloadResource, url); + doc.head.appendChild(preloadLink); + } + + private createElement(doc: Document, resource: IInjectable, source: string): HTMLElement { + if (resource.type === "script") { + return scriptify(doc, resource, source); + } + if (resource.type === "link") { + return linkify(doc, resource, source); + } + throw new Error(`Unsupported element type: ${resource.type}`); + } + + private async applyRule(doc: Document, rule: IInjectableRule): Promise { + const createdElements: { element: HTMLElement; url: string }[] = []; + + try { + for (const resource of rule.injectables) { + const target = resource.target === "head" ? doc.head : doc.body; + if (!target) continue; + + let url: string | null = null; + try { + url = await this.getResourceUrl(resource, doc); + + if (resource.attributes?.rel === "preload" && "url" in resource) { + this.createPreloadLink(doc, resource, url); + } else { + const element = this.createElement(doc, resource, url); + createdElements.push({ element, url }); + + if (resource.insertion === "prepend") { + target.prepend(element); + } else { + target.append(element); + } + } + } catch (error) { + console.error("Failed to process resource:", error); + if (url && "blob" in resource) { + await this.releaseBlobUrl(url); + } + throw error; + } + } + } catch (error) { + // Clean up any created elements on error + for (const { element, url } of createdElements) { + try { + element.remove(); + await this.releaseBlobUrl(url); + } catch (cleanupError) { + console.error("Error during cleanup:", cleanupError); + } + } + throw error; + } + } + + private isValidUrl(url: string, doc: Document): boolean { + try { + const parsed = new URL(url, doc.baseURI); + + // Allow data URLs + if (parsed.protocol === "data:") return true; + + // Allow blob URLs that we created + if (parsed.protocol === "blob:" && this.createdBlobUrls.has(url)) { + return true; + } + + // Check against allowed domains if any are specified + if (this.allowedDomains.length > 0) { + const domain = parsed.hostname; + return this.allowedDomains.some(allowed => + domain === allowed || + (allowed.startsWith(".") && domain.endsWith(allowed)) + ); + } + + // Default to allowing https URLs if no allowed domains are specified + if (parsed.protocol === "https:") return true; + + return false; + } catch { + return false; + } + } +} diff --git a/navigator/src/injection/index.ts b/navigator/src/injection/index.ts new file mode 100644 index 00000000..d1efced1 --- /dev/null +++ b/navigator/src/injection/index.ts @@ -0,0 +1,2 @@ +export * from "./Injectable"; +export * from "./Injector"; \ No newline at end of file diff --git a/navigator/src/webpub/WebPubBlobBuilder.ts b/navigator/src/webpub/WebPubBlobBuilder.ts index d4175c4b..7c5c3327 100644 --- a/navigator/src/webpub/WebPubBlobBuilder.ts +++ b/navigator/src/webpub/WebPubBlobBuilder.ts @@ -1,4 +1,5 @@ import { Link, Publication } from "@readium/shared"; +import { Injector } from "../injection/Injector"; // Readium CSS imports // The "?inline" query is to prevent some bundlers from injecting these into the page (e.g. vite) @@ -63,12 +64,22 @@ export class WebPubBlobBuilder { private readonly burl: string; private readonly pub: Publication; private readonly cssProperties?: { [key: string]: string }; - - constructor(pub: Publication, baseURL: string, item: Link, cssProperties?: { [key: string]: string }) { + private readonly injector: Injector | null = null; + + constructor( + pub: Publication, + baseURL: string, + item: Link, + options: { + cssProperties?: { [key: string]: string }; + injector?: Injector | null; + } + ) { this.pub = pub; this.item = item; this.burl = item.toURL(baseURL) || ""; - this.cssProperties = cssProperties; + this.cssProperties = options.cssProperties; + this.injector = options.injector ?? null; } public async build(): Promise { @@ -92,6 +103,11 @@ export class WebPubBlobBuilder { const details = perror.querySelector("div"); throw new Error(`Failed parsing item ${this.item.href}: ${details?.textContent || perror.textContent}`); } + + // Apply resource injections if injection service is provided + if (this.injector) { + await this.injector.injectForDocument(doc, this.item); + } return this.finalizeDOM(doc, this.burl, this.item.mediaType, txt, this.cssProperties); } diff --git a/navigator/src/webpub/WebPubFramePoolManager.ts b/navigator/src/webpub/WebPubFramePoolManager.ts index cd1926d4..d10acdc9 100644 --- a/navigator/src/webpub/WebPubFramePoolManager.ts +++ b/navigator/src/webpub/WebPubFramePoolManager.ts @@ -2,6 +2,7 @@ import { ModuleName } from "@readium/navigator-html-injectables"; import { Locator, Publication } from "@readium/shared"; import { WebPubBlobBuilder } from "./WebPubBlobBuilder"; import { WebPubFrameManager } from "./WebPubFrameManager"; +import { Injector } from "../injection/Injector"; export class WebPubFramePoolManager { private readonly container: HTMLElement; @@ -12,10 +13,16 @@ export class WebPubFramePoolManager { private readonly inprogress: Map> = new Map(); private pendingUpdates: Map = new Map(); private currentBaseURL: string | undefined; + private readonly injector?: Injector | null = null; - constructor(container: HTMLElement, cssProperties?: { [key: string]: string }) { + constructor( + container: HTMLElement, + cssProperties?: { [key: string]: string }, + injector?: Injector | null + ) { this.container = container; this.currentCssProperties = cssProperties; + this.injector = injector; } async destroy() { @@ -42,9 +49,15 @@ export class WebPubFramePoolManager { this.pool.clear(); // Revoke all blobs - this.blobs.forEach(v => URL.revokeObjectURL(v)); + this.blobs.forEach(v => { + this.injector?.releaseBlobUrl?.(v); + URL.revokeObjectURL(v); + }); this.blobs.clear(); + // Clean up injector if it exists + this.injector?.dispose(); + // Empty container of elements this.container.childNodes.forEach(v => { if(v.nodeType === Node.ELEMENT_NODE || v.nodeType === Node.TEXT_NODE) v.remove(); @@ -87,7 +100,10 @@ export class WebPubFramePoolManager { }); if(this.currentBaseURL !== undefined && pub.baseURL !== this.currentBaseURL) { - this.blobs.forEach(v => URL.revokeObjectURL(v)); + this.blobs.forEach(v => { + this.injector?.releaseBlobUrl?.(v); + URL.revokeObjectURL(v); + }); this.blobs.clear(); } this.currentBaseURL = pub.baseURL; @@ -97,6 +113,7 @@ export class WebPubFramePoolManager { if(this.pendingUpdates.has(href) && this.pendingUpdates.get(href)?.inPool === false) { const url = this.blobs.get(href); if(url) { + this.injector?.releaseBlobUrl?.(url); URL.revokeObjectURL(url); this.blobs.delete(href); this.pendingUpdates.delete(href); @@ -117,7 +134,15 @@ export class WebPubFramePoolManager { const itm = pub.readingOrder.findWithHref(href); if(!itm) return; if(!this.blobs.has(href)) { - const blobBuilder = new WebPubBlobBuilder(pub, this.currentBaseURL || "", itm, this.currentCssProperties); + const blobBuilder = new WebPubBlobBuilder( + pub, + this.currentBaseURL || "", + itm, + { + cssProperties: this.currentCssProperties, + injector: this.injector + } + ); const blobURL = await blobBuilder.build(); this.blobs.set(href, blobURL); } diff --git a/navigator/src/webpub/WebPubNavigator.ts b/navigator/src/webpub/WebPubNavigator.ts index ffacb534..9d6a17e9 100644 --- a/navigator/src/webpub/WebPubNavigator.ts +++ b/navigator/src/webpub/WebPubNavigator.ts @@ -14,10 +14,13 @@ import { IWebPubDefaults, WebPubDefaults } from "./preferences/WebPubDefaults"; import { WebPubSettings } from "./preferences/WebPubSettings"; import { IPreferencesEditor } from "../preferences/PreferencesEditor"; import { WebPubPreferencesEditor } from "./preferences/WebPubPreferencesEditor"; +import { Injector } from "../injection/Injector"; +import { IInjectablesConfig } from "../injection/Injectable"; export interface WebPubNavigatorConfiguration { preferences: IWebPubPreferences; defaults: IWebPubDefaults; + injectables?: IInjectablesConfig; } export interface WebPubNavigatorListeners { @@ -57,6 +60,7 @@ export class WebPubNavigator extends VisualNavigator implements Configurable Date: Mon, 26 Jan 2026 10:24:04 +0100 Subject: [PATCH 02/11] Remove href and src from attributes --- navigator/src/injection/Injectable.ts | 6 ++---- navigator/src/injection/Injector.ts | 27 ++++++++++++++------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/navigator/src/injection/Injectable.ts b/navigator/src/injection/Injectable.ts index f58b13de..ddcb066a 100644 --- a/navigator/src/injection/Injectable.ts +++ b/navigator/src/injection/Injectable.ts @@ -4,14 +4,12 @@ export interface IBaseInjectable { type: "script" | "link"; target: "head" | "body"; insertion: "prepend" | "append"; - attributes?: { + attributes?: Omit<{ [key: string]: string | undefined; type?: string; rel?: string; - href?: string; - src?: string; crossorigin?: string; - }; + }, "href" | "src">; // "href" and "src" are handled by url/blob, not as attributes } export interface IUrlInjectable extends IBaseInjectable { diff --git a/navigator/src/injection/Injector.ts b/navigator/src/injection/Injector.ts index 95c4295c..a0d6f9bb 100644 --- a/navigator/src/injection/Injector.ts +++ b/navigator/src/injection/Injector.ts @@ -5,19 +5,19 @@ const scriptify = (doc: Document, resource: IUrlInjectable | IBlobInjectable, so const s = doc.createElement("script"); s.dataset.readium = "true"; - // Create attributes object with source URL - const attributes = { - ...(resource.attributes || {}), - src: source // Always set src to the processed source URL (could be blob: or https:) - }; + // Create attributes object, explicitly excluding href and src + const { href, src, ...safeAttributes } = resource.attributes || {}; - // Apply all attributes - Object.entries(attributes).forEach(([key, value]) => { + // Apply all safe attributes + Object.entries(safeAttributes).forEach(([key, value]) => { if (value !== undefined) { s.setAttribute(key, value); } }); + // Always set src from the processed URL + s.src = source; + return s; }; @@ -25,18 +25,19 @@ const linkify = (doc: Document, resource: IUrlInjectable | IBlobInjectable, sour const s = doc.createElement("link"); s.dataset.readium = "true"; - // Apply all attributes from the resource, including href - const attributes = { - ...(resource.attributes || {}), - href: source // Use the processed source URL as href - }; + // Create attributes object, explicitly excluding href and src + const { href, src, ...safeAttributes } = resource.attributes || {}; - Object.entries(attributes).forEach(([key, value]) => { + // Apply all safe attributes + Object.entries(safeAttributes).forEach(([key, value]) => { if (value !== undefined) { s.setAttribute(key, value); } }); + // Always set href from the processed URL + s.href = source; + return s; }; From a82a30203680916044470edbafdd5c28b2d21a6e Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 26 Jan 2026 10:25:54 +0100 Subject: [PATCH 03/11] Rename properties For clarity --- navigator/src/injection/Injectable.ts | 4 ++-- navigator/src/injection/Injector.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/navigator/src/injection/Injectable.ts b/navigator/src/injection/Injectable.ts index ddcb066a..107e9a23 100644 --- a/navigator/src/injection/Injectable.ts +++ b/navigator/src/injection/Injectable.ts @@ -1,9 +1,9 @@ import { Link } from "@readium/shared"; export interface IBaseInjectable { - type: "script" | "link"; + as: "script" | "link"; target: "head" | "body"; - insertion: "prepend" | "append"; + insert: "prepend" | "append"; attributes?: Omit<{ [key: string]: string | undefined; type?: string; diff --git a/navigator/src/injection/Injector.ts b/navigator/src/injection/Injector.ts index a0d6f9bb..138bd6e2 100644 --- a/navigator/src/injection/Injector.ts +++ b/navigator/src/injection/Injector.ts @@ -173,7 +173,7 @@ export class Injector implements IInjector { attributes: { ...resource.attributes, rel: "preload", - as: resource.type === "script" ? "script" : "style" + as: resource.as } }; @@ -182,13 +182,13 @@ export class Injector implements IInjector { } private createElement(doc: Document, resource: IInjectable, source: string): HTMLElement { - if (resource.type === "script") { + if (resource.as === "script") { return scriptify(doc, resource, source); } - if (resource.type === "link") { + if (resource.as === "link") { return linkify(doc, resource, source); } - throw new Error(`Unsupported element type: ${resource.type}`); + throw new Error(`Unsupported element type: ${resource.as}`); } private async applyRule(doc: Document, rule: IInjectableRule): Promise { @@ -209,7 +209,7 @@ export class Injector implements IInjector { const element = this.createElement(doc, resource, url); createdElements.push({ element, url }); - if (resource.insertion === "prepend") { + if (resource.insert === "prepend") { target.prepend(element); } else { target.append(element); From f4e6d9231832e8b2e420d4225adb96f212d01406 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 26 Jan 2026 10:42:08 +0100 Subject: [PATCH 04/11] Handle unknown type in injectable --- navigator/src/injection/Injector.ts | 41 +++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/navigator/src/injection/Injector.ts b/navigator/src/injection/Injector.ts index 138bd6e2..1e1b5aeb 100644 --- a/navigator/src/injection/Injector.ts +++ b/navigator/src/injection/Injector.ts @@ -1,12 +1,36 @@ import { IInjectableRule, IInjectable, IInjector, IInjectablesConfig, IUrlInjectable, IBlobInjectable } from "./Injectable"; import { Link } from "@readium/shared"; +const inferTypeFromResource = (resource: IUrlInjectable | IBlobInjectable): string | undefined => { + // If blob has a type, use it + if ("blob" in resource && resource.blob.type) { + return resource.blob.type; + } + + // For scripts, default to text/javascript + if (resource.as === "script") { + return "text/javascript"; + } + + // For links, try to infer from URL extension + if (resource.as === "link" && "url" in resource) { + const url = resource.url.toLowerCase(); + if (url.endsWith(".css")) return "text/css"; + if ([".js", ".mjs", ".cjs"].some(ext => url.endsWith(ext))) return "text/javascript"; + } + + return undefined; +}; + const scriptify = (doc: Document, resource: IUrlInjectable | IBlobInjectable, source: string): HTMLScriptElement => { const s = doc.createElement("script"); s.dataset.readium = "true"; // Create attributes object, explicitly excluding href and src - const { href, src, ...safeAttributes } = resource.attributes || {}; + const { href, src, type, ...safeAttributes } = resource.attributes || {}; + + // Use provided type or infer it + const finalType = type || inferTypeFromResource(resource); // Apply all safe attributes Object.entries(safeAttributes).forEach(([key, value]) => { @@ -15,6 +39,11 @@ const scriptify = (doc: Document, resource: IUrlInjectable | IBlobInjectable, so } }); + // Set type if we have it + if (finalType) { + s.type = finalType; + } + // Always set src from the processed URL s.src = source; @@ -26,7 +55,10 @@ const linkify = (doc: Document, resource: IUrlInjectable | IBlobInjectable, sour s.dataset.readium = "true"; // Create attributes object, explicitly excluding href and src - const { href, src, ...safeAttributes } = resource.attributes || {}; + const { href, src, type, ...safeAttributes } = resource.attributes || {}; + + // Use provided type or infer it + const finalType = type || inferTypeFromResource(resource); // Apply all safe attributes Object.entries(safeAttributes).forEach(([key, value]) => { @@ -35,6 +67,11 @@ const linkify = (doc: Document, resource: IUrlInjectable | IBlobInjectable, sour } }); + // Set type if we have it + if (finalType) { + s.type = finalType; + } + // Always set href from the processed URL s.href = source; From 5b388c05be9eb8e4b819392aa5b976c7570347b8 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 26 Jan 2026 10:50:42 +0100 Subject: [PATCH 05/11] Remove permissive logic in isValidUrl --- navigator/src/injection/Injector.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/navigator/src/injection/Injector.ts b/navigator/src/injection/Injector.ts index 1e1b5aeb..6974cbcc 100644 --- a/navigator/src/injection/Injector.ts +++ b/navigator/src/injection/Injector.ts @@ -291,13 +291,12 @@ export class Injector implements IInjector { const domain = parsed.hostname; return this.allowedDomains.some(allowed => domain === allowed || - (allowed.startsWith(".") && domain.endsWith(allowed)) + (allowed.startsWith(".") && domain.endsWith(allowed) && + (domain.length === allowed.length || domain.charAt(domain.length - allowed.length - 1) === ".")) ); } - // Default to allowing https URLs if no allowed domains are specified - if (parsed.protocol === "https:") return true; - + // No allowed domains specified - deny external URLs return false; } catch { return false; From 442ec90a175e4af949d8f7159b4e356af3b19d59 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 26 Jan 2026 13:06:25 +0100 Subject: [PATCH 06/11] Adjust Injection for internal needs --- navigator/src/injection/Injectable.ts | 2 + navigator/src/injection/Injector.ts | 65 ++++++++++++--------------- 2 files changed, 30 insertions(+), 37 deletions(-) diff --git a/navigator/src/injection/Injectable.ts b/navigator/src/injection/Injectable.ts index 107e9a23..e690c067 100644 --- a/navigator/src/injection/Injectable.ts +++ b/navigator/src/injection/Injectable.ts @@ -1,9 +1,11 @@ import { Link } from "@readium/shared"; export interface IBaseInjectable { + id?: string; as: "script" | "link"; target: "head" | "body"; insert: "prepend" | "append"; + condition?: (doc: Document) => boolean; attributes?: Omit<{ [key: string]: string | undefined; type?: string; diff --git a/navigator/src/injection/Injector.ts b/navigator/src/injection/Injector.ts index 6974cbcc..cac86894 100644 --- a/navigator/src/injection/Injector.ts +++ b/navigator/src/injection/Injector.ts @@ -83,13 +83,18 @@ export class Injector implements IInjector { private readonly createdBlobUrls: Set = new Set(); private readonly rules: IInjectableRule[]; private readonly allowedDomains: string[] = []; - - // Store the first chunk of each blob (16 bytes) for content-based identification - private blobContentCache = new Map(); - private blobCounter = 0; + private injectableIdCounter = 0; constructor(config: IInjectablesConfig) { - this.rules = config.rules; + // Assign IDs to injectables that don't have them + this.rules = config.rules.map(rule => ({ + ...rule, + injectables: rule.injectables.map(injectable => ({ + ...injectable, + id: injectable.id || `injectable-${this.injectableIdCounter++}` + })) + })); + this.allowedDomains = config.allowedDomains || []; } @@ -129,42 +134,24 @@ export class Injector implements IInjector { }); } - private async getBlobKey(blob: Blob): Promise { - // For small blobs, we can use the entire content as a key - if (blob.size <= 64) { - const content = await blob.text(); - return `blob-${content.length}-${content}`; - } - - // For larger blobs, use the first and last 32 bytes as a fingerprint - const firstChunk = await blob.slice(0, 32).text(); - const lastChunk = blob.size > 32 ? await blob.slice(-32).text() : ""; - const contentKey = `${blob.size}-${firstChunk}-${lastChunk}`; + private async getOrCreateBlobUrl(resource: IInjectable): Promise { + // Use the injectable ID as the cache key + const cacheKey = resource.id!; // ID is guaranteed to exist after constructor - // Check if we've seen this content before - if (this.blobContentCache.has(contentKey)) { - return this.blobContentCache.get(contentKey)!; - } - - // If not, generate a new key and store it - const key = `blob-${this.blobCounter++}`; - this.blobContentCache.set(contentKey, key); - return key; - } - - private async getOrCreateBlobUrl(blob: Blob): Promise { - const key = await this.getBlobKey(blob); - - if (this.blobStore.has(key)) { - const entry = this.blobStore.get(key)!; + if (this.blobStore.has(cacheKey)) { + const entry = this.blobStore.get(cacheKey)!; entry.refCount++; return entry.url; } - const url = URL.createObjectURL(blob); - this.blobStore.set(key, { url, refCount: 1 }); - this.createdBlobUrls.add(url); - return url; + if ("blob" in resource) { + const url = URL.createObjectURL(resource.blob); + this.blobStore.set(cacheKey, { url, refCount: 1 }); + this.createdBlobUrls.add(url); + return url; + } + + throw new Error("Resource must have a blob property"); } public async releaseBlobUrl(url: string): Promise { @@ -197,7 +184,7 @@ export class Injector implements IInjector { } return resolvedUrl; } else { - return this.getOrCreateBlobUrl(resource.blob); + return this.getOrCreateBlobUrl(resource); } } @@ -233,6 +220,10 @@ export class Injector implements IInjector { try { for (const resource of rule.injectables) { + if (resource.condition && !resource.condition(doc)) { + continue; // Skip this injectable + } + const target = resource.target === "head" ? doc.head : doc.body; if (!target) continue; From 946a4e430765c16dd662107898eaeb6c4023a605 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 26 Jan 2026 13:11:25 +0100 Subject: [PATCH 07/11] Make target and insert optional --- navigator/src/injection/Injectable.ts | 4 ++-- navigator/src/injection/Injector.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/navigator/src/injection/Injectable.ts b/navigator/src/injection/Injectable.ts index e690c067..651f2ae4 100644 --- a/navigator/src/injection/Injectable.ts +++ b/navigator/src/injection/Injectable.ts @@ -3,8 +3,8 @@ import { Link } from "@readium/shared"; export interface IBaseInjectable { id?: string; as: "script" | "link"; - target: "head" | "body"; - insert: "prepend" | "append"; + target?: "head" | "body"; + insert?: "prepend" | "append"; condition?: (doc: Document) => boolean; attributes?: Omit<{ [key: string]: string | undefined; diff --git a/navigator/src/injection/Injector.ts b/navigator/src/injection/Injector.ts index cac86894..9acaaca4 100644 --- a/navigator/src/injection/Injector.ts +++ b/navigator/src/injection/Injector.ts @@ -224,7 +224,7 @@ export class Injector implements IInjector { continue; // Skip this injectable } - const target = resource.target === "head" ? doc.head : doc.body; + const target = resource.target === "body" ? doc.body : doc.head; if (!target) continue; let url: string | null = null; From 5eb8c96cf27eec8fda49d5197681f11229475356 Mon Sep 17 00:00:00 2001 From: Jiminy Panoz Date: Mon, 26 Jan 2026 14:51:48 +0100 Subject: [PATCH 08/11] Migrate internal css + scripts to Injection API --- .../src/dom/_readium_cssSelectorGenerator.js | 2 +- .../src/dom/_readium_executionCleanup.js | 13 ++ .../src/dom/_readium_executionPrevention.js | 65 +++++++++ .../src/dom/_readium_webpubProperties.js | 4 + navigator/src/epub/EpubNavigator.ts | 11 +- navigator/src/epub/frame/FrameBlobBuilder.ts | 131 +----------------- navigator/src/helpers/minify.ts | 14 ++ navigator/src/injection/epubInjectables.ts | 88 ++++++++++++ navigator/src/injection/webpubInjectables.ts | 57 ++++++++ navigator/src/webpub/WebPubBlobBuilder.ts | 77 ---------- navigator/src/webpub/WebPubNavigator.ts | 10 +- 11 files changed, 265 insertions(+), 207 deletions(-) create mode 100644 navigator/src/dom/_readium_executionCleanup.js create mode 100644 navigator/src/dom/_readium_executionPrevention.js create mode 100644 navigator/src/dom/_readium_webpubProperties.js create mode 100644 navigator/src/helpers/minify.ts create mode 100644 navigator/src/injection/epubInjectables.ts create mode 100644 navigator/src/injection/webpubInjectables.ts diff --git a/navigator/src/dom/_readium_cssSelectorGenerator.js b/navigator/src/dom/_readium_cssSelectorGenerator.js index baf9a608..fc0fc7ef 100644 --- a/navigator/src/dom/_readium_cssSelectorGenerator.js +++ b/navigator/src/dom/_readium_cssSelectorGenerator.js @@ -1 +1 @@ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports._readium_cssSelectorGenerator=e():t._readium_cssSelectorGenerator=e()}(self,(()=>(()=>{"use strict";var t={d:(e,n)=>{for(var o in n)t.o(n,o)&&!t.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:n[o]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};function n(t){return"object"==typeof t&&null!==t&&t.nodeType===Node.ELEMENT_NODE}t.r(e),t.d(e,{_readium_cssSelectorGenerator:()=>Z,default:()=>tt,getCssSelector:()=>X});const o={NONE:"",DESCENDANT:" ",CHILD:" > "},r={id:"id",class:"class",tag:"tag",attribute:"attribute",nthchild:"nthchild",nthoftype:"nthoftype"},i="_readium_cssSelectorGenerator";function c(t="unknown problem",...e){console.warn(`${i}: ${t}`,...e)}const s={selectors:[r.id,r.class,r.tag,r.attribute],includeTag:!1,whitelist:[],blacklist:[/.*:.*/],combineWithinSelector:!0,combineBetweenSelectors:!0,root:null,maxCombinations:Number.POSITIVE_INFINITY,maxCandidates:Number.POSITIVE_INFINITY,useScope:!1};function u(t){return t instanceof RegExp}function l(t){return["string","function"].includes(typeof t)||u(t)}function a(t){return Array.isArray(t)?t.filter(l):[]}function f(t){const e=[Node.DOCUMENT_NODE,Node.DOCUMENT_FRAGMENT_NODE,Node.ELEMENT_NODE];return function(t){return t instanceof Node}(t)&&e.includes(t.nodeType)}function d(t,e){if(f(t))return t.contains(e)||c("element root mismatch","Provided root does not contain the element. This will most likely result in producing a fallback selector using element's real root node. If you plan to use the selector using provided root (e.g. `root.querySelector`), it will not work as intended."),t;const n=e.getRootNode({composed:!1});return f(n)?(n!==document&&c("shadow root inferred","You did not provide a root and the element is a child of Shadow DOM. This will produce a selector using ShadowRoot as a root. If you plan to use the selector using document as a root (e.g. `document.querySelector`), it will not work as intended."),n):S(e)}function m(t){return"number"==typeof t?t:Number.POSITIVE_INFINITY}function p(t=[]){const[e=[],...n]=t;return 0===n.length?e:n.reduce(((t,e)=>t.filter((t=>e.includes(t)))),e)}function g(t){const e=t.map((t=>{if(u(t))return e=>t.test(e);if("function"==typeof t)return e=>{const n=t(e);return"boolean"!=typeof n?(c("pattern matcher function invalid","Provided pattern matching function does not return boolean. It's result will be ignored.",t),!1):n};if("string"==typeof t){const e=new RegExp("^"+t.replace(/[|\\{}()[\]^$+?.]/g,"\\$&").replace(/\*/g,".+")+"$");return t=>e.test(t)}return c("pattern matcher invalid","Pattern matching only accepts strings, regular expressions and/or functions. This item is invalid and will be ignored.",t),()=>!1}));return t=>e.some((e=>e(t)))}function h(t,e,n){const o=Array.from(d(n,t[0]).querySelectorAll(e));return o.length===t.length&&t.every((t=>o.includes(t)))}function y(t,e){e=null!=e?e:S(t);const o=[];let r=t;for(;n(r)&&r!==e;)o.push(r),r=r.parentElement;return o}function b(t,e){return p(t.map((t=>y(t,e))))}function S(t){return t.ownerDocument.querySelector(":root")}const N=", ",v=new RegExp(["^$","\\s"].join("|")),E=new RegExp(["^$"].join("|")),x=[r.nthoftype,r.tag,r.id,r.class,r.attribute,r.nthchild],w=g(["class","id","ng-*"]);function I({name:t}){return`[${t}]`}function T({name:t,value:e}){return`[${t}='${e}']`}function O({nodeName:t,nodeValue:e}){return{name:F(t),value:F(null!=e?e:void 0)}}function C(t){const e=Array.from(t.attributes).filter((e=>function({nodeName:t,nodeValue:e},n){const o=n.tagName.toLowerCase();return!(["input","option"].includes(o)&&"value"===t||"src"===t&&(null==e?void 0:e.startsWith("data:"))||w(t))}(e,t))).map(O);return[...e.map(I),...e.map(T)]}function j(t){var e;return(null!==(e=t.getAttribute("class"))&&void 0!==e?e:"").trim().split(/\s+/).filter((t=>!E.test(t))).map((t=>`.${F(t)}`))}function A(t){var e;const n=null!==(e=t.getAttribute("id"))&&void 0!==e?e:"",o=`#${F(n)}`,r=t.getRootNode({composed:!1});return!v.test(n)&&h([t],o,r)?[o]:[]}function R(t){var e;const n=null===(e=t.parentElement)||void 0===e?void 0:e.children;if(n)for(let e=0;e1?[]:[e[0]]}function k(t){const e=D([t])[0],n=t.parentElement;if(n){const o=Array.from(n.children).filter((t=>t.tagName.toLowerCase()===e)),r=o.indexOf(t);if(r>-1)return[`${e}:nth-of-type(${String(r+1)})`]}return[]}function*P(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){let n=0,o=L(1);for(;o.length<=t.length&&nt[e]));yield e,o=_(o,t.length-1)}}function _(t=[],e=0){const n=t.length;if(0===n)return[];const o=[...t];o[n-1]+=1;for(let t=n-1;t>=0;t--)if(o[t]>e){if(0===t)return L(n+1);o[t-1]++,o[t]=o[t-1]+1}return o[n-1]>e?L(n+1):o}function L(t=1){return Array.from(Array(t).keys())}const M=":".charCodeAt(0).toString(16).toUpperCase(),V=/[ !"#$%&'()\[\]{|}<>*+,./;=?@^`~\\]/;function F(t=""){return CSS?CSS.escape(t):function(t=""){return t.split("").map((t=>":"===t?`\\${M} `:V.test(t)?`\\${t}`:escape(t).replace(/%/g,"\\"))).join("")}(t)}const Y={tag:D,id:function(t){return 0===t.length||t.length>1?[]:A(t[0])},class:function(t){return p(t.map(j))},attribute:function(t){return p(t.map(C))},nthchild:function(t){return p(t.map(R))},nthoftype:function(t){return p(t.map(k))}},G={tag:$,id:A,class:j,attribute:C,nthchild:R,nthoftype:k};function W(t){return t.includes(r.tag)||t.includes(r.nthoftype)?[...t]:[...t,r.tag]}function*q(t,e){const n={};for(const o of t){const t=e[o];t&&t.length>0&&(n[o]=t)}for(const t of function*(t={}){const e=Object.entries(t);if(0===e.length)return;const n=[{index:e.length-1,partial:{}}];for(;n.length>0;){const t=n.pop();if(!t)break;const{index:o,partial:r}=t;if(o<0){yield r;continue}const[i,c]=e[o];for(let t=c.length-1;t>=0;t--)n.push({index:o-1,partial:Object.assign(Object.assign({},r),{[i]:c[t]})})}}(n))yield B(t)}function B(t={}){const e=[...x];return t[r.tag]&&t[r.nthoftype]&&e.splice(e.indexOf(r.tag),1),e.map((e=>{return(o=t)[n=e]?o[n].join(""):"";var n,o})).join("")}function H(t,e){return[...t.map((t=>e+o.DESCENDANT+t)),...t.map((t=>e+o.CHILD+t))]}function*U(t,e,n="",o){const r=function*(t,e){const n=new Set,o=function(t,e){const{blacklist:n,whitelist:o,combineWithinSelector:r,maxCombinations:i}=e,c=g(n),s=g(o);return function(t){const{selectors:e,includeTag:n}=t,o=[...e];return n&&!o.includes("tag")&&o.push("tag"),o}(e).reduce(((e,n)=>{const o=function(t,e){return(0,Y[e])(t)}(t,n),u=function(t=[],e,n){return t.filter((t=>n(t)||!e(t)))}(o,c,s),l=function(t=[],e){return t.sort(((t,n)=>{const o=e(t),r=e(n);return o&&!r?-1:!o&&r?1:0}))}(u,s);return e[n]=r?Array.from(P(l,{maxResults:i})):l.map((t=>[t])),e}),{})}(t,e);for(const t of function*(t,e){for(const n of function(t){const{selectors:e,combineBetweenSelectors:n,includeTag:o,maxCandidates:r}=t,i=n?function(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){return Array.from(P(t,{maxResults:e}))}(e,{maxResults:r}):e.map((t=>[t]));return o?i.map(W):i}(e))yield*q(n,t)}(o,e))n.has(t)||(n.add(t),yield t)}(t,o);for(const o of function*(t,e){if(""===e)yield*t;else for(const n of t)yield*H([n],e)}(r,n))h(t,o,e)&&(yield o)}function*z(t,e,n="",o){if(0===t.length)return null;const r=[t.length>1?t:[],...b(t,e).map((t=>[t]))];for(const t of r)for(const r of U(t,e,n,o))yield{foundElements:t,selector:r}}function J(t){return{value:t,include:!1}}function K({selectors:t,operator:e}){let n=[...x];t[r.tag]&&t[r.nthoftype]&&(n=n.filter((t=>t!==r.tag)));let o="";return n.forEach((e=>{var n;(null!==(n=t[e])&&void 0!==n?n:[]).forEach((({value:t,include:e})=>{e&&(o+=t)}))})),e+o}function Q(t,e){return t.map((t=>function(t,e){return[e?":scope":":root",...y(t,e).reverse().map((t=>{var e;const n=function(t,e,n=o.NONE){const r={};return e.forEach((e=>{Reflect.set(r,e,function(t,e){return G[e](t)}(t,e).map(J))})),{element:t,operator:n,selectors:r}}(t,[r.nthchild],o.CHILD);return(null!==(e=n.selectors.nthchild)&&void 0!==e?e:[]).forEach((t=>{t.include=!0})),n})).map(K)].join("")}(t,e))).join(N)}function X(t,e={}){return Z(t,Object.assign(Object.assign({},e),{maxResults:1})).next().value}function*Z(t,e={}){var o;const i=function(t){(t instanceof NodeList||t instanceof HTMLCollection)&&(t=Array.from(t));const e=(Array.isArray(t)?t:[t]).filter(n);return[...new Set(e)]}(t),c=function(t,e={}){const n=Object.assign(Object.assign({},s),e);return{selectors:(o=n.selectors,Array.isArray(o)?o.filter((t=>{return e=r,n=t,Object.values(e).includes(n);var e,n})):[]),whitelist:a(n.whitelist),blacklist:a(n.blacklist),root:d(n.root,t),combineWithinSelector:!!n.combineWithinSelector,combineBetweenSelectors:!!n.combineBetweenSelectors,includeTag:!!n.includeTag,maxCombinations:m(n.maxCombinations),maxCandidates:m(n.maxCandidates),useScope:!!n.useScope,maxResults:m(n.maxResults)};var o}(i[0],e),u=null!==(o=c.root)&&void 0!==o?o:S(i[0]);let l=0;for(const t of function*({elements:t,root:e,rootSelector:n="",options:o}){let r=e,i=n,c=!0;for(;c;){let n=!1;for(const c of z(t,r,i,o)){const{foundElements:o,selector:s}=c;if(n=!0,!h(t,s,e)){r=o[0],i=s;break}yield s}n||(c=!1)}}({elements:i,options:c,root:u,rootSelector:""}))if(yield t,l++,l>=c.maxResults)return;i.length>1&&(yield i.map((t=>X(t,c))).join(N),l++,l>=c.maxResults)||(yield Q(i,c.useScope?u:void 0))}const tt=X;return e})())); \ No newline at end of file +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports._readium_cssSelectorGenerator=e():t._readium_cssSelectorGenerator=e()}(self,(()=>(()=>{"use strict";var t={d:(e,n)=>{for(var o in n)t.o(n,o)&&!t.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:n[o]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},e={};function n(t){return"object"==typeof t&&null!==t&&t.nodeType===Node.ELEMENT_NODE}t.r(e),t.d(e,{_readium_cssSelectorGenerator:()=>Z,default:()=>tt,getCssSelector:()=>X});const o={NONE:"",DESCENDANT:" ",CHILD:" > "},r={id:"id",class:"class",tag:"tag",attribute:"attribute",nthchild:"nthchild",nthoftype:"nthoftype"},i="_readium_cssSelectorGenerator";function c(t="unknown problem",...e){console.warn(`${i}: ${t}`,...e)}const s={selectors:[r.id,r.class,r.tag,r.attribute],includeTag:!1,whitelist:[],blacklist:[],combineWithinSelector:!0,combineBetweenSelectors:!0,root:null,maxCombinations:Number.POSITIVE_INFINITY,maxCandidates:Number.POSITIVE_INFINITY,useScope:!1};function u(t){return t instanceof RegExp}function l(t){return["string","function"].includes(typeof t)||u(t)}function a(t){return Array.isArray(t)?t.filter(l):[]}function f(t){const e=[Node.DOCUMENT_NODE,Node.DOCUMENT_FRAGMENT_NODE,Node.ELEMENT_NODE];return function(t){return t instanceof Node}(t)&&e.includes(t.nodeType)}function d(t,e){if(f(t))return t.contains(e)||c("element root mismatch","Provided root does not contain the element. This will most likely result in producing a fallback selector using element's real root node. If you plan to use the selector using provided root (e.g. `root.querySelector`), it will not work as intended."),t;const n=e.getRootNode({composed:!1});return f(n)?(n!==document&&c("shadow root inferred","You did not provide a root and the element is a child of Shadow DOM. This will produce a selector using ShadowRoot as a root. If you plan to use the selector using document as a root (e.g. `document.querySelector`), it will not work as intended."),n):S(e)}function m(t){return"number"==typeof t?t:Number.POSITIVE_INFINITY}function p(t=[]){const[e=[],...n]=t;return 0===n.length?e:n.reduce(((t,e)=>t.filter((t=>e.includes(t)))),e)}function g(t){const e=t.map((t=>{if(u(t))return e=>t.test(e);if("function"==typeof t)return e=>{const n=t(e);return"boolean"!=typeof n?(c("pattern matcher function invalid","Provided pattern matching function does not return boolean. It's result will be ignored.",t),!1):n};if("string"==typeof t){const e=new RegExp("^"+t.replace(/[|\\{}()[\]^$+?.]/g,"\\$&").replace(/\*/g,".+")+"$");return t=>e.test(t)}return c("pattern matcher invalid","Pattern matching only accepts strings, regular expressions and/or functions. This item is invalid and will be ignored.",t),()=>!1}));return t=>e.some((e=>e(t)))}function h(t,e,n){const o=Array.from(d(n,t[0]).querySelectorAll(e));return o.length===t.length&&t.every((t=>o.includes(t)))}function y(t,e){e=null!=e?e:S(t);const o=[];let r=t;for(;n(r)&&r!==e;)o.push(r),r=r.parentElement;return o}function b(t,e){return p(t.map((t=>y(t,e))))}function S(t){return t.ownerDocument.querySelector(":root")}const N=", ",v=new RegExp(["^$","\\s"].join("|")),E=new RegExp(["^$"].join("|")),x=[r.nthoftype,r.tag,r.id,r.class,r.attribute,r.nthchild],w=g(["class","id","ng-*"]);function I({name:t}){return`[${t}]`}function T({name:t,value:e}){return`[${t}='${e}']`}function O({nodeName:t,nodeValue:e}){return{name:F(t),value:F(null!=e?e:void 0)}}function C(t){const e=Array.from(t.attributes).filter((e=>function({nodeName:t,nodeValue:e},n){const o=n.tagName.toLowerCase();return!(["input","option"].includes(o)&&"value"===t||"src"===t&&(null==e?void 0:e.startsWith("data:"))||w(t))}(e,t))).map(O);return[...e.map(I),...e.map(T)]}function j(t){var e;return(null!==(e=t.getAttribute("class"))&&void 0!==e?e:"").trim().split(/\s+/).filter((t=>!E.test(t))).map((t=>`.${F(t)}`))}function A(t){var e;const n=null!==(e=t.getAttribute("id"))&&void 0!==e?e:"",o=`#${F(n)}`,r=t.getRootNode({composed:!1});return!v.test(n)&&h([t],o,r)?[o]:[]}function R(t){var e;const n=null===(e=t.parentElement)||void 0===e?void 0:e.children;if(n)for(let e=0;e1?[]:[e[0]]}function k(t){const e=D([t])[0],n=t.parentElement;if(n){const o=Array.from(n.children).filter((t=>t.tagName.toLowerCase()===e)),r=o.indexOf(t);if(r>-1)return[`${e}:nth-of-type(${String(r+1)})`]}return[]}function*P(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){let n=0,o=L(1);for(;o.length<=t.length&&nt[e]));yield e,o=_(o,t.length-1)}}function _(t=[],e=0){const n=t.length;if(0===n)return[];const o=[...t];o[n-1]+=1;for(let t=n-1;t>=0;t--)if(o[t]>e){if(0===t)return L(n+1);o[t-1]++,o[t]=o[t-1]+1}return o[n-1]>e?L(n+1):o}function L(t=1){return Array.from(Array(t).keys())}const M=":".charCodeAt(0).toString(16).toUpperCase(),V=/[ !"#$%&'()\[\]{|}<>*+,./;=?@^`~\\]/;function F(t=""){return CSS?CSS.escape(t):function(t=""){return t.split("").map((t=>":"===t?`\\${M} `:V.test(t)?`\\${t}`:escape(t).replace(/%/g,"\\"))).join("")}(t)}const Y={tag:D,id:function(t){return 0===t.length||t.length>1?[]:A(t[0])},class:function(t){return p(t.map(j))},attribute:function(t){return p(t.map(C))},nthchild:function(t){return p(t.map(R))},nthoftype:function(t){return p(t.map(k))}},G={tag:$,id:A,class:j,attribute:C,nthchild:R,nthoftype:k};function W(t){return t.includes(r.tag)||t.includes(r.nthoftype)?[...t]:[...t,r.tag]}function*q(t,e){const n={};for(const o of t){const t=e[o];t&&t.length>0&&(n[o]=t)}for(const t of function*(t={}){const e=Object.entries(t);if(0===e.length)return;const n=[{index:e.length-1,partial:{}}];for(;n.length>0;){const t=n.pop();if(!t)break;const{index:o,partial:r}=t;if(o<0){yield r;continue}const[i,c]=e[o];for(let t=c.length-1;t>=0;t--)n.push({index:o-1,partial:Object.assign(Object.assign({},r),{[i]:c[t]})})}}(n))yield B(t)}function B(t={}){const e=[...x];return t[r.tag]&&t[r.nthoftype]&&e.splice(e.indexOf(r.tag),1),e.map((e=>{return(o=t)[n=e]?o[n].join(""):"";var n,o})).join("")}function H(t,e){return[...t.map((t=>e+o.DESCENDANT+t)),...t.map((t=>e+o.CHILD+t))]}function*U(t,e,n="",o){const r=function*(t,e){const n=new Set,o=function(t,e){const{blacklist:n,whitelist:o,combineWithinSelector:r,maxCombinations:i}=e,c=g(n),s=g(o);return function(t){const{selectors:e,includeTag:n}=t,o=[...e];return n&&!o.includes("tag")&&o.push("tag"),o}(e).reduce(((e,n)=>{const o=function(t,e){return(0,Y[e])(t)}(t,n),u=function(t=[],e,n){return t.filter((t=>n(t)||!e(t)))}(o,c,s),l=function(t=[],e){return t.sort(((t,n)=>{const o=e(t),r=e(n);return o&&!r?-1:!o&&r?1:0}))}(u,s);return e[n]=r?Array.from(P(l,{maxResults:i})):l.map((t=>[t])),e}),{})}(t,e);for(const t of function*(t,e){for(const n of function(t){const{selectors:e,combineBetweenSelectors:n,includeTag:o,maxCandidates:r}=t,i=n?function(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){return Array.from(P(t,{maxResults:e}))}(e,{maxResults:r}):e.map((t=>[t]));return o?i.map(W):i}(e))yield*q(n,t)}(o,e))n.has(t)||(n.add(t),yield t)}(t,o);for(const o of function*(t,e){if(""===e)yield*t;else for(const n of t)yield*H([n],e)}(r,n))h(t,o,e)&&(yield o)}function*z(t,e,n="",o){if(0===t.length)return null;const r=[t.length>1?t:[],...b(t,e).map((t=>[t]))];for(const t of r)for(const r of U(t,e,n,o))yield{foundElements:t,selector:r}}function J(t){return{value:t,include:!1}}function K({selectors:t,operator:e}){let n=[...x];t[r.tag]&&t[r.nthoftype]&&(n=n.filter((t=>t!==r.tag)));let o="";return n.forEach((e=>{var n;(null!==(n=t[e])&&void 0!==n?n:[]).forEach((({value:t,include:e})=>{e&&(o+=t)}))})),e+o}function Q(t,e){return t.map((t=>function(t,e){return[e?":scope":":root",...y(t,e).reverse().map((t=>{var e;const n=function(t,e,n=o.NONE){const r={};return e.forEach((e=>{Reflect.set(r,e,function(t,e){return G[e](t)}(t,e).map(J))})),{element:t,operator:n,selectors:r}}(t,[r.nthchild],o.CHILD);return(null!==(e=n.selectors.nthchild)&&void 0!==e?e:[]).forEach((t=>{t.include=!0})),n})).map(K)].join("")}(t,e))).join(N)}function X(t,e={}){return Z(t,Object.assign(Object.assign({},e),{maxResults:1})).next().value}function*Z(t,e={}){var o;const i=function(t){(t instanceof NodeList||t instanceof HTMLCollection)&&(t=Array.from(t));const e=(Array.isArray(t)?t:[t]).filter(n);return[...new Set(e)]}(t),c=function(t,e={}){const n=Object.assign(Object.assign({},s),e);return{selectors:(o=n.selectors,Array.isArray(o)?o.filter((t=>{return e=r,n=t,Object.values(e).includes(n);var e,n})):[]),whitelist:a(n.whitelist),blacklist:a(n.blacklist),root:d(n.root,t),combineWithinSelector:!!n.combineWithinSelector,combineBetweenSelectors:!!n.combineBetweenSelectors,includeTag:!!n.includeTag,maxCombinations:m(n.maxCombinations),maxCandidates:m(n.maxCandidates),useScope:!!n.useScope,maxResults:m(n.maxResults)};var o}(i[0],e),u=null!==(o=c.root)&&void 0!==o?o:S(i[0]);let l=0;for(const t of function*({elements:t,root:e,rootSelector:n="",options:o}){let r=e,i=n,c=!0;for(;c;){let n=!1;for(const c of z(t,r,i,o)){const{foundElements:o,selector:s}=c;if(n=!0,!h(t,s,e)){r=o[0],i=s;break}yield s}n||(c=!1)}}({elements:i,options:c,root:u,rootSelector:""}))if(yield t,l++,l>=c.maxResults)return;i.length>1&&(yield i.map((t=>X(t,c))).join(N),l++,l>=c.maxResults)||(yield Q(i,c.useScope?u:void 0))}const tt=X;return e})())); \ No newline at end of file diff --git a/navigator/src/dom/_readium_executionCleanup.js b/navigator/src/dom/_readium_executionCleanup.js new file mode 100644 index 00000000..a5217ec2 --- /dev/null +++ b/navigator/src/dom/_readium_executionCleanup.js @@ -0,0 +1,13 @@ +(function() { + if(window.onload) window.onload = new Proxy(window.onload, { + apply: function(target, receiver, args) { + if(!window._readium_blockEvents) { + Reflect.apply(target, receiver, args); + return; + } + _readium_blockedEvents.push([ + 0, target, receiver, args + ]); + } + }); +})(); diff --git a/navigator/src/dom/_readium_executionPrevention.js b/navigator/src/dom/_readium_executionPrevention.js new file mode 100644 index 00000000..ed40dcd7 --- /dev/null +++ b/navigator/src/dom/_readium_executionPrevention.js @@ -0,0 +1,65 @@ +// Note: we aren't blocking some of the events right now to try and be as nonintrusive as possible. +// For a more comprehensive implementation, see https://github.com/hackademix/noscript/blob/3a83c0e4a506f175e38b0342dad50cdca3eae836/src/content/syncFetchPolicy.js#L142 +// The snippet of code at the beginning of this source is an attempt at defence against JS using persistent storage +(function() { + const noop = () => {}, emptyObj = {}, emptyPromise = () => Promise.resolve(void 0), fakeStorage = { + getItem: noop, + setItem: noop, + removeItem: noop, + clear: noop, + key: noop, + length: 0 + }; + + ["localStorage", "sessionStorage"].forEach((e) => Object.defineProperty(window, e, { + get: () => fakeStorage, + configurable: !0 + })); + + Object.defineProperty(document, "cookie", { + get: () => "", + set: noop, + configurable: !0 + }); + + Object.defineProperty(window, "indexedDB", { + get: () => {}, + configurable: !0 + }); + + Object.defineProperty(window, "caches", { + get: () => emptyObj, + configurable: !0 + }); + + Object.defineProperty(navigator, "storage", { + get: () => ({ + persist: emptyPromise, + persisted: emptyPromise, + estimate: () => Promise.resolve({quota: 0, usage: 0}) + }), + configurable: !0 + }); + + Object.defineProperty(navigator, "serviceWorker", { + get: () => ({ + register: emptyPromise, + getRegistration: emptyPromise, + ready: emptyPromise() + }), + configurable: !0 + }); + + window._readium_blockedEvents = []; + window._readium_blockEvents = true; + window._readium_eventBlocker = (e) => { + if(!window._readium_blockEvents) return; + e.preventDefault(); + e.stopImmediatePropagation(); + _readium_blockedEvents.push([ + 1, e, e.currentTarget || e.target + ]); + }; + window.addEventListener("DOMContentLoaded", window._readium_eventBlocker, true); + window.addEventListener("load", window._readium_eventBlocker, true); +})(); diff --git a/navigator/src/dom/_readium_webpubProperties.js b/navigator/src/dom/_readium_webpubProperties.js new file mode 100644 index 00000000..65290312 --- /dev/null +++ b/navigator/src/dom/_readium_webpubProperties.js @@ -0,0 +1,4 @@ +// WebPub-specific setup - no execution blocking needed +window._readium_blockedEvents = []; +window._readium_blockEvents = false; // WebPub doesn't need event blocking +window._readium_eventBlocker = null; diff --git a/navigator/src/epub/EpubNavigator.ts b/navigator/src/epub/EpubNavigator.ts index 56fba736..efca07bd 100644 --- a/navigator/src/epub/EpubNavigator.ts +++ b/navigator/src/epub/EpubNavigator.ts @@ -15,6 +15,7 @@ import { ReadiumCSS } from "./css/ReadiumCSS"; import { RSProperties, UserProperties } from "./css/Properties"; import { getContentWidth } from "../helpers/dimensions"; import { Injector } from "../injection/Injector"; +import { createReadiumEpubRules } from "../injection/epubInjectables"; import { IInjectablesConfig } from "../injection/Injectable"; export type ManagerEventKey = "zoom"; @@ -109,7 +110,15 @@ export class EpubNavigator extends VisualNavigator implements Configurable URL.createObjectURL(new Blob([source], { type })); -const stripJS = (source: string) => source.replace(/\/\/.*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\n/g, "").replace(/\s+/g, " "); -const stripCSS = (source: string) => source.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\/|[\r\n\t]+/g, '').replace(/ {2,}/g, ' ') - // Fully resolve absolute local URLs created by bundlers since it's going into a blob - .replace(/url\((?!(https?:)?\/\/)("?)\/([^\)]+)/g, `url($2${window.location.origin}/$3`); -const scriptify = (doc: Document, source: string) => { - const s = doc.createElement("script"); - s.dataset.readium = "true"; - s.src = source.startsWith("blob:") ? source : blobify(source, "text/javascript"); - return s; -} -const styleify = (doc: Document, source: string) => { - const s = doc.createElement("link"); - s.dataset.readium = "true"; - s.rel = "stylesheet"; - s.type = "text/css"; - s.href = source.startsWith("blob:") ? source : blobify(source, "text/css"); - return s; -} - -type CacheFunction = () => string; -const resourceBlobCache = new Map(); -const cached = (key: string, cacher: CacheFunction) => { - if(resourceBlobCache.has(key)) return resourceBlobCache.get(key)!; - const value = cacher(); - resourceBlobCache.set(key, value); - return value; -}; - -const cssSelectorGenerator = (doc: Document) => scriptify(doc, cached("css-selector-generator", () => blobify( - cssSelectorGeneratorContent, - "text/javascript" -))); - -// Note: we aren't blocking some of the events right now to try and be as nonintrusive as possible. -// For a more comprehensive implementation, see https://github.com/hackademix/noscript/blob/3a83c0e4a506f175e38b0342dad50cdca3eae836/src/content/syncFetchPolicy.js#L142 -// The snippet of code at the beginning of this source is an attempt at defence against JS using persistent storage -const rBefore = (doc: Document) => scriptify(doc, cached("JS-Before", () => blobify(stripJS(` - const noop=()=>{},emptyObj={},emptyPromise=()=>Promise.resolve(void 0),fakeStorage={getItem:noop,setItem:noop,removeItem:noop,clear:noop,key:noop,length:0};["localStorage","sessionStorage"].forEach((e=>Object.defineProperty(window,e,{get:()=>fakeStorage,configurable:!0}))),Object.defineProperty(document,"cookie",{get:()=>"",set:noop,configurable:!0}),Object.defineProperty(window,"indexedDB",{get:()=>{},configurable:!0}),Object.defineProperty(window,"caches",{get:()=>emptyObj,configurable:!0}),Object.defineProperty(navigator,"storage",{get:()=>({persist:emptyPromise,persisted:emptyPromise,estimate:()=>Promise.resolve({quota:0,usage:0})}),configurable:!0}),Object.defineProperty(navigator,"serviceWorker",{get:()=>({register:emptyPromise,getRegistration:emptyPromise,ready:emptyPromise()}),configurable:!0}); - - window._readium_blockedEvents = []; - window._readium_blockEvents = true; - window._readium_eventBlocker = (e) => { - if(!window._readium_blockEvents) return; - e.preventDefault(); - e.stopImmediatePropagation(); - _readium_blockedEvents.push([ - 1, e, e.currentTarget || e.target - ]); - }; - window.addEventListener("DOMContentLoaded", window._readium_eventBlocker, true); - window.addEventListener("load", window._readium_eventBlocker, true);` -), "text/javascript"))); -const rAfter = (doc: Document) => scriptify(doc, cached("JS-After", () => blobify(stripJS(` - if(window.onload) window.onload = new Proxy(window.onload, { - apply: function(target, receiver, args) { - if(!window._readium_blockEvents) { - Reflect.apply(target, receiver, args); - return; - } - _readium_blockedEvents.push([ - 0, target, receiver, args - ]); - } - });` -), "text/javascript"))); - const csp = (domains: string[]) => { const d = domains.join(" "); return [ @@ -170,29 +89,6 @@ export default class FrameBlobBuider { return this.finalizeDOM(doc, this.pub.baseURL, this.burl, this.item.mediaType, true); } - // Has JS that may have side-effects when the document is loaded, without any user interaction - private hasExecutable(doc: Document): boolean { - // This is not a 100% comprehensive check of all possibilities for JS execution, - // but it covers what the prevention scripts cover. Other possibilities include: - // -