diff --git a/navigator/docs/epub/ResourceInjection.md b/navigator/docs/epub/ResourceInjection.md new file mode 100644 index 00000000..4d755938 --- /dev/null +++ b/navigator/docs/epub/ResourceInjection.md @@ -0,0 +1,250 @@ +# Resource Injection System + +The Readium Navigator includes a powerful resource injection system that allows you to dynamically inject CSS, JavaScript, and other resources into EPUB and WebPub content documents. This system is used internally to provide core functionality like ReadiumCSS, script execution control, and CSS selector generation. + +## Overview + +The injection system consists of three main components: + +- **Injectables**: Definitions of resources to be injected (scripts, stylesheets, etc.) +- **Rules**: Patterns that determine which injectables should be applied to which documents +- **Injector**: The engine that processes rules and injects resources into documents + +## Core Concepts + +### Injectables + +An injectable represents a single resource that can be injected into a document. There are two types: + +```typescript +// URL-based injectable (external resource) +const urlInjectable: IUrlInjectable = { + id: "external-script", + as: "script", + url: "https://cdn.example.com/script.js", + target: "head" +}; + +// Blob-based injectable (inline content) +const blobInjectable: IBlobInjectable = { + id: "inline-styles", + as: "link", + blob: new Blob(["body { color: red; }"], { type: "text/css" }), + rel: "stylesheet", + target: "head" +}; +``` + +### Injection Rules + +Rules define which injectables should be applied to which documents based on URL patterns: + +```typescript +const rule: IInjectableRule = { + resources: [/* Resources from readingOrder or regex patterns */], + prepend: [/* injectables to prepend to their target (head/body) */], + append: [/* injectables to append to their target (head/body) */] +}; +``` + +### Configuration + +The injection system is configured through `IInjectablesConfig`: + +```typescript +const config: IInjectablesConfig = { + rules: [/* array of rules */], + allowedDomains: ["https://fonts.googleapis.com"] // Optional: allow external domains +}; +``` + +## Injectable Properties + +### Core Properties + +- **`id`**: Unique identifier for the injectable (auto-generated if not provided) +- **`as`**: Resource type - `"script"` or `"link"` +- **`rel`**: Required when `as` is `"link"` (e.g., `"stylesheet"`, `"preload"`) +- **`target`**: Where to inject - `"head"` or `"body"` (default: `"head"`) + +### Resource Properties + +For URL-based injectables: +- **`url`**: URL to the resource. Can be: + - An absolute HTTPS URL (must be in the `allowedDomains` list) + - A `data:` URL for inline content + - A `blob:` URL (you are responsible for managing the blob URL lifecycle) + +For Blob-based injectables: +- **`blob`**: Blob object containing the resource content. The injector will: + - Create and cache a `blob:` URL for this resource + - Reuse the same URL if the same Blob is injected multiple times + - Automatically revoke the blob URL when it's no longer used or when the injector is disposed + +### Optional Properties + +- **`type`**: MIME type (inferred from file extension or blob type for CSS and JS resources if not provided) +- **`condition`**: Function to run in the resource's context that determines if the injectable should be applied (target document is passed as a parameter) +- **`attributes`**: Additional HTML attributes (excluding `type`, `rel`, `href`, `src`) + +## Conditional Injection + +Injectables can be conditionally applied based on document content: + +```typescript +const conditionalScript: IInjectable = { + id: "conditional-script", + as: "script", + blob: new Blob(["console.log('Scripts detected!');"], { type: "text/javascript" }), + condition: (doc: Document) => { + // Only inject if the document has existing scripts + return !!doc.querySelector("script"); + } +}; +``` + +## Domain Security + +The injection system includes built-in security controls for external resources: + +### Allowed Domains + +You can allow specific domains for external resources: + +```typescript +const config: IInjectablesConfig = { + rules: [/* rules */], + allowedDomains: [ + "https://fonts.googleapis.com", + "https://cdn.jsdelivr.net" + ] +}; +``` + +### URL Validation + +The system only allows: +- URLs from allowed domains +- `data:` URLs +- `blob:` URLs + +## Built-in Injectables + +### EPUB Content + +Core functionality injected into all EPUB content: + +- **CSS Selector Generator**: Enables customized CSS selection utilities +- **Execution Prevention**: Blocks script execution when scripts are detected +- **Onload Proxy**: Manages script cleanup when scripts are detected +- **Readium CSS**: Applies reading-optimized styles (only for reflowable documents) + +### WebPub Content + +Core functionality injected into all WebPub content: + +- **CSS Selector Generator**: Enables customized CSS selection utilities +- **WebPub Execution**: Manages WebPub-specific events and state +- **Onload Proxy**: Manages script cleanup +- **Readium CSS WebPub**: Applies WebPub-specific reading styles + +## Custom Injection + +### Creating Custom Rules + +Create rules to inject resources into specific documents. The `resources` array accepts either paths in the reading order or RegExp patterns to match against document URLs. + +```typescript +const customRule: IInjectableRule = { + resources: [/.*\.xhtml$/i], // Matches all .xhtml files (case insensitive) + prepend: [ + { + id: "custom-styles", + as: "link", + blob: new Blob([".highlight { background: yellow; }"], { type: "text/css" }), + rel: "stylesheet" + } + ], + append: [ + { + id: "custom-script", + as: "script", + blob: new Blob(["console.log('Chapter loaded');"], { type: "text/javascript" }) + } + ] +}; +``` + +### Integrating with EpubNavigator + +To use custom injection with `EpubNavigator`, pass your injection rules through the configuration object: + +```typescript +const navigator = new EpubNavigator( + container, + publication, + listeners, + positions, + initialPosition, + { + preferences: { /* your preferences */ }, + defaults: { /* your defaults */ }, + injectables: { + rules: [/* your custom rules */], + allowedDomains: [/* allowed domains if needed */] + } + } +); +``` + +## Best Practices + +1. **Use Unique IDs**: Always provide meaningful IDs for your injectables +2. **Be Specific with Patterns**: Use precise URL patterns to avoid unintended injections +3. **Secure External Resources**: Always allow domains for external resources +4. **Consider Performance**: Use conditional injection to avoid unnecessary overhead +5. **Test Thoroughly**: Test injection rules with various document types and content + +## API Reference + +### IInjectableRule Interface + +```typescript +interface IInjectableRule { + resources: Array; + prepend?: IInjectable[]; + append?: IInjectable[]; +} +``` + +### IInjectable Interface + +```typescript +interface IBaseInjectable { + id?: string; + target?: "head" | "body"; + type?: string; + condition?: (doc: Document) => boolean; + attributes?: AllowedAttributes; +} + +interface IScriptInjectable extends IBaseInjectable { + as: "script"; + rel?: never; // Scripts don't have rel +} + +interface ILinkInjectable extends IBaseInjectable { + as: "link"; + rel: string; // Required for links +} + +interface IUrlInjectable { + url: string; +} + +interface IBlobInjectable { + blob: Blob; +} + +type IInjectable = (IScriptInjectable | ILinkInjectable) & (IUrlInjectable | IBlobInjectable); +``` \ No newline at end of file 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_webpubExecution.js b/navigator/src/dom/_readium_webpubExecution.js new file mode 100644 index 00000000..65290312 --- /dev/null +++ b/navigator/src/dom/_readium_webpubExecution.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 ef8a3a81..4fae0853 100644 --- a/navigator/src/epub/EpubNavigator.ts +++ b/navigator/src/epub/EpubNavigator.ts @@ -14,12 +14,16 @@ 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 { createReadiumEpubRules } from "../injection/epubInjectables"; +import { IInjectablesConfig } from "../injection/Injectable"; export type ManagerEventKey = "zoom"; export interface EpubNavigatorConfiguration { preferences: IEpubPreferences; defaults: IEpubDefaults; + injectables?: IInjectablesConfig; } export interface EpubNavigatorListeners { @@ -65,6 +69,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 a40d1b90..efbdb888 100644 --- a/navigator/src/epub/frame/FrameBlobBuilder.ts +++ b/navigator/src/epub/frame/FrameBlobBuilder.ts @@ -1,86 +1,6 @@ import { MediaType } from "@readium/shared"; import { Link, Publication } from "@readium/shared"; - -// Readium CSS imports -// The "?inline" query is to prevent some bundlers from injecting these into the page (e.g. vite) -// @ts-ignore -import readiumCSSAfter from "@readium/css/css/dist/ReadiumCSS-after.css?inline"; -// @ts-ignore -import readiumCSSBefore from "@readium/css/css/dist/ReadiumCSS-before.css?inline"; -// @ts-ignore -import readiumCSSDefault from "@readium/css/css/dist/ReadiumCSS-default.css?inline"; - -// Import the pre-built CSS selector generator -// This has to be injected because you need to be in the iframe's context for it to work properly -import cssSelectorGeneratorContent from "../../dom/_readium_cssSelectorGenerator.js?raw"; - -// Utilities -const blobify = (source: string, type: string) => 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"))); +import { Injector } from "../../injection/Injector"; const csp = (domains: string[]) => { const d = domains.join(" "); @@ -105,12 +25,22 @@ export default class FrameBlobBuider { 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(fxl = false): Promise { @@ -128,15 +58,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); } @@ -151,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: - // -