nodebb-plugin-pdf-secure2 1.2.35 → 1.2.36

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/static/viewer.html +247 -36
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure2",
3
- "version": "1.2.35",
3
+ "version": "1.2.36",
4
4
  "description": "Secure PDF viewer plugin for NodeBB - prevents downloading, enables canvas-only rendering with Premium group support",
5
5
  "main": "library.js",
6
6
  "repository": {
@@ -23,7 +23,7 @@
23
23
  --accent: #0078d4;
24
24
  --accent-hover: #1a86d9;
25
25
  --border-color: #404040;
26
- --toolbar-height: 44px;
26
+ --toolbar-height: 36px;
27
27
  --sidebar-width: 200px;
28
28
  --toolbar-height-mobile: 44px;
29
29
  --bottom-bar-height: 52px;
@@ -98,7 +98,7 @@
98
98
  fill: var(--accent);
99
99
  }
100
100
 
101
- /* Toolbar - Edge Style */
101
+ /* Toolbar - Edge Style (compact for iframe embed) */
102
102
  #toolbar {
103
103
  position: fixed;
104
104
  top: 0;
@@ -109,25 +109,26 @@
109
109
  border-bottom: 1px solid var(--border-color);
110
110
  display: flex;
111
111
  align-items: center;
112
- padding: 0 8px;
113
- gap: 2px;
112
+ justify-content: flex-start;
113
+ padding: 0 4px;
114
+ gap: 0px;
114
115
  z-index: 100;
115
116
  }
116
117
 
117
118
  .toolbarGroup {
118
119
  display: flex;
119
120
  align-items: center;
120
- gap: 1px;
121
+ gap: 0px;
121
122
  }
122
123
 
123
124
  .toolbarBtn {
124
- width: 32px;
125
- height: 32px;
126
- min-width: 32px;
125
+ width: 26px;
126
+ height: 26px;
127
+ min-width: 26px;
127
128
  border: none;
128
129
  background: transparent;
129
130
  color: var(--text-primary);
130
- border-radius: 4px;
131
+ border-radius: 3px;
131
132
  cursor: pointer;
132
133
  display: flex;
133
134
  align-items: center;
@@ -145,16 +146,16 @@
145
146
  }
146
147
 
147
148
  .toolbarBtn svg {
148
- width: 17px;
149
- height: 17px;
149
+ width: 15px;
150
+ height: 15px;
150
151
  fill: currentColor;
151
152
  }
152
153
 
153
154
  .separator {
154
155
  width: 1px;
155
- height: 20px;
156
+ height: 18px;
156
157
  background: var(--border-color);
157
- margin: 0 5px;
158
+ margin: 0 3px;
158
159
  }
159
160
 
160
161
  /* Enhanced Tooltips */
@@ -416,8 +417,8 @@
416
417
  }
417
418
 
418
419
  .dropdownArrow {
419
- width: 16px;
420
- height: 32px;
420
+ width: 14px;
421
+ height: 26px;
421
422
  border: none;
422
423
  background: transparent;
423
424
  color: var(--text-primary);
@@ -542,24 +543,25 @@
542
543
  .pageInfo {
543
544
  display: flex;
544
545
  align-items: center;
545
- gap: 4px;
546
- margin-left: 4px;
546
+ gap: 3px;
547
+ margin-left: 3px;
547
548
  }
548
549
 
549
550
  #pageInput {
550
- width: 34px;
551
- height: 26px;
551
+ width: 28px;
552
+ height: 22px;
552
553
  background: var(--bg-tertiary);
553
554
  border: 1px solid var(--border-color);
554
- border-radius: 4px;
555
+ border-radius: 3px;
555
556
  color: var(--text-primary);
556
557
  text-align: center;
557
- font-size: 12px;
558
+ font-size: 11px;
559
+ padding: 0;
558
560
  }
559
561
 
560
562
  #pageCount {
561
563
  color: var(--text-secondary);
562
- font-size: 12px;
564
+ font-size: 11px;
563
565
  }
564
566
 
565
567
  /* Sidebar - Thumbnails */
@@ -2304,6 +2306,181 @@
2304
2306
  const annotationsStore = new Map();
2305
2307
  const annotationRotations = new Map(); // tracks rotation when annotations were saved
2306
2308
 
