@readium/navigator 2.2.7 → 2.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ar-DyHX_uy2-DyHX_uy2-DyHX_uy2.js +7 -0
- package/dist/ar-DyHX_uy2-DyHX_uy2.js +7 -0
- package/dist/da-Dct0PS3E-Dct0PS3E-Dct0PS3E.js +7 -0
- package/dist/da-Dct0PS3E-Dct0PS3E.js +7 -0
- package/dist/fr-C5HEel98-C5HEel98-C5HEel98.js +7 -0
- package/dist/fr-C5HEel98-C5HEel98.js +7 -0
- package/dist/index.js +4330 -2342
- package/dist/index.umd.cjs +1571 -39
- package/dist/it-DFOBoXGy-DFOBoXGy-DFOBoXGy.js +7 -0
- package/dist/it-DFOBoXGy-DFOBoXGy.js +7 -0
- package/dist/pt_PT-Di3sVjze-Di3sVjze-Di3sVjze.js +7 -0
- package/dist/pt_PT-Di3sVjze-Di3sVjze.js +7 -0
- package/dist/sv-BfzAFsVN-BfzAFsVN-BfzAFsVN.js +7 -0
- package/dist/sv-BfzAFsVN-BfzAFsVN.js +7 -0
- package/package.json +1 -1
- package/src/dom/_readium_executionCleanup.js +13 -0
- package/src/dom/_readium_executionPrevention.js +65 -0
- package/src/dom/_readium_webpubExecution.js +4 -0
- package/src/epub/EpubNavigator.ts +26 -2
- package/src/epub/frame/FrameBlobBuilder.ts +37 -131
- package/src/epub/frame/FramePoolManager.ts +34 -5
- package/src/epub/fxl/FXLFramePoolManager.ts +20 -2
- package/src/helpers/minify.ts +14 -0
- package/src/index.ts +2 -1
- package/src/injection/Injectable.ts +85 -0
- package/src/injection/Injector.ts +356 -0
- package/src/injection/epubInjectables.ts +90 -0
- package/src/injection/index.ts +2 -0
- package/src/injection/webpubInjectables.ts +59 -0
- package/src/webpub/WebPubBlobBuilder.ts +19 -80
- package/src/webpub/WebPubFramePoolManager.ts +29 -4
- package/src/webpub/WebPubNavigator.ts +15 -1
- package/types/src/epub/EpubNavigator.d.ts +3 -0
- package/types/src/epub/frame/FrameBlobBuilder.d.ts +7 -4
- package/types/src/epub/frame/FramePoolManager.d.ts +3 -1
- package/types/src/epub/fxl/FXLFramePoolManager.d.ts +3 -1
- package/types/src/helpers/minify.d.ts +12 -0
- package/types/src/index.d.ts +1 -0
- package/types/src/injection/Injectable.d.ts +68 -0
- package/types/src/injection/Injector.d.ts +22 -0
- package/types/src/injection/epubInjectables.d.ts +6 -0
- package/types/src/injection/index.d.ts +2 -0
- package/types/src/injection/webpubInjectables.d.ts +5 -0
- package/types/src/webpub/WebPubBlobBuilder.d.ts +7 -3
- package/types/src/webpub/WebPubFramePoolManager.d.ts +3 -1
- package/types/src/webpub/WebPubNavigator.d.ts +3 -0
|
@@ -2,6 +2,7 @@ import { ModuleName } from "@readium/navigator-html-injectables";
|
|
|
2
2
|
import { Locator, Publication } from "@readium/shared";
|
|
3
3
|
import FrameBlobBuider from "./FrameBlobBuilder";
|
|
4
4
|
import { FrameManager } from "./FrameManager";
|
|
5
|
+
import { Injector } from "../../injection/Injector";
|
|
5
6
|
|
|
6
7
|
const UPPER_BOUNDARY = 5;
|
|
7
8
|
const LOWER_BOUNDARY = 3;
|
|
@@ -16,11 +17,18 @@ export class FramePoolManager {
|
|
|
16
17
|
private readonly inprogress: Map<string, Promise<void>> = new Map();
|
|
17
18
|
private pendingUpdates: Map<string, { inPool: boolean }> = new Map();
|
|
18
19
|
private currentBaseURL: string | undefined;
|
|
20
|
+
private readonly injector: Injector | null = null;
|
|
19
21
|
|
|
20
|
-
constructor(
|
|
22
|
+
constructor(
|
|
23
|
+
container: HTMLElement,
|
|
24
|
+
positions: Locator[],
|
|
25
|
+
cssProperties?: { [key: string]: string },
|
|
26
|
+
injector?: Injector | null
|
|
27
|
+
) {
|
|
21
28
|
this.container = container;
|
|
22
29
|
this.positions = positions;
|
|
23
30
|
this.currentCssProperties = cssProperties;
|
|
31
|
+
this.injector = injector ?? null;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
async destroy() {
|
|
@@ -47,7 +55,13 @@ export class FramePoolManager {
|
|
|
47
55
|
this.pool.clear();
|
|
48
56
|
|
|
49
57
|
// Revoke all blobs
|
|
50
|
-
this.blobs.forEach(v =>
|
|
58
|
+
this.blobs.forEach(v => {
|
|
59
|
+
this.injector?.releaseBlobUrl?.(v);
|
|
60
|
+
URL.revokeObjectURL(v);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Clean up injector if it exists
|
|
64
|
+
this.injector?.dispose();
|
|
51
65
|
|
|
52
66
|
// Empty container of elements
|
|
53
67
|
this.container.childNodes.forEach(v => {
|
|
@@ -90,7 +104,10 @@ export class FramePoolManager {
|
|
|
90
104
|
// Check if base URL of publication has changed
|
|
91
105
|
if(this.currentBaseURL !== undefined && pub.baseURL !== this.currentBaseURL) {
|
|
92
106
|
// Revoke all blobs
|
|
93
|
-
this.blobs.forEach(v =>
|
|
107
|
+
this.blobs.forEach(v => {
|
|
108
|
+
this.injector?.releaseBlobUrl?.(v);
|
|
109
|
+
URL.revokeObjectURL(v);
|
|
110
|
+
});
|
|
94
111
|
this.blobs.clear();
|
|
95
112
|
}
|
|
96
113
|
this.currentBaseURL = pub.baseURL;
|
|
@@ -103,13 +120,17 @@ export class FramePoolManager {
|
|
|
103
120
|
// when navigating backwards, where paginated will go the
|
|
104
121
|
// start of the resource instead of the end due to the
|
|
105
122
|
// corrupted width ColumnSnapper (injectables) gets on init
|
|
106
|
-
this.blobs.forEach(v =>
|
|
123
|
+
this.blobs.forEach(v => {
|
|
124
|
+
this.injector?.releaseBlobUrl?.(v);
|
|
125
|
+
URL.revokeObjectURL(v);
|
|
126
|
+
});
|
|
107
127
|
this.blobs.clear();
|
|
108
128
|
this.pendingUpdates.clear();
|
|
109
129
|
}
|
|
110
130
|
if(this.pendingUpdates.has(href) && this.pendingUpdates.get(href)?.inPool === false) {
|
|
111
131
|
const url = this.blobs.get(href);
|
|
112
132
|
if(url) {
|
|
133
|
+
this.injector?.releaseBlobUrl?.(url);
|
|
113
134
|
URL.revokeObjectURL(url);
|
|
114
135
|
this.blobs.delete(href);
|
|
115
136
|
this.pendingUpdates.delete(href);
|
|
@@ -129,7 +150,15 @@ export class FramePoolManager {
|
|
|
129
150
|
const itm = pub.readingOrder.findWithHref(href);
|
|
130
151
|
if(!itm) return; // TODO throw?
|
|
131
152
|
if(!this.blobs.has(href)) {
|
|
132
|
-
const blobBuilder = new FrameBlobBuider(
|
|
153
|
+
const blobBuilder = new FrameBlobBuider(
|
|
154
|
+
pub,
|
|
155
|
+
this.currentBaseURL || "",
|
|
156
|
+
itm,
|
|
157
|
+
{
|
|
158
|
+
cssProperties: this.currentCssProperties,
|
|
159
|
+
injector: this.injector
|
|
160
|
+
}
|
|
161
|
+
);
|
|
133
162
|
const blobURL = await blobBuilder.build();
|
|
134
163
|
this.blobs.set(href, blobURL);
|
|
135
164
|
}
|
|
@@ -6,6 +6,7 @@ import { FXLFrameManager } from "./FXLFrameManager";
|
|
|
6
6
|
import { FXLPeripherals } from "./FXLPeripherals";
|
|
7
7
|
import { FXLSpreader, Orientation, Spread } from "./FXLSpreader";
|
|
8
8
|
import { VisualNavigatorViewport } from "../../Navigator";
|
|
9
|
+
import { Injector } from "../../injection/Injector";
|
|
9
10
|
|
|
10
11
|
const UPPER_BOUNDARY = 8;
|
|
11
12
|
const LOWER_BOUNDARY = 5;
|
|
@@ -26,6 +27,7 @@ export class FXLFramePoolManager {
|
|
|
26
27
|
private readonly delayedTimeout: Map<string, number> = new Map();
|
|
27
28
|
private currentBaseURL: string | undefined;
|
|
28
29
|
private previousFrames: FXLFrameManager[] = [];
|
|
30
|
+
private readonly injector: Injector | null = null;
|
|
29
31
|
|
|
30
32
|
// NEW
|
|
31
33
|
private readonly bookElement: HTMLDivElement;
|
|
@@ -44,10 +46,16 @@ export class FXLFramePoolManager {
|
|
|
44
46
|
// private readonly pages: FXLFrameManager[] = [];
|
|
45
47
|
public readonly peripherals: FXLPeripherals;
|
|
46
48
|
|
|
47
|
-
constructor(
|
|
49
|
+
constructor(
|
|
50
|
+
container: HTMLElement,
|
|
51
|
+
positions: Locator[],
|
|
52
|
+
pub: Publication,
|
|
53
|
+
injector?: Injector | null
|
|
54
|
+
) {
|
|
48
55
|
this.container = container;
|
|
49
56
|
this.positions = positions;
|
|
50
57
|
this.pub = pub;
|
|
58
|
+
this.injector = injector ?? null;
|
|
51
59
|
this.spreadPresentation = pub.metadata.otherMetadata?.spread || Spread.auto;
|
|
52
60
|
|
|
53
61
|
if(this.pub.metadata.effectiveReadingProgression !== ReadingProgression.rtl && this.pub.metadata.effectiveReadingProgression !== ReadingProgression.ltr)
|
|
@@ -393,6 +401,9 @@ export class FXLFramePoolManager {
|
|
|
393
401
|
// Revoke all blobs
|
|
394
402
|
this.blobs.forEach(v => URL.revokeObjectURL(v));
|
|
395
403
|
|
|
404
|
+
// Clean up injector if it exists
|
|
405
|
+
this.injector?.dispose();
|
|
406
|
+
|
|
396
407
|
// Empty container of elements
|
|
397
408
|
this.container.childNodes.forEach(v => {
|
|
398
409
|
if(v.nodeType === Node.ELEMENT_NODE || v.nodeType === Node.TEXT_NODE) v.remove();
|
|
@@ -495,7 +506,14 @@ export class FXLFramePoolManager {
|
|
|
495
506
|
const itm = pub.readingOrder.items[index];
|
|
496
507
|
if(!itm) return; // TODO throw?
|
|
497
508
|
if(!this.blobs.has(href)) {
|
|
498
|
-
const blobBuilder = new FrameBlobBuider(
|
|
509
|
+
const blobBuilder = new FrameBlobBuider(
|
|
510
|
+
pub,
|
|
511
|
+
this.currentBaseURL || "",
|
|
512
|
+
itm,
|
|
513
|
+
{
|
|
514
|
+
injector: this.injector
|
|
515
|
+
}
|
|
516
|
+
);
|
|
499
517
|
const blobURL = await blobBuilder.build(true);
|
|
500
518
|
this.blobs.set(href, blobURL);
|
|
501
519
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for processing CSS and JavaScript content
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minifies JavaScript by removing comments and normalizing whitespace
|
|
7
|
+
*/
|
|
8
|
+
export const stripJS = (source: string) => source.replace(/\/\/.*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\n/g, "").replace(/\s+/g, " ");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minifies CSS by removing comments and normalizing whitespace
|
|
12
|
+
* Note: URL resolution should be handled by the caller with correct context
|
|
13
|
+
*/
|
|
14
|
+
export const stripCSS = (source: string) => source.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\/|[\r\n\t]+/g, "").replace(/ {2,}/g, " ");
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Link } from "@readium/shared";
|
|
2
|
+
|
|
3
|
+
type ForbiddenAttributes = "type" | "rel" | "href" | "src";
|
|
4
|
+
type AllowedAttributes = {
|
|
5
|
+
[K in string]: K extends ForbiddenAttributes
|
|
6
|
+
? never
|
|
7
|
+
: (string | boolean | undefined);
|
|
8
|
+
} & {
|
|
9
|
+
[K in ForbiddenAttributes]?: never;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface IBaseInjectable {
|
|
13
|
+
id?: string;
|
|
14
|
+
target?: "head" | "body";
|
|
15
|
+
type?: string;
|
|
16
|
+
condition?: (doc: Document) => boolean;
|
|
17
|
+
|
|
18
|
+
// Extra attributes - type and rel are forbidden here since they are at root
|
|
19
|
+
attributes?: AllowedAttributes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IScriptInjectable extends IBaseInjectable {
|
|
23
|
+
as: "script";
|
|
24
|
+
rel?: never; // Scripts don't have rel
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ILinkInjectable extends IBaseInjectable {
|
|
28
|
+
as: "link";
|
|
29
|
+
rel: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IUrlInjectable {
|
|
33
|
+
url: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface IBlobInjectable {
|
|
37
|
+
blob: Blob;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type IInjectable = (IScriptInjectable | ILinkInjectable) & (IUrlInjectable | IBlobInjectable);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Defines a rule for resource injection, specifying which resources to inject into which documents.
|
|
44
|
+
*/
|
|
45
|
+
export interface IInjectableRule {
|
|
46
|
+
/**
|
|
47
|
+
* List of resource URLs or patterns that this rule applies to.
|
|
48
|
+
* Can be exact URLs or patterns with wildcards.
|
|
49
|
+
*/
|
|
50
|
+
resources: Array<string | RegExp>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resources to inject at the beginning of the target (in array order)
|
|
54
|
+
*/
|
|
55
|
+
prepend?: IInjectable[];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resources to inject at the end of the target (in array order)
|
|
59
|
+
*/
|
|
60
|
+
append?: IInjectable[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface IInjectablesConfig {
|
|
64
|
+
rules: IInjectableRule[];
|
|
65
|
+
allowedDomains?: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface IInjector {
|
|
69
|
+
/**
|
|
70
|
+
* Injects resources into a document based on matching rules
|
|
71
|
+
* @param doc The document to inject resources into
|
|
72
|
+
* @param link The link being loaded, used to match against injection rules
|
|
73
|
+
*/
|
|
74
|
+
injectForDocument(doc: Document, link: Link): Promise<void>;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cleans up any resources used by the injector
|
|
78
|
+
*/
|
|
79
|
+
dispose(): void;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get the list of allowed domains
|
|
83
|
+
*/
|
|
84
|
+
getAllowedDomains(): string[]
|
|
85
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { IInjectableRule, IInjectable, IInjector, IInjectablesConfig } from "./Injectable";
|
|
2
|
+
import { Link } from "@readium/shared";
|
|
3
|
+
|
|
4
|
+
const inferTypeFromResource = (resource: IInjectable): string | undefined => {
|
|
5
|
+
// If blob has a type, use it
|
|
6
|
+
if ("blob" in resource && resource.blob.type) {
|
|
7
|
+
return resource.blob.type;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// For scripts, default to text/javascript
|
|
11
|
+
if (resource.as === "script") {
|
|
12
|
+
return "text/javascript";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// For links, try to infer from URL extension
|
|
16
|
+
if (resource.as === "link" && "url" in resource) {
|
|
17
|
+
const url = resource.url.toLowerCase();
|
|
18
|
+
if (url.endsWith(".css")) return "text/css";
|
|
19
|
+
if ([".js", ".mjs", ".cjs"].some(ext => url.endsWith(ext))) return "text/javascript";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const applyAttributes = (element: HTMLElement, resource: IInjectable): void => {
|
|
26
|
+
// Apply extra attributes, filtering out root-level properties
|
|
27
|
+
if (resource.attributes) {
|
|
28
|
+
Object.entries(resource.attributes).forEach(([key, value]) => {
|
|
29
|
+
// Skip root-level properties to prevent conflicts
|
|
30
|
+
if (key === "type" || key === "rel" || key === "href" || key === "src") {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (value !== undefined && value !== null) {
|
|
35
|
+
// Convert boolean attributes to proper HTML format
|
|
36
|
+
if (typeof value === "boolean") {
|
|
37
|
+
if (value) {
|
|
38
|
+
element.setAttribute(key, "");
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
element.setAttribute(key, value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const scriptify = (doc: Document, resource: IInjectable, source: string): HTMLScriptElement => {
|
|
49
|
+
const s = doc.createElement("script");
|
|
50
|
+
s.dataset.readium = "true";
|
|
51
|
+
|
|
52
|
+
// Set the injectable ID if provided
|
|
53
|
+
if (resource.id) {
|
|
54
|
+
s.id = resource.id;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Apply root-level type if provided
|
|
58
|
+
const finalType = resource.type || inferTypeFromResource(resource);
|
|
59
|
+
if (finalType) {
|
|
60
|
+
s.type = finalType;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Apply extra attributes
|
|
64
|
+
applyAttributes(s, resource);
|
|
65
|
+
|
|
66
|
+
// Always set src from the processed URL
|
|
67
|
+
s.src = source;
|
|
68
|
+
|
|
69
|
+
return s;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const linkify = (doc: Document, resource: IInjectable, source: string): HTMLLinkElement => {
|
|
73
|
+
const s = doc.createElement("link");
|
|
74
|
+
s.dataset.readium = "true";
|
|
75
|
+
|
|
76
|
+
// Set the injectable ID if provided
|
|
77
|
+
if (resource.id) {
|
|
78
|
+
s.id = resource.id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Apply root-level rel if provided
|
|
82
|
+
if (resource.rel) {
|
|
83
|
+
s.rel = resource.rel;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const finalType = resource.type || inferTypeFromResource(resource);
|
|
87
|
+
if (finalType) {
|
|
88
|
+
s.type = finalType;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Apply extra attributes
|
|
92
|
+
applyAttributes(s, resource);
|
|
93
|
+
|
|
94
|
+
// Always set href from the processed URL
|
|
95
|
+
s.href = source;
|
|
96
|
+
|
|
97
|
+
return s;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export class Injector implements IInjector {
|
|
101
|
+
private readonly blobStore: Map<string, { url: string; refCount: number }> = new Map();
|
|
102
|
+
private readonly createdBlobUrls: Set<string> = new Set();
|
|
103
|
+
private readonly rules: IInjectableRule[];
|
|
104
|
+
private readonly allowedDomains: string[] = [];
|
|
105
|
+
private injectableIdCounter = 0;
|
|
106
|
+
|
|
107
|
+
constructor(config: IInjectablesConfig) {
|
|
108
|
+
// Validate allowed domains - they should be proper URLs for external resources
|
|
109
|
+
this.allowedDomains = (config.allowedDomains || []).map(domain => {
|
|
110
|
+
try {
|
|
111
|
+
new URL(domain);
|
|
112
|
+
return domain;
|
|
113
|
+
} catch {
|
|
114
|
+
throw new Error(`Invalid allowed domain: "${domain}". Must be a valid URL (e.g., "https://fonts.googleapis.com").`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Assign IDs to injectables that don't have them
|
|
119
|
+
this.rules = config.rules.map(rule => {
|
|
120
|
+
const processedRule: IInjectableRule = { ...rule };
|
|
121
|
+
|
|
122
|
+
// Process prepend injectables (reverse to preserve order when prepending)
|
|
123
|
+
if (rule.prepend) {
|
|
124
|
+
processedRule.prepend = rule.prepend.map(injectable => ({
|
|
125
|
+
...injectable,
|
|
126
|
+
id: injectable.id || `injectable-${this.injectableIdCounter++}`
|
|
127
|
+
})).reverse(); // Reverse here so we can process normally later
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Process append injectables (keep original order)
|
|
131
|
+
if (rule.append) {
|
|
132
|
+
processedRule.append = rule.append.map(injectable => ({
|
|
133
|
+
...injectable,
|
|
134
|
+
id: injectable.id || `injectable-${this.injectableIdCounter++}`
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return processedRule;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public dispose(): void {
|
|
143
|
+
// Cleanup any created blob URLs
|
|
144
|
+
for (const url of this.createdBlobUrls) {
|
|
145
|
+
try {
|
|
146
|
+
URL.revokeObjectURL(url);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.warn("Failed to revoke blob URL:", url, error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
this.createdBlobUrls.clear();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public getAllowedDomains(): string[] {
|
|
155
|
+
return [...this.allowedDomains]; // Return a copy to prevent external modification
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public async injectForDocument(doc: Document, link: Link): Promise<void> {
|
|
159
|
+
for (const rule of this.rules) {
|
|
160
|
+
if (this.matchesRule(rule, link)) {
|
|
161
|
+
await this.applyRule(doc, rule);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private matchesRule(rule: IInjectableRule, link: Link): boolean {
|
|
167
|
+
// Use the original href from the publication, not the resolved blob URL
|
|
168
|
+
const originalHref = link.href;
|
|
169
|
+
|
|
170
|
+
return rule.resources.some(pattern => {
|
|
171
|
+
if (pattern instanceof RegExp) {
|
|
172
|
+
return pattern.test(originalHref);
|
|
173
|
+
}
|
|
174
|
+
return originalHref === pattern;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async getOrCreateBlobUrl(resource: IInjectable): Promise<string> {
|
|
179
|
+
// Use the injectable ID as the cache key
|
|
180
|
+
const cacheKey = resource.id!; // ID is guaranteed to exist after constructor
|
|
181
|
+
|
|
182
|
+
if (this.blobStore.has(cacheKey)) {
|
|
183
|
+
const entry = this.blobStore.get(cacheKey)!;
|
|
184
|
+
entry.refCount++;
|
|
185
|
+
return entry.url;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if ("blob" in resource) {
|
|
189
|
+
const url = URL.createObjectURL(resource.blob);
|
|
190
|
+
this.blobStore.set(cacheKey, { url, refCount: 1 });
|
|
191
|
+
this.createdBlobUrls.add(url);
|
|
192
|
+
return url;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
throw new Error("Resource must have a blob property");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
public async releaseBlobUrl(url: string): Promise<void> {
|
|
199
|
+
if (!this.createdBlobUrls.has(url)) return;
|
|
200
|
+
|
|
201
|
+
const entry = Array.from(this.blobStore.values())
|
|
202
|
+
.find(entry => entry.url === url);
|
|
203
|
+
|
|
204
|
+
if (entry) {
|
|
205
|
+
entry.refCount--;
|
|
206
|
+
if (entry.refCount <= 0) {
|
|
207
|
+
URL.revokeObjectURL(url);
|
|
208
|
+
this.createdBlobUrls.delete(url);
|
|
209
|
+
// Remove from blobStore
|
|
210
|
+
for (const [key, value] of this.blobStore.entries()) {
|
|
211
|
+
if (value.url === url) {
|
|
212
|
+
this.blobStore.delete(key);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async getResourceUrl(resource: IInjectable, doc: Document): Promise<string> {
|
|
221
|
+
if ("url" in resource) {
|
|
222
|
+
const resolvedUrl = new URL(resource.url, doc.baseURI).toString();
|
|
223
|
+
if (!this.isValidUrl(resolvedUrl, doc)) {
|
|
224
|
+
throw new Error(`Invalid URL: Only HTTPS, data:, blob:, or localhost HTTP URLs are allowed. Got: ${resource.url}`);
|
|
225
|
+
}
|
|
226
|
+
return resolvedUrl;
|
|
227
|
+
} else {
|
|
228
|
+
return this.getOrCreateBlobUrl(resource);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private createPreloadLink(doc: Document, resource: IInjectable, url: string): void {
|
|
233
|
+
if (resource.as !== "link" || resource.rel !== "preload") return;
|
|
234
|
+
|
|
235
|
+
// Create a new resource object with preload attributes
|
|
236
|
+
const preloadResource: IInjectable = {
|
|
237
|
+
...resource,
|
|
238
|
+
rel: "preload",
|
|
239
|
+
attributes: {
|
|
240
|
+
...resource.attributes,
|
|
241
|
+
as: resource.as
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const preloadLink = linkify(doc, preloadResource, url);
|
|
246
|
+
doc.head.appendChild(preloadLink);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private createElement(doc: Document, resource: IInjectable, source: string): HTMLElement {
|
|
250
|
+
if (resource.as === "script") {
|
|
251
|
+
return scriptify(doc, resource, source);
|
|
252
|
+
}
|
|
253
|
+
if (resource.as === "link") {
|
|
254
|
+
return linkify(doc, resource, source);
|
|
255
|
+
}
|
|
256
|
+
throw new Error(`Unsupported element type: ${(resource as any).as}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async applyRule(doc: Document, rule: IInjectableRule): Promise<void> {
|
|
260
|
+
const createdElements: { element: HTMLElement; url: string }[] = [];
|
|
261
|
+
|
|
262
|
+
// Collect all injectables that pass their conditions before modifying the document
|
|
263
|
+
const prependInjectables = rule.prepend ? rule.prepend.filter(resource =>
|
|
264
|
+
!resource.condition || resource.condition(doc)
|
|
265
|
+
) : [];
|
|
266
|
+
|
|
267
|
+
const appendInjectables = rule.append ? rule.append.filter(resource =>
|
|
268
|
+
!resource.condition || resource.condition(doc)
|
|
269
|
+
) : [];
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
// Process prepend injectables first (already reversed in constructor)
|
|
273
|
+
for (const resource of prependInjectables) {
|
|
274
|
+
await this.processInjectable(resource, doc, createdElements, "prepend");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Process append injectables next (in order)
|
|
278
|
+
for (const resource of appendInjectables) {
|
|
279
|
+
await this.processInjectable(resource, doc, createdElements, "append");
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
// Clean up any created elements on error
|
|
283
|
+
for (const { element, url } of createdElements) {
|
|
284
|
+
try {
|
|
285
|
+
element.remove();
|
|
286
|
+
await this.releaseBlobUrl(url);
|
|
287
|
+
} catch (cleanupError) {
|
|
288
|
+
console.error("Error during cleanup:", cleanupError);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async processInjectable(
|
|
296
|
+
resource: IInjectable,
|
|
297
|
+
doc: Document,
|
|
298
|
+
createdElements: { element: HTMLElement; url: string }[],
|
|
299
|
+
position: "prepend" | "append"
|
|
300
|
+
): Promise<void> {
|
|
301
|
+
const target = resource.target === "body" ? doc.body : doc.head;
|
|
302
|
+
if (!target) return;
|
|
303
|
+
|
|
304
|
+
let url: string | null = null;
|
|
305
|
+
try {
|
|
306
|
+
url = await this.getResourceUrl(resource, doc);
|
|
307
|
+
|
|
308
|
+
if (resource.rel === "preload" && "url" in resource) {
|
|
309
|
+
this.createPreloadLink(doc, resource, url);
|
|
310
|
+
} else {
|
|
311
|
+
const element = this.createElement(doc, resource, url);
|
|
312
|
+
createdElements.push({ element, url });
|
|
313
|
+
|
|
314
|
+
if (position === "prepend") {
|
|
315
|
+
target.prepend(element);
|
|
316
|
+
} else {
|
|
317
|
+
target.append(element);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
console.error("Failed to process resource:", error);
|
|
322
|
+
if (url && "blob" in resource) {
|
|
323
|
+
await this.releaseBlobUrl(url);
|
|
324
|
+
}
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private isValidUrl(url: string, doc: Document): boolean {
|
|
330
|
+
try {
|
|
331
|
+
const parsed = new URL(url, doc.baseURI);
|
|
332
|
+
|
|
333
|
+
// Allow data URLs
|
|
334
|
+
if (parsed.protocol === "data:") return true;
|
|
335
|
+
|
|
336
|
+
// Allow blob URLs that we created
|
|
337
|
+
if (parsed.protocol === "blob:" && this.createdBlobUrls.has(url)) {
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Check against allowed domains if any are specified
|
|
342
|
+
if (this.allowedDomains.length > 0) {
|
|
343
|
+
const origin = parsed.origin;
|
|
344
|
+
return this.allowedDomains.some(allowed => {
|
|
345
|
+
const allowedOrigin = new URL(allowed).origin;
|
|
346
|
+
return origin === allowedOrigin;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// No allowed domains specified - deny external URLs
|
|
351
|
+
return false;
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|