@readium/navigator-html-injectables 2.2.5 → 2.3.0
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/index.js +1107 -548
- package/dist/index.umd.cjs +23 -9
- package/package.json +1 -1
- package/src/comms/keys.ts +22 -3
- package/src/index.ts +2 -1
- package/src/modules/ModuleLibrary.ts +10 -4
- package/src/modules/Peripherals.ts +458 -13
- package/src/modules/snapper/ColumnSnapper.ts +63 -2
- package/src/modules/snapper/ScrollSnapper.ts +59 -1
- package/src/modules/snapper/WebPubSnapper.ts +57 -3
- package/src/protection/BulkCopyProtector.ts +134 -0
- package/src/protection/KeyCombinationManager.ts +139 -0
- package/src/protection/KeyboardCombinations.ts +60 -0
- package/src/protection/PatternAnalyzer.ts +117 -0
- package/src/protection/PrintProtector.ts +87 -0
- package/src/protection/SelectionAnalyzer.ts +133 -0
- package/src/protection/config.ts +33 -0
- package/src/protection/index.ts +6 -0
- package/types/src/comms/keys.d.ts +3 -2
- package/types/src/index.d.ts +1 -0
- package/types/src/modules/ModuleLibrary.d.ts +1 -1
- package/types/src/modules/Peripherals.d.ts +102 -2
- package/types/src/modules/snapper/ColumnSnapper.d.ts +10 -0
- package/types/src/modules/snapper/ScrollSnapper.d.ts +13 -0
- package/types/src/modules/snapper/WebPubSnapper.d.ts +6 -0
- package/types/src/protection/BulkCopyProtector.d.ts +20 -0
- package/types/src/protection/KeyCombinationManager.d.ts +33 -0
- package/types/src/protection/KeyboardCombinations.d.ts +15 -0
- package/types/src/protection/PatternAnalyzer.d.ts +18 -0
- package/types/src/protection/PrintProtector.d.ts +20 -0
- package/types/src/protection/SelectionAnalyzer.d.ts +20 -0
- package/types/src/protection/config.d.ts +7 -0
- package/types/src/protection/index.d.ts +6 -0
- package/src/types/protection.ts +0 -16
- package/types/src/types/protection.d.ts +0 -7
|
@@ -6,8 +6,16 @@ import { ModuleName } from "../ModuleLibrary";
|
|
|
6
6
|
import { Locator, LocatorLocations, LocatorText } from "@readium/shared";
|
|
7
7
|
import { rangeFromLocator } from "../../helpers/locator";
|
|
8
8
|
import { ReadiumWindow, deselect, findFirstVisibleLocator } from "../../helpers/dom";
|
|
9
|
+
import { PatternAnalyzer } from "../../protection/PatternAnalyzer";
|
|
10
|
+
import { BaseSuspiciousActivityEvent } from "../Peripherals";
|
|
9
11
|
|
|
10
12
|
const COLUMN_SNAPPER_STYLE_ID = "readium-column-snapper-style";
|
|
13
|
+
|
|
14
|
+
export interface SuspiciousSnappingEvent extends BaseSuspiciousActivityEvent {
|
|
15
|
+
type: "suspicious_snapping";
|
|
16
|
+
event: null;
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
const SNAP_DURATION = 200; // Milliseconds
|
|
12
20
|
|
|
13
21
|
enum ScrollTouchState {
|
|
@@ -21,8 +29,11 @@ enum ScrollTouchState {
|
|
|
21
29
|
*/
|
|
22
30
|
export class ColumnSnapper extends Snapper {
|
|
23
31
|
static readonly moduleName: ModuleName = "column_snapper";
|
|
32
|
+
private isSnapProtectionEnabled = false;
|
|
24
33
|
private resizeObserver!: ResizeObserver;
|
|
25
34
|
private mutationObserver!: MutationObserver;
|
|
35
|
+
private patternAnalyzer: PatternAnalyzer | null = null;
|
|
36
|
+
private lastTurnTime: number = 0;
|
|
26
37
|
private wnd!: ReadiumWindow;
|
|
27
38
|
private comms!: Comms;
|
|
28
39
|
private doc() { return this.wnd.document.scrollingElement as HTMLElement; }
|
|
@@ -72,6 +83,7 @@ export class ColumnSnapper extends Snapper {
|
|
|
72
83
|
private alreadyScrollLeft = 0;
|
|
73
84
|
private overscroll = 0;
|
|
74
85
|
private cachedScrollWidth = 0; // We have to cache this because during overscroll (transform, or left) the width is incorrect due to browser
|
|
86
|
+
|
|
75
87
|
private takeOverSnap() {
|
|
76
88
|
this.snappingCancelled = true;
|
|
77
89
|
this.clearTouches();
|
|
@@ -80,7 +92,7 @@ export class ColumnSnapper extends Snapper {
|
|
|
80
92
|
// translate3d(XXXpx, 0px, 0px) -> slice 12 -> XXXpx, 0px, 0px) -> split "px" [0] -> XXX
|
|
81
93
|
this.overscroll = doc.style.transform?.length > 12 ? parseFloat(doc.style.transform.slice(12).split("px")[0]) : 0;
|
|
82
94
|
}
|
|
83
|
-
|
|
95
|
+
|
|
84
96
|
// Snaps the current offset to the page width.
|
|
85
97
|
snapCurrentOffset(smooth=false, noprogress=false) {
|
|
86
98
|
const startX = this.wnd.scrollX > 0 ? this.wnd.scrollX : this.alreadyScrollLeft;
|
|
@@ -96,6 +108,11 @@ export class ColumnSnapper extends Snapper {
|
|
|
96
108
|
((factor * cdo) > 0 ? 2 : 1);
|
|
97
109
|
|
|
98
110
|
const so = this.snapOffset(currentOffset + hurdle);
|
|
111
|
+
|
|
112
|
+
const direction = so > this.scrollOffset() ? "right" : "left";
|
|
113
|
+
|
|
114
|
+
this.checkSuspiciousSnap(direction, Math.abs(so - this.scrollOffset()));
|
|
115
|
+
|
|
99
116
|
if(smooth && so !== this.scrollOffset()) { // Smooth snapping
|
|
100
117
|
this.snappingCancelled = false;
|
|
101
118
|
const position = (start: number, end: number, elapsed: number, period: number) => {
|
|
@@ -232,6 +249,37 @@ export class ColumnSnapper extends Snapper {
|
|
|
232
249
|
}
|
|
233
250
|
private readonly onTouchMover = this.onTouchMove.bind(this);
|
|
234
251
|
|
|
252
|
+
private enableSnapProtection() {
|
|
253
|
+
if (!this.patternAnalyzer) {
|
|
254
|
+
this.patternAnalyzer = new PatternAnalyzer({
|
|
255
|
+
maxVelocity: this.wnd.innerWidth, // page width
|
|
256
|
+
minVariance: 0.1, // Allow for some variation in swipe speed
|
|
257
|
+
historySize: 5, // Fewer samples needed for swipe detection
|
|
258
|
+
maxConsistentScrolls: 3, // Lower threshold for consistent scrolling
|
|
259
|
+
minDirectionChanges: 0.3 // Require more direction changes
|
|
260
|
+
});
|
|
261
|
+
this.isSnapProtectionEnabled = true;
|
|
262
|
+
this.comms?.log("Snap protection enabled");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private checkSuspiciousSnap(direction: "left" | "right", distance: number) {
|
|
267
|
+
if (!this.isSnapProtectionEnabled || !this.patternAnalyzer) return;
|
|
268
|
+
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
const timeDelta = now - (this.lastTurnTime || now);
|
|
271
|
+
this.lastTurnTime = now;
|
|
272
|
+
|
|
273
|
+
const isSuspicious = this.patternAnalyzer.analyze(direction, distance, timeDelta);
|
|
274
|
+
if (isSuspicious) {
|
|
275
|
+
this.comms?.send("content_protection", {
|
|
276
|
+
type: "suspicious_snapping",
|
|
277
|
+
timestamp: Date.now(),
|
|
278
|
+
event: null
|
|
279
|
+
} as SuspiciousSnappingEvent);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
235
283
|
mount(wnd: ReadiumWindow, comms: Comms): boolean {
|
|
236
284
|
this.wnd = wnd;
|
|
237
285
|
this.comms = comms;
|
|
@@ -317,6 +365,11 @@ export class ColumnSnapper extends Snapper {
|
|
|
317
365
|
// For cases the resizeObserver is not able to detect cos body is not resizing despite colCount,
|
|
318
366
|
// we need to check the syle attribute on the documentElement (ReadiumCSS props)
|
|
319
367
|
this.mutationObserver.observe(wnd.document.documentElement, {attributes: true, attributeFilter: ["style"]});
|
|
368
|
+
|
|
369
|
+
comms.register("scroll_protection", ColumnSnapper.moduleName, (_, ack) => {
|
|
370
|
+
this.enableSnapProtection();
|
|
371
|
+
ack(true);
|
|
372
|
+
});
|
|
320
373
|
|
|
321
374
|
const scrollToOffset = (offset: number): boolean => {
|
|
322
375
|
const oldScrollLeft = this.doc().scrollLeft;
|
|
@@ -427,6 +480,7 @@ export class ColumnSnapper extends Snapper {
|
|
|
427
480
|
if(change) {
|
|
428
481
|
this.reportProgress();
|
|
429
482
|
deselect(this.wnd);
|
|
483
|
+
this.checkSuspiciousSnap("left", this.wnd.innerWidth);
|
|
430
484
|
}
|
|
431
485
|
ack(change);
|
|
432
486
|
});
|
|
@@ -441,6 +495,7 @@ export class ColumnSnapper extends Snapper {
|
|
|
441
495
|
if(change) {
|
|
442
496
|
this.reportProgress();
|
|
443
497
|
deselect(this.wnd);
|
|
498
|
+
this.checkSuspiciousSnap("right", this.wnd.innerWidth);
|
|
444
499
|
}
|
|
445
500
|
ack(change);
|
|
446
501
|
});
|
|
@@ -478,7 +533,7 @@ export class ColumnSnapper extends Snapper {
|
|
|
478
533
|
wnd.addEventListener("touchmove", this.onTouchMover, { passive: true });
|
|
479
534
|
|
|
480
535
|
// Safari hack, otherwise other events won't register
|
|
481
|
-
wnd.document.addEventListener(
|
|
536
|
+
wnd.document.addEventListener("touchstart", () => {});
|
|
482
537
|
|
|
483
538
|
comms.log("ColumnSnapper Mounted");
|
|
484
539
|
return true;
|
|
@@ -490,6 +545,12 @@ export class ColumnSnapper extends Snapper {
|
|
|
490
545
|
this.resizeObserver.disconnect();
|
|
491
546
|
this.mutationObserver.disconnect();
|
|
492
547
|
|
|
548
|
+
if (this.patternAnalyzer) {
|
|
549
|
+
this.patternAnalyzer.clear();
|
|
550
|
+
this.patternAnalyzer = null;
|
|
551
|
+
this.isSnapProtectionEnabled = false;
|
|
552
|
+
}
|
|
553
|
+
|
|
493
554
|
wnd.removeEventListener("touchstart", this.onTouchStarter);
|
|
494
555
|
wnd.removeEventListener("touchend", this.onTouchEnder);
|
|
495
556
|
wnd.removeEventListener("touchmove", this.onTouchMover);
|
|
@@ -5,14 +5,27 @@ import { ModuleName } from "../ModuleLibrary";
|
|
|
5
5
|
import { Snapper } from "./Snapper";
|
|
6
6
|
import { rangeFromLocator } from "../../helpers/locator";
|
|
7
7
|
import { forceWebkitRecalc } from "../../helpers/document";
|
|
8
|
+
import { PatternAnalyzer } from "../../protection/PatternAnalyzer";
|
|
9
|
+
import { SCROLL_PROTECTION_CONFIG } from "../../protection/config";
|
|
10
|
+
import { BaseSuspiciousActivityEvent } from "../Peripherals";
|
|
8
11
|
|
|
9
12
|
const SCROLL_SNAPPER_STYLE_ID = "readium-scroll-snapper-style";
|
|
10
13
|
|
|
14
|
+
export interface SuspiciousScrollingEvent extends BaseSuspiciousActivityEvent {
|
|
15
|
+
type: "suspicious_scrolling";
|
|
16
|
+
scrollDelta: number;
|
|
17
|
+
scrollDirection: "up" | "down";
|
|
18
|
+
targetElement: { tagName: string } | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
export class ScrollSnapper extends Snapper {
|
|
12
22
|
static readonly moduleName: ModuleName = "scroll_snapper";
|
|
13
23
|
private wnd!: ReadiumWindow;
|
|
14
24
|
private comms!: Comms;
|
|
15
25
|
private resizeObserver!: ResizeObserver;
|
|
26
|
+
private patternAnalyzer: PatternAnalyzer | null = null;
|
|
27
|
+
private lastScrollTime: number = 0;
|
|
28
|
+
private isScrollProtectionEnabled = false;
|
|
16
29
|
|
|
17
30
|
private initialScrollHandled = false;
|
|
18
31
|
private isScrolling = false;
|
|
@@ -41,7 +54,7 @@ export class ScrollSnapper extends Snapper {
|
|
|
41
54
|
});
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
private handleScroll = () => {
|
|
57
|
+
private handleScroll = (_e: Event) => {
|
|
45
58
|
if (!this.comms.ready) return;
|
|
46
59
|
|
|
47
60
|
// We have to filter scroll from resize events
|
|
@@ -68,6 +81,31 @@ export class ScrollSnapper extends Snapper {
|
|
|
68
81
|
const deltaY = currentScrollTop - this.lastScrollTop;
|
|
69
82
|
this.lastScrollTop = currentScrollTop;
|
|
70
83
|
|
|
84
|
+
if (this.isScrollProtectionEnabled && Math.abs(deltaY) > 5) { // Ignore tiny scrolls
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const timeDelta = now - (this.lastScrollTime || now);
|
|
87
|
+
if (this.patternAnalyzer) {
|
|
88
|
+
const isSuspicious = this.patternAnalyzer.analyze(
|
|
89
|
+
deltaY > 0 ? "down" : "up",
|
|
90
|
+
Math.abs(deltaY),
|
|
91
|
+
timeDelta
|
|
92
|
+
);
|
|
93
|
+
if (isSuspicious) {
|
|
94
|
+
const target = _e.target && "tagName" in _e.target ?
|
|
95
|
+
{ tagName: (_e.target as Element).tagName } : null;
|
|
96
|
+
|
|
97
|
+
this.comms?.send("content_protection", {
|
|
98
|
+
type: "suspicious_scrolling",
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
scrollDelta: deltaY,
|
|
101
|
+
scrollDirection: deltaY > 0 ? "down" : "up",
|
|
102
|
+
targetElement: target
|
|
103
|
+
} as SuspiciousScrollingEvent);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this.lastScrollTime = now;
|
|
107
|
+
}
|
|
108
|
+
|
|
71
109
|
this.comms.send("scroll", deltaY);
|
|
72
110
|
|
|
73
111
|
this.isScrolling = false;
|
|
@@ -75,6 +113,14 @@ export class ScrollSnapper extends Snapper {
|
|
|
75
113
|
}
|
|
76
114
|
};
|
|
77
115
|
|
|
116
|
+
private enableScrollProtection() {
|
|
117
|
+
if (!this.patternAnalyzer) {
|
|
118
|
+
this.patternAnalyzer = new PatternAnalyzer(SCROLL_PROTECTION_CONFIG);
|
|
119
|
+
this.isScrollProtectionEnabled = true;
|
|
120
|
+
this.comms?.log("Scroll protection enabled");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
78
124
|
mount(wnd: ReadiumWindow, comms: Comms): boolean {
|
|
79
125
|
this.wnd = wnd;
|
|
80
126
|
this.comms = comms;
|
|
@@ -222,6 +268,11 @@ export class ScrollSnapper extends Snapper {
|
|
|
222
268
|
ack(true);
|
|
223
269
|
});
|
|
224
270
|
|
|
271
|
+
comms.register("scroll_protection", ScrollSnapper.moduleName, (_, ack) => {
|
|
272
|
+
this.enableScrollProtection();
|
|
273
|
+
ack(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
225
276
|
comms.register([
|
|
226
277
|
"go_next",
|
|
227
278
|
"go_prev",
|
|
@@ -247,6 +298,13 @@ export class ScrollSnapper extends Snapper {
|
|
|
247
298
|
this.resizeObserver.disconnect();
|
|
248
299
|
if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
|
|
249
300
|
wnd.document.getElementById(SCROLL_SNAPPER_STYLE_ID)?.remove();
|
|
301
|
+
|
|
302
|
+
if (this.patternAnalyzer) {
|
|
303
|
+
this.patternAnalyzer.clear();
|
|
304
|
+
this.patternAnalyzer = null;
|
|
305
|
+
this.isScrollProtectionEnabled = false;
|
|
306
|
+
}
|
|
307
|
+
|
|
250
308
|
comms.log("ScrollSnapper Unmounted");
|
|
251
309
|
return true;
|
|
252
310
|
}
|
|
@@ -5,6 +5,11 @@ import { ModuleName } from "../ModuleLibrary";
|
|
|
5
5
|
import { Snapper } from "./Snapper";
|
|
6
6
|
import { rangeFromLocator } from "../../helpers/locator";
|
|
7
7
|
import { forceWebkitRecalc } from "../../helpers/document";
|
|
8
|
+
import { PatternAnalyzer } from "../../protection/PatternAnalyzer";
|
|
9
|
+
import { SCROLL_PROTECTION_CONFIG } from "../../protection/config";
|
|
10
|
+
import { SuspiciousScrollingEvent } from "./ScrollSnapper";
|
|
11
|
+
|
|
12
|
+
export type { SuspiciousScrollingEvent };
|
|
8
13
|
|
|
9
14
|
export class WebPubSnapper extends Snapper {
|
|
10
15
|
static readonly moduleName: ModuleName = "webpub_snapper";
|
|
@@ -12,6 +17,9 @@ export class WebPubSnapper extends Snapper {
|
|
|
12
17
|
private wnd!: ReadiumWindow;
|
|
13
18
|
private comms!: Comms;
|
|
14
19
|
private resizeObserver!: ResizeObserver;
|
|
20
|
+
private patternAnalyzer: PatternAnalyzer | null = null;
|
|
21
|
+
private lastScrollTime: number = 0;
|
|
22
|
+
private isScrollProtectionEnabled = false;
|
|
15
23
|
|
|
16
24
|
private initialScrollHandled = false;
|
|
17
25
|
private isScrolling = false;
|
|
@@ -38,7 +46,7 @@ export class WebPubSnapper extends Snapper {
|
|
|
38
46
|
});
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
private handleScroll = () => {
|
|
49
|
+
private handleScroll = (_e: Event) => {
|
|
42
50
|
if (!this.comms.ready) return;
|
|
43
51
|
|
|
44
52
|
// Filter resize events
|
|
@@ -63,6 +71,32 @@ export class WebPubSnapper extends Snapper {
|
|
|
63
71
|
const deltaY = currentScrollTop - this.lastScrollTop;
|
|
64
72
|
this.lastScrollTop = currentScrollTop;
|
|
65
73
|
|
|
74
|
+
// Record scroll for pattern analysis if protection is enabled
|
|
75
|
+
if (this.isScrollProtectionEnabled && Math.abs(deltaY) > 5) { // Ignore tiny scrolls
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const timeDelta = now - (this.lastScrollTime || now);
|
|
78
|
+
if (this.patternAnalyzer) {
|
|
79
|
+
const isSuspicious = this.patternAnalyzer.analyze(
|
|
80
|
+
deltaY > 0 ? "down" : "up",
|
|
81
|
+
Math.abs(deltaY),
|
|
82
|
+
timeDelta
|
|
83
|
+
);
|
|
84
|
+
if (isSuspicious) {
|
|
85
|
+
const target = _e.target && "tagName" in _e.target ?
|
|
86
|
+
{ tagName: (_e.target as Element).tagName } : null;
|
|
87
|
+
|
|
88
|
+
this.comms?.send("content_protection", {
|
|
89
|
+
type: "suspicious_scrolling",
|
|
90
|
+
timestamp: Date.now(),
|
|
91
|
+
scrollDelta: deltaY,
|
|
92
|
+
scrollDirection: deltaY > 0 ? "down" : "up",
|
|
93
|
+
targetElement: target
|
|
94
|
+
} as SuspiciousScrollingEvent);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
this.lastScrollTime = now;
|
|
98
|
+
}
|
|
99
|
+
|
|
66
100
|
this.comms.send("scroll", deltaY);
|
|
67
101
|
|
|
68
102
|
this.isScrolling = false;
|
|
@@ -70,6 +104,14 @@ export class WebPubSnapper extends Snapper {
|
|
|
70
104
|
}
|
|
71
105
|
};
|
|
72
106
|
|
|
107
|
+
private enableScrollProtection() {
|
|
108
|
+
if (!this.patternAnalyzer) {
|
|
109
|
+
this.patternAnalyzer = new PatternAnalyzer(SCROLL_PROTECTION_CONFIG);
|
|
110
|
+
this.isScrollProtectionEnabled = true;
|
|
111
|
+
this.comms?.log("Scroll protection enabled");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
73
115
|
mount(wnd: ReadiumWindow, comms: Comms): boolean {
|
|
74
116
|
this.wnd = wnd;
|
|
75
117
|
this.comms = comms;
|
|
@@ -134,7 +176,7 @@ export class WebPubSnapper extends Snapper {
|
|
|
134
176
|
});
|
|
135
177
|
});
|
|
136
178
|
|
|
137
|
-
|
|
179
|
+
comms.register("go_id", WebPubSnapper.moduleName, (data, ack) => {
|
|
138
180
|
const element = wnd.document.getElementById(data as string);
|
|
139
181
|
if(!element) {
|
|
140
182
|
ack(false);
|
|
@@ -197,6 +239,11 @@ export class WebPubSnapper extends Snapper {
|
|
|
197
239
|
ack(true);
|
|
198
240
|
});
|
|
199
241
|
|
|
242
|
+
comms.register("scroll_protection", WebPubSnapper.moduleName, (_, ack) => {
|
|
243
|
+
this.enableScrollProtection();
|
|
244
|
+
ack(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
200
247
|
comms.register([
|
|
201
248
|
"go_next",
|
|
202
249
|
"go_prev",
|
|
@@ -220,7 +267,14 @@ export class WebPubSnapper extends Snapper {
|
|
|
220
267
|
comms.unregisterAll(WebPubSnapper.moduleName);
|
|
221
268
|
this.resizeObserver.disconnect();
|
|
222
269
|
if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
|
|
270
|
+
|
|
271
|
+
if (this.patternAnalyzer) {
|
|
272
|
+
this.patternAnalyzer.clear();
|
|
273
|
+
this.patternAnalyzer = null;
|
|
274
|
+
this.isScrollProtectionEnabled = false;
|
|
275
|
+
}
|
|
276
|
+
|
|
223
277
|
comms.log("WebPubSnapper Unmounted");
|
|
224
278
|
return true;
|
|
225
279
|
}
|
|
226
|
-
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ReadiumWindow } from "../helpers/dom";
|
|
2
|
+
|
|
3
|
+
interface CopyAttempt {
|
|
4
|
+
timestamp: number;
|
|
5
|
+
length: number;
|
|
6
|
+
wasBlocked: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface BulkCopyProtectionOptions {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
maxSelectionPercent: number;
|
|
12
|
+
absoluteMaxChars: number;
|
|
13
|
+
minThreshold: number;
|
|
14
|
+
historySize: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class BulkCopyProtector {
|
|
18
|
+
private options: BulkCopyProtectionOptions;
|
|
19
|
+
private copyHistory: CopyAttempt[] = [];
|
|
20
|
+
private lastSelectionLength = 0;
|
|
21
|
+
private lastSelectionTime = 0;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly window: ReadiumWindow,
|
|
25
|
+
options: BulkCopyProtectionOptions
|
|
26
|
+
) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private cleanupOldHistory(now: number): void {
|
|
31
|
+
// Keep history for the last 10 seconds
|
|
32
|
+
const historyWindow = 10000; // 10 seconds
|
|
33
|
+
this.copyHistory = this.copyHistory.filter(
|
|
34
|
+
attempt => now - attempt.timestamp < historyWindow
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Trim to max history size
|
|
38
|
+
if (this.copyHistory.length > this.options.historySize) {
|
|
39
|
+
this.copyHistory = this.copyHistory.slice(-this.options.historySize);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private isSuspiciousPattern(now: number): boolean {
|
|
44
|
+
if (this.copyHistory.length < 3) return false;
|
|
45
|
+
|
|
46
|
+
// Check for rapid successive copy attempts
|
|
47
|
+
const recentAttempts = this.copyHistory.filter(
|
|
48
|
+
attempt => now - attempt.timestamp < 2000 // Last 2 seconds
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (recentAttempts.length >= 3) {
|
|
52
|
+
// If multiple rapid copy attempts, it's suspicious
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check for increasing selection sizes (bulk copy pattern)
|
|
57
|
+
const increasingSelections = this.copyHistory
|
|
58
|
+
.slice()
|
|
59
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
60
|
+
.every((attempt, i, arr) => {
|
|
61
|
+
if (i === 0) return true;
|
|
62
|
+
return attempt.length > arr[i - 1].length * 1.5; // Each selection is significantly larger
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return increasingSelections;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public shouldAllowCopy(event: ClipboardEvent): boolean {
|
|
69
|
+
if (!this.options.enabled) return true;
|
|
70
|
+
|
|
71
|
+
const selection = this.window.getSelection();
|
|
72
|
+
if (!selection) return true;
|
|
73
|
+
|
|
74
|
+
const selectedText = selection.toString();
|
|
75
|
+
const selectedLength = selectedText.length;
|
|
76
|
+
const docLength = this.window.document.body.innerText.length;
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
|
|
79
|
+
// Clean up old history entries
|
|
80
|
+
this.cleanupOldHistory(now);
|
|
81
|
+
|
|
82
|
+
// Always allow small selections
|
|
83
|
+
if (selectedLength < this.options.minThreshold) {
|
|
84
|
+
this.copyHistory.push({
|
|
85
|
+
timestamp: now,
|
|
86
|
+
length: selectedLength,
|
|
87
|
+
wasBlocked: false
|
|
88
|
+
});
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const timeSinceLastSelection = now - this.lastSelectionTime;
|
|
93
|
+
|
|
94
|
+
// Check for rapid successive selections
|
|
95
|
+
const isRapidSelection =
|
|
96
|
+
timeSinceLastSelection < 100 &&
|
|
97
|
+
selectedLength > this.lastSelectionLength * 1.5;
|
|
98
|
+
|
|
99
|
+
const maxAllowedSelection = Math.min(
|
|
100
|
+
docLength * this.options.maxSelectionPercent,
|
|
101
|
+
this.options.absoluteMaxChars
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Check if this matches a suspicious pattern
|
|
105
|
+
const isSuspicious = this.isSuspiciousPattern(now);
|
|
106
|
+
|
|
107
|
+
const shouldBlock = selectedLength > maxAllowedSelection ||
|
|
108
|
+
isRapidSelection ||
|
|
109
|
+
isSuspicious;
|
|
110
|
+
|
|
111
|
+
// Record this attempt in history
|
|
112
|
+
this.copyHistory.push({
|
|
113
|
+
timestamp: now,
|
|
114
|
+
length: selectedLength,
|
|
115
|
+
wasBlocked: shouldBlock
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (shouldBlock) {
|
|
119
|
+
event?.preventDefault();
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.lastSelectionLength = selectedLength;
|
|
124
|
+
this.lastSelectionTime = now;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
public destroy(): void {
|
|
129
|
+
this.lastSelectionLength = 0;
|
|
130
|
+
this.lastSelectionTime = 0;
|
|
131
|
+
this.copyHistory = [];
|
|
132
|
+
this.options.enabled = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { KeyCombo, KeyboardPeripheral } from "./KeyboardCombinations";
|
|
2
|
+
import { BaseKeyboardPeripheralEvent, KeyboardEventData, BasicTextSelection } from "../modules/Peripherals";
|
|
3
|
+
import { ReadiumWindow } from "../helpers/dom";
|
|
4
|
+
|
|
5
|
+
export type KeyHandler = (event: KeyboardEvent) => void;
|
|
6
|
+
export type ActivityEventDispatcher = (event: KeyboardPeripheralEvent) => void;
|
|
7
|
+
|
|
8
|
+
export interface KeyboardPeripheralEvent extends BaseKeyboardPeripheralEvent, KeyboardEventData {
|
|
9
|
+
type: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface KeyComboWithHandler extends KeyCombo {
|
|
13
|
+
handler?: (event: KeyboardEvent) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class KeyCombinationManager {
|
|
17
|
+
/**
|
|
18
|
+
* Checks if the given keyboard event matches any of the provided key combinations
|
|
19
|
+
*/
|
|
20
|
+
public match(event: KeyboardEvent, combos: KeyCombo[]): boolean {
|
|
21
|
+
for (const combo of combos) {
|
|
22
|
+
const keyMatch = event.keyCode === combo.keyCode;
|
|
23
|
+
const ctrlMatch = combo.ctrl === undefined || event.ctrlKey === combo.ctrl;
|
|
24
|
+
const shiftMatch = combo.shift === undefined || event.shiftKey === combo.shift;
|
|
25
|
+
const altMatch = combo.alt === undefined || event.altKey === combo.alt;
|
|
26
|
+
const metaMatch = combo.meta === undefined || event.metaKey === combo.meta;
|
|
27
|
+
|
|
28
|
+
if (keyMatch && ctrlMatch && shiftMatch && altMatch && metaMatch) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Creates an event handler that will call the provided handler when any of the key combinations match
|
|
37
|
+
*/
|
|
38
|
+
public createKeyHandler(combos: KeyCombo[], handler: KeyHandler): (event: KeyboardEvent) => void {
|
|
39
|
+
return (event: KeyboardEvent) => {
|
|
40
|
+
if (this.match(event, combos)) {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
event.stopPropagation();
|
|
43
|
+
handler(event);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a standardized activity event for keyboard shortcuts
|
|
50
|
+
*/
|
|
51
|
+
private createActivityEvent(
|
|
52
|
+
event: KeyboardEvent,
|
|
53
|
+
type: string,
|
|
54
|
+
targetFrameSrc: string,
|
|
55
|
+
wnd?: ReadiumWindow,
|
|
56
|
+
): KeyboardPeripheralEvent {
|
|
57
|
+
// Capture selected text if window is available
|
|
58
|
+
let selectedText: Omit<BasicTextSelection, "targetFrameSrc"> | undefined;
|
|
59
|
+
if (wnd) {
|
|
60
|
+
const selection = wnd.getSelection();
|
|
61
|
+
const selectedTextStr = selection?.toString() || '';
|
|
62
|
+
const domRectList = (selectedTextStr && selection?.rangeCount) ? selection.getRangeAt(0)?.getClientRects() : null;
|
|
63
|
+
const rect = domRectList?.[0];
|
|
64
|
+
|
|
65
|
+
if (rect && selectedTextStr) {
|
|
66
|
+
selectedText = {
|
|
67
|
+
text: selectedTextStr,
|
|
68
|
+
x: rect.x,
|
|
69
|
+
y: rect.y,
|
|
70
|
+
width: rect.width,
|
|
71
|
+
height: rect.height
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
type: type,
|
|
78
|
+
timestamp: Date.now(),
|
|
79
|
+
key: event.key,
|
|
80
|
+
code: event.code,
|
|
81
|
+
keyCode: event.keyCode,
|
|
82
|
+
ctrlKey: event.ctrlKey,
|
|
83
|
+
altKey: event.altKey,
|
|
84
|
+
shiftKey: event.shiftKey,
|
|
85
|
+
metaKey: event.metaKey,
|
|
86
|
+
targetFrameSrc: targetFrameSrc,
|
|
87
|
+
selectedText
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Creates handlers for keyboard shortcuts with centralized activity event dispatch
|
|
93
|
+
*/
|
|
94
|
+
public createKeyboardHandlers(
|
|
95
|
+
targetFrameSrc: string,
|
|
96
|
+
shortcuts: KeyboardPeripheral[],
|
|
97
|
+
dispatcher: ActivityEventDispatcher,
|
|
98
|
+
wnd?: ReadiumWindow,
|
|
99
|
+
): KeyComboWithHandler[] {
|
|
100
|
+
const handlers: KeyComboWithHandler[] = [];
|
|
101
|
+
|
|
102
|
+
// Handle KeyboardPeripheral objects only
|
|
103
|
+
shortcuts.forEach(shortcut => {
|
|
104
|
+
handlers.push(...shortcut.keyCombos.map(combo => ({
|
|
105
|
+
...combo,
|
|
106
|
+
handler: (event: KeyboardEvent) => {
|
|
107
|
+
const eventType = shortcut.type;
|
|
108
|
+
const activityEvent = this.createActivityEvent(event, eventType, targetFrameSrc, wnd);
|
|
109
|
+
dispatcher(activityEvent);
|
|
110
|
+
}
|
|
111
|
+
})));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return handlers;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Creates a unified keyboard event handler that processes all shortcuts
|
|
119
|
+
*/
|
|
120
|
+
public createUnifiedHandler(
|
|
121
|
+
targetFrameSrc: string,
|
|
122
|
+
shortcuts: KeyboardPeripheral[],
|
|
123
|
+
dispatcher: ActivityEventDispatcher,
|
|
124
|
+
wnd?: ReadiumWindow,
|
|
125
|
+
): (event: KeyboardEvent) => void {
|
|
126
|
+
const handlers = this.createKeyboardHandlers(targetFrameSrc, shortcuts, dispatcher, wnd);
|
|
127
|
+
|
|
128
|
+
return (event: KeyboardEvent) => {
|
|
129
|
+
for (const handlerConfig of handlers) {
|
|
130
|
+
if (this.match(event, [handlerConfig])) {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
event.stopPropagation();
|
|
133
|
+
handlerConfig.handler!(event);
|
|
134
|
+
return; // Stop after first match
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|