2309
+ // localStorage persistence helpers
2310
+ var _storagePrefix = (function () {
2311
+ var filename = (_cfg && _cfg.filename) || 'local';
2312
+ // FNV-1a hash to avoid key collisions from filenames containing delimiters
2313
+ var hash = 0x811c9dc5;
2314
+ for (var i = 0; i < filename.length; i++) {
2315
+ hash ^= filename.charCodeAt(i);
2316
+ hash = (hash * 0x01000193) >>> 0;
2317
+ }
2318
+ return hash.toString(36);
2319
+ })();
2320
+
2321
+ function getStorageKey(pageNum) {
2322
+ return 'pdfA::' + _storagePrefix + '::p' + pageNum;
2323
+ }
2324
+ function getRotStorageKey(pageNum) {
2325
+ return 'pdfR::' + _storagePrefix + '::p' + pageNum;
2326
+ }
2327
+ function getTextHighlightKey(pageNum) {
2328
+ return 'pdfH::' + _storagePrefix + '::p' + pageNum;
2329
+ }
2330
+
2331
+ // Touch manifest - tracks which files have annotations and when last accessed
2332
+ function touchManifest() {
2333
+ try {
2334
+ var manifest = JSON.parse(localStorage.getItem('pdfAnnot_manifest') || '{}');
2335
+ manifest[_storagePrefix] = Date.now();
2336
+ localStorage.setItem('pdfAnnot_manifest', JSON.stringify(manifest));
2337
+ } catch (e) { /* ignore */ }
2338
+ }
2339
+
2340
+ // Evict oldest file's annotations when quota is exceeded
2341
+ function evictOldest(excludePrefix) {
2342
+ try {
2343
+ var manifest = JSON.parse(localStorage.getItem('pdfAnnot_manifest') || '{}');
2344
+ var entries = Object.entries(manifest).sort(function (a, b) { return a[1] - b[1]; });
2345
+ for (var i = 0; i < entries.length; i++) {
2346
+ var oldPrefix = entries[i][0];
2347
+ if (oldPrefix === excludePrefix) continue;
2348
+ // Remove all keys for this old file
2349
+ var keysToRemove = [];
2350
+ for (var j = 0; j < localStorage.length; j++) {
2351
+ var key = localStorage.key(j);
2352
+ if (key && key.indexOf('::' + oldPrefix + '::') !== -1) {
2353
+ keysToRemove.push(key);
2354
+ }
2355
+ }
2356
+ keysToRemove.forEach(function (k) { localStorage.removeItem(k); });
2357
+ delete manifest[oldPrefix];
2358
+ localStorage.setItem('pdfAnnot_manifest', JSON.stringify(manifest));
2359
+ return keysToRemove.length > 0;
2360
+ }
2361
+ return false;
2362
+ } catch (e) { return false; }
2363
+ }
2364
+
2365
+ // Debounced persistence - avoids blocking main thread on every stroke
2366
+ var _persistTimers = {};
2367
+ function debouncedPersist(pageNum, svgHtml, rotation) {
2368
+ if (_persistTimers[pageNum]) clearTimeout(_persistTimers[pageNum]);
2369
+ _persistTimers[pageNum] = setTimeout(function () {
2370
+ persistToStorage(pageNum, svgHtml, rotation);
2371
+ delete _persistTimers[pageNum];
2372
+ }, 400);
2373
+ }
2374
+
2375
+ // Flush all pending debounced writes (called on page unload)
2376
+ function flushPendingPersist() {
2377
+ Object.keys(_persistTimers).forEach(function (pn) {
2378
+ clearTimeout(_persistTimers[pn]);
2379
+ var data = annotationsStore.get(parseInt(pn, 10));
2380
+ var rot = annotationRotations.get(parseInt(pn, 10)) || 0;
2381
+ if (data) persistToStorage(parseInt(pn, 10), data, rot);
2382
+ });
2383
+ _persistTimers = {};
2384
+ }
2385
+ window.addEventListener('beforeunload', flushPendingPersist);
2386
+ window.addEventListener('pagehide', flushPendingPersist);
2387
+ document.addEventListener('visibilitychange', function () {
2388
+ if (document.hidden) flushPendingPersist();
2389
+ });
2390
+
2391
+ function persistToStorage(pageNum, svgHtml, rotation) {
2392
+ try {
2393
+ if (svgHtml) {
2394
+ localStorage.setItem(getStorageKey(pageNum), svgHtml);
2395
+ localStorage.setItem(getRotStorageKey(pageNum), String(rotation || 0));
2396
+ } else {
2397
+ localStorage.removeItem(getStorageKey(pageNum));
2398
+ localStorage.removeItem(getRotStorageKey(pageNum));
2399
+ }
2400
+ } catch (e) {
2401
+ if (e.name === 'QuotaExceededError' || e.code === 22) {
2402
+ if (evictOldest(_storagePrefix)) {
2403
+ try {
2404
+ localStorage.setItem(getStorageKey(pageNum), svgHtml);
2405
+ localStorage.setItem(getRotStorageKey(pageNum), String(rotation || 0));
2406
+ return;
2407
+ } catch (e2) { /* still full */ }
2408
+ }
2409
+ if (typeof showToast === 'function') {
2410
+ showToast('Depolama alani dolu. Bazi eski notlar silindi.', 'warning');
2411
+ }
2412
+ }
2413
+ }
2414
+ }
2415
+ function loadFromStorage(pageNum) {
2416
+ try {
2417
+ var html = localStorage.getItem(getStorageKey(pageNum));
2418
+ var rot = parseInt(localStorage.getItem(getRotStorageKey(pageNum)) || '0', 10);
2419
+ return html ? { html: html, rotation: rot } : null;
2420
+ } catch (e) { return null; }
2421
+ }
2422
+
2423
+ // Sanitize SVG/HTML from localStorage to prevent XSS
2424
+ var _allowedSvgTags = ['path','rect','ellipse','line','circle','text','tspan','g','defs','marker','polyline','polygon'];
2425
+ var _allowedSvgAttrs = ['d','x','y','x1','y1','x2','y2','cx','cy','r','rx','ry','width','height','viewBox',
2426
+ 'fill','stroke','stroke-width','stroke-linecap','stroke-linejoin','stroke-dasharray','opacity',
2427
+ 'transform','class','data-id','data-type','font-size','font-family','text-anchor','points',
2428
+ 'marker-start','marker-end','marker-mid','id','refX','refY','markerWidth','markerHeight',
2429
+ 'orient','markerUnits','style'];
2430
+ function sanitizeSvgHtml(html) {
2431
+ if (!html) return '';
2432
+ var temp = document.createElement('div');
2433
+ temp.innerHTML = html;
2434
+ // Remove all non-allowed elements
2435
+ temp.querySelectorAll('*').forEach(function (el) {
2436
+ var tag = el.tagName.toLowerCase();
2437
+ if (_allowedSvgTags.indexOf(tag) === -1) {
2438
+ el.remove();
2439
+ return;
2440
+ }
2441
+ // Remove non-allowed attributes
2442
+ var attrs = Array.from(el.attributes);
2443
+ attrs.forEach(function (attr) {
2444
+ if (_allowedSvgAttrs.indexOf(attr.name) === -1) {
2445
+ el.removeAttribute(attr.name);
2446
+ }
2447
+ });
2448
+ // Sanitize style attribute - only allow safe CSS properties
2449
+ if (el.hasAttribute('style')) {
2450
+ var style = el.getAttribute('style');
2451
+ if (/expression|javascript|url\s*\(/i.test(style)) {
2452
+ el.removeAttribute('style');
2453
+ }
2454
+ }
2455
+ });
2456
+ return temp.innerHTML;
2457
+ }
2458
+
2459
+ // Sanitize text highlight HTML from localStorage
2460
+ function sanitizeHighlightHtml(html) {
2461
+ if (!html) return '';
2462
+ var temp = document.createElement('div');
2463
+ temp.innerHTML = html;
2464
+ temp.querySelectorAll('*').forEach(function (el) {
2465
+ var tag = el.tagName.toLowerCase();
2466
+ if (tag !== 'div') { el.remove(); return; }
2467
+ // Only allow style and class attributes on divs
2468
+ var attrs = Array.from(el.attributes);
2469
+ attrs.forEach(function (attr) {
2470
+ if (attr.name !== 'style' && attr.name !== 'class') {
2471
+ el.removeAttribute(attr.name);
2472
+ }
2473
+ });
2474
+ if (el.hasAttribute('style')) {
2475
+ var style = el.getAttribute('style');
2476
+ if (/expression|javascript|url\s*\(/i.test(style)) {
2477
+ el.removeAttribute('style');
2478
+ }
2479
+ }
2480
+ });
2481
+ return temp.innerHTML;
2482
+ }
2483
+
2307
2484
  // AbortControllers for annotation layer event listeners (cleanup on re-inject)
2308
2485
  const annotationAbortControllers = new Map(); // pageNum -> AbortController
2309
2486
 
@@ -2651,6 +2828,7 @@
2651
2828
  console.log('[PDF-Secure] No config found, showing file picker');
2652
2829
  return;
2653
2830
  }
2831
+ touchManifest();
2654
2832
 
2655
2833
  const config = _cfg;
2656
2834
  console.log('[PDF-Secure] Auto-loading:', config.filename);
@@ -3350,6 +3528,15 @@
3350
3528
 
3351
3529
 
3352
3530
 
3531
+ // Restore from localStorage if not already in memory
3532
+ if (!annotationsStore.has(pageNum)) {
3533
+ var stored = loadFromStorage(pageNum);
3534
+ if (stored) {
3535
+ annotationsStore.set(pageNum, sanitizeSvgHtml(stored.html));
3536
+ annotationRotations.set(pageNum, stored.rotation);
3537
+ }
3538
+ }
3539
+
3353
3540
  // Restore saved annotations for this page (with rotation transform if needed)
3354
3541
  if (annotationsStore.has(pageNum)) {
3355
3542
  const savedRot = annotationRotations.get(pageNum) || 0;
@@ -3538,10 +3725,13 @@
3538
3725
 
3539
3726
  if (newState) {
3540
3727
  annotationsStore.set(pageNum, newState);
3541
- annotationRotations.set(pageNum, pdfViewer.pagesRotation || 0);
3728
+ var rot = pdfViewer.pagesRotation || 0;
3729
+ annotationRotations.set(pageNum, rot);
3730
+ debouncedPersist(pageNum, newState, rot);
3542
3731
  } else {
3543
3732
  annotationsStore.delete(pageNum);
3544
3733
  annotationRotations.delete(pageNum);
3734
+ persistToStorage(pageNum, null, 0);
3545
3735
  }
3546
3736
 
3547
3737
  updateUndoRedoButtons();
@@ -3584,9 +3774,11 @@
3584
3774
  if (previousHtml.trim()) {
3585
3775
  annotationsStore.set(pageNum, svg.innerHTML);
3586
3776
  annotationRotations.set(pageNum, curRot);
3777
+ persistToStorage(pageNum, svg.innerHTML, curRot);
3587
3778
  } else {
3588
3779
  annotationsStore.delete(pageNum);
3589
3780
  annotationRotations.delete(pageNum);
3781
+ persistToStorage(pageNum, null, 0);
3590
3782
  }
3591
3783
 
3592
3784
  clearAnnotationSelection();
@@ -3620,9 +3812,11 @@
3620
3812
  if (redoHtml.trim()) {
3621
3813
  annotationsStore.set(pageNum, svg.innerHTML);
3622
3814
  annotationRotations.set(pageNum, curRot);
3815
+ persistToStorage(pageNum, svg.innerHTML, curRot);
3623
3816
  } else {
3624
3817
  annotationsStore.delete(pageNum);
3625
3818
  annotationRotations.delete(pageNum);
3819
+ persistToStorage(pageNum, null, 0);
3626
3820
  }
3627
3821
 
3628
3822
  clearAnnotationSelection();
@@ -3648,6 +3842,7 @@
3648
3842
  svg.innerHTML = '';
3649
3843
  annotationsStore.delete(pageNum);
3650
3844
  annotationRotations.delete(pageNum);
3845
+ persistToStorage(pageNum, null, 0);
3651
3846
 
3652
3847
  clearAnnotationSelection();
3653
3848
  updateUndoRedoButtons();
@@ -4328,24 +4523,40 @@
4328
4523
  function saveTextHighlights(pageNum, pageDiv) {
4329
4524
  const container = pageDiv.querySelector('.textHighlightContainer');
4330
4525
  if (container) {
4331
- const key = `textHighlight_${pageNum}`;
4332
- localStorage.setItem(key, container.innerHTML);
4526
+ try {
4527
+ localStorage.setItem(getTextHighlightKey(pageNum), container.innerHTML);
4528
+ touchManifest();
4529
+ } catch (e) {
4530
+ if (e.name === 'QuotaExceededError' || e.code === 22) {
4531
+ if (evictOldest(_storagePrefix)) {
4532
+ try {
4533
+ localStorage.setItem(getTextHighlightKey(pageNum), container.innerHTML);
4534
+ touchManifest();
4535
+ return;
4536
+ } catch (e2) { /* still full */ }
4537
+ }
4538
+ if (typeof showToast === 'function') {
4539
+ showToast('Depolama alani dolu. Bazi eski notlar silindi.', 'warning');
4540
+ }
4541
+ }
4542
+ }
4333
4543
  }
4334
4544
  }
4335
4545
 
4336
4546
  function loadTextHighlights(pageNum, pageDiv) {
4337
- const key = `textHighlight_${pageNum}`;
4338
- const saved = localStorage.getItem(key);
4339
- if (saved) {
4340
- let container = pageDiv.querySelector('.textHighlightContainer');
4341
- if (!container) {
4342
- container = document.createElement('div');
4343
- container.className = 'textHighlightContainer';
4344
- container.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:5;';
4345
- pageDiv.insertBefore(container, pageDiv.firstChild);
4547
+ try {
4548
+ const saved = localStorage.getItem(getTextHighlightKey(pageNum));
4549
+ if (saved) {
4550
+ let container = pageDiv.querySelector('.textHighlightContainer');
4551
+ if (!container) {
4552
+ container = document.createElement('div');
4553
+ container.className = 'textHighlightContainer';
4554
+ container.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:5;';
4555
+ pageDiv.insertBefore(container, pageDiv.firstChild);
4556
+ }
4557
+ container.innerHTML = sanitizeHighlightHtml(saved);
4346
4558
  }
4347
- container.innerHTML = saved;
4348
- }
4559
+ } catch (e) { /* localStorage unavailable */ }
4349
4560
  }
4350
4561
 
4351
4562
  function showHighlightPopup(x, y, pageDiv, rects) {