nodebb-plugin-pdf-secure 1.2.11 → 1.2.13

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.
@@ -37,11 +37,9 @@ Controllers.servePdfBinary = async function (req, res) {
37
37
  return res.status(400).json({ error: 'Missing nonce' });
38
38
  }
39
39
 
40
- if (!req.uid) {
41
- return res.status(401).json({ error: 'Not authenticated' });
42
- }
40
+ const uid = req.uid || 0; // Guest uid = 0
43
41
 
44
- const data = nonceStore.validate(nonce, req.uid);
42
+ const data = nonceStore.validate(nonce, uid);
45
43
  if (!data) {
46
44
  return res.status(403).json({ error: 'Invalid or expired nonce' });
47
45
  }
package/library.js CHANGED
@@ -54,14 +54,14 @@ plugin.init = async (params) => {
54
54
  next();
55
55
  });
56
56
 
57
- // PDF binary endpoint (nonce-validated)
58
- router.get('/api/v3/plugins/pdf-secure/pdf-data', middleware.ensureLoggedIn, controllers.servePdfBinary);
57
+ // PDF binary endpoint (nonce-validated, guests allowed)
58
+ router.get('/api/v3/plugins/pdf-secure/pdf-data', controllers.servePdfBinary);
59
59
 
60
60
  // Admin page route
61
61
  routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
62
62
 
63
- // Viewer page route (fullscreen Mozilla PDF.js viewer)
64
- router.get('/plugins/pdf-secure/viewer', middleware.ensureLoggedIn, (req, res) => {
63
+ // Viewer page route (fullscreen Mozilla PDF.js viewer, guests allowed)
64
+ router.get('/plugins/pdf-secure/viewer', (req, res) => {
65
65
  const { file } = req.query;
66
66
  if (!file) {
67
67
  return res.status(400).send('Missing file parameter');
@@ -81,7 +81,7 @@ plugin.init = async (params) => {
81
81
  // Generate nonce + key HERE (in viewer route)
82
82
  // This way the key is ONLY embedded in HTML, never in a separate API response
83
83
  const isPremium = true;
84
- const nonceData = nonceStore.generate(req.uid, safeName, isPremium);
84
+ const nonceData = nonceStore.generate(req.uid || 0, safeName, isPremium);
85
85
 
86
86
  // Serve the viewer template with comprehensive security headers
87
87
  res.set({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure",
3
- "version": "1.2.11",
3
+ "version": "1.2.13",
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": {
@@ -73,11 +73,13 @@
73
73
  const cached = pdfBufferCache.get(filename);
74
74
  if (cached && event.source) {
75
75
  // Send cached buffer to viewer (transferable for 0-copy)
76
+ // Clone once: keep original in cache, transfer the copy
77
+ const copy = cached.slice(0);
76
78
  event.source.postMessage({
77
79
  type: 'pdf-secure-cache-response',
78
80
  filename: filename,
79
- buffer: cached
80
- }, event.origin, [cached.slice(0)]); // Clone buffer since we keep original
81
+ buffer: copy
82
+ }, event.origin, [copy]);
81
83
  console.log('[PDF-Secure] Cache: Hit -', filename);
82
84
  } else if (event.source) {
83
85
  // No cache, viewer will fetch normally
@@ -52,6 +52,8 @@
52
52
  -moz-user-select: none;
53
53
  -ms-user-select: none;
54
54
  user-select: none;
55
+ /* Prevent scroll chaining out of iframe */
56
+ overscroll-behavior: contain;
55
57
  }
56
58
 
57
59
  /* Print Protection - hide everything when printing */
@@ -624,6 +626,9 @@
624
626
  overflow: auto;
625
627
  background: #525659;
626
628
  z-index: 1;
629
+ /* Prevent scroll chaining to parent/iframe on touch devices */
630
+ overscroll-behavior: contain;
631
+ -webkit-overflow-scrolling: touch;
627
632
  }
628
633
 
629
634
  #viewerContainer.withSidebar {
@@ -1287,10 +1292,12 @@
1287
1292
  display: block !important;
1288
1293
  min-width: unset;
1289
1294
  box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
1295
+ pointer-events: none;
1290
1296
  }
1291
1297
 
1292
1298
  .toolDropdown.visible {
1293
1299
  transform: translateY(0);
1300
+ pointer-events: auto;
1294
1301
  }
1295
1302
 
1296
1303
  /* Responsive dropzone */
@@ -1454,10 +1461,12 @@
1454
1461
  display: block !important;
1455
1462
  min-width: unset;
1456
1463
  box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
1464
+ pointer-events: none;
1457
1465
  }
1458
1466
 
1459
1467
  .toolDropdown.visible {
1460
1468
  transform: translateY(0);
1469
+ pointer-events: auto;
1461
1470
  }
1462
1471
 
1463
1472
  /* Selection toolbar & toast above bottom bar */
@@ -1520,6 +1529,11 @@
1520
1529
  width: 56px;
1521
1530
  height: 56px;
1522
1531
  }
1532
+
1533
+ /* Let our JS handle pinch-to-zoom, but allow browser pan-y for scroll */
1534
+ #viewerContainer {
1535
+ touch-action: pan-x pan-y;
1536
+ }
1523
1537
  }
1524
1538
  </style>
1525
1539
  </head>
@@ -2267,7 +2281,7 @@
2267
2281
  });
2268
2282
 
2269
2283
  eventBus.on('pagerendered', (evt) => {
2270
- if (annotationMode) injectAnnotationLayer(evt.pageNumber);
2284
+ injectAnnotationLayer(evt.pageNumber);
2271
2285
 
2272
2286
  // Rotation is handled natively by PDF.js via pagesRotation
2273
2287
  });
@@ -2350,7 +2364,17 @@
2350
2364
  const dropdownBackdrop = document.getElementById('dropdownBackdrop');
2351
2365
  const overflowDropdown = document.getElementById('overflowDropdown');
2352
2366
 
2367
+ // Swipe-to-dismiss controller (declared before closeAllDropdowns which references it)
2368
+ let swipeAbortController = null;
2369
+
2353
2370
  function closeAllDropdowns() {
2371
+ // Clean up swipe listeners
2372
+ if (swipeAbortController) { swipeAbortController.abort(); swipeAbortController = null; }
2373
+ // Reset inline transform from swipe gesture
2374
+ [highlightDropdown, drawDropdown, shapesDropdown, overflowDropdown].forEach(dd => {
2375
+ dd.style.transform = '';
2376
+ dd.style.transition = '';
2377
+ });
2354
2378
  highlightDropdown.classList.remove('visible');
2355
2379
  drawDropdown.classList.remove('visible');
2356
2380
  shapesDropdown.classList.remove('visible');
@@ -2374,10 +2398,52 @@
2374
2398
  // Show backdrop on mobile/tablet portrait
2375
2399
  if (useBottomSheet) {
2376
2400
  dropdownBackdrop.classList.add('visible');
2401
+ setupBottomSheetSwipe(dropdown);
2377
2402
  }
2378
2403
  }
2379
2404
  }
2380
2405
 
2406
+ // Bottom sheet swipe-to-dismiss (uses AbortController to prevent listener accumulation)
2407
+ function setupBottomSheetSwipe(dropdown) {
2408
+ // Abort previous swipe listeners if any
2409
+ if (swipeAbortController) swipeAbortController.abort();
2410
+ swipeAbortController = new AbortController();
2411
+ const signal = swipeAbortController.signal;
2412
+
2413
+ let startY = 0, currentY = 0, isDragging = false;
2414
+
2415
+ dropdown.addEventListener('touchstart', (e) => {
2416
+ const rect = dropdown.getBoundingClientRect();
2417
+ const touchY = e.touches[0].clientY;
2418
+ if (touchY - rect.top > 40 && dropdown.scrollTop > 0) return;
2419
+ startY = touchY;
2420
+ currentY = startY;
2421
+ isDragging = true;
2422
+ dropdown.style.transition = 'none';
2423
+ }, { signal });
2424
+
2425
+ dropdown.addEventListener('touchmove', (e) => {
2426
+ if (!isDragging) return;
2427
+ currentY = e.touches[0].clientY;
2428
+ const dy = currentY - startY;
2429
+ if (dy > 0) {
2430
+ dropdown.style.transform = `translateY(${dy}px)`;
2431
+ e.preventDefault();
2432
+ }
2433
+ }, { passive: false, signal });
2434
+
2435
+ dropdown.addEventListener('touchend', () => {
2436
+ if (!isDragging) return;
2437
+ isDragging = false;
2438
+ const dy = currentY - startY;
2439
+ dropdown.style.transition = '';
2440
+ if (dy > 80) {
2441
+ closeAllDropdowns();
2442
+ }
2443
+ dropdown.style.transform = '';
2444
+ }, { signal });
2445
+ }
2446
+
2381
2447
  // Backdrop click closes dropdowns
2382
2448
  dropdownBackdrop.addEventListener('click', () => {
2383
2449
  closeAllDropdowns();
@@ -2881,7 +2947,7 @@
2881
2947
  const scaleY = vb.scaleY;
2882
2948
 
2883
2949
  if (currentTool === 'eraser') {
2884
- eraseAt(svg, x, y, scaleX);
2950
+ eraseAt(svg, x, y, scaleX, coords.clientX, coords.clientY);
2885
2951
  // Performance: Don't save here - isDrawing is true, stopDraw will save on mouseup
2886
2952
  return;
2887
2953
  }
@@ -2982,7 +3048,7 @@
2982
3048
  const scaleX = vb.scaleX;
2983
3049
 
2984
3050
  if (currentTool === 'eraser') {
2985
- eraseAt(svg, x, y, scaleX);
3051
+ eraseAt(svg, x, y, scaleX, coords.clientX, coords.clientY);
2986
3052
  // Performance: Don't save on every mousemove - let mouseup handle it
2987
3053
  return;
2988
3054
  }
@@ -3390,13 +3456,12 @@
3390
3456
  });
3391
3457
  }
3392
3458
 
3393
- function eraseAt(svg, x, y, scale = 1) {
3394
- const hitRadius = 15 * scale; // Scale hit radius with viewBox
3395
- // Erase paths, text, and shape elements (rect, ellipse, line)
3396
- svg.querySelectorAll('path, text, rect, ellipse, line').forEach(el => {
3397
- const bbox = el.getBBox();
3398
- if (x >= bbox.x - hitRadius && x <= bbox.x + bbox.width + hitRadius &&
3399
- y >= bbox.y - hitRadius && y <= bbox.y + bbox.height + hitRadius) {
3459
+ function eraseAt(svg, x, y, scale = 1, clientX, clientY) {
3460
+ // Use elementsFromPoint for rotation-aware hit testing
3461
+ const annotationTags = new Set(['path', 'text', 'rect', 'ellipse', 'line']);
3462
+ const elements = document.elementsFromPoint(clientX, clientY);
3463
+ elements.forEach(el => {
3464
+ if (el.closest('.annotationLayer') === svg && annotationTags.has(el.tagName)) {
3400
3465
  el.remove();
3401
3466
  }
3402
3467
  });
@@ -4322,12 +4387,16 @@
4322
4387
  // ERGONOMIC FEATURES
4323
4388
  // ==========================================
4324
4389
 
4325
- // Fullscreen toggle function
4390
+ // Fullscreen toggle function (with webkit fallback for iOS Safari)
4326
4391
  function toggleFullscreen() {
4327
- if (document.fullscreenElement) {
4328
- document.exitFullscreen();
4392
+ const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
4393
+ if (fsEl) {
4394
+ if (document.exitFullscreen) document.exitFullscreen();
4395
+ else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
4329
4396
  } else {
4330
- document.documentElement.requestFullscreen().catch(() => { });
4397
+ const el = document.documentElement;
4398
+ if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
4399
+ else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
4331
4400
  }
4332
4401
  }
4333
4402
 
@@ -4335,7 +4404,8 @@
4335
4404
  function updateFullscreenIcon() {
4336
4405
  const icon = document.getElementById('fullscreenIcon');
4337
4406
  const btn = document.getElementById('fullscreenBtn');
4338
- if (document.fullscreenElement) {
4407
+ const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
4408
+ if (fsEl) {
4339
4409
  icon.innerHTML = '<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>';
4340
4410
  btn.classList.add('active');
4341
4411
  } else {
@@ -4345,31 +4415,12 @@
4345
4415
  }
4346
4416
 
4347
4417
  document.addEventListener('fullscreenchange', updateFullscreenIcon);
4418
+ document.addEventListener('webkitfullscreenchange', updateFullscreenIcon);
4348
4419
 
4349
4420
  // Fullscreen button click
4350
4421
  document.getElementById('fullscreenBtn').onclick = () => toggleFullscreen();
4351
4422
 
4352
- // Double-click on page for fullscreen
4353
- let lastClickTime = 0;
4354
- container.addEventListener('click', (e) => {
4355
- const now = Date.now();
4356
- if (now - lastClickTime < 300) {
4357
- toggleFullscreen();
4358
- }
4359
- lastClickTime = now;
4360
- });
4361
4423
 
4362
- // Auto-fullscreen when viewer loads inside iframe
4363
- if (window.self !== window.top && window.PDF_SECURE_CONFIG) {
4364
- // We're inside an iframe - request fullscreen on first user interaction
4365
- const autoFullscreen = () => {
4366
- document.documentElement.requestFullscreen().catch(() => { });
4367
- container.removeEventListener('click', autoFullscreen);
4368
- container.removeEventListener('touchstart', autoFullscreen);
4369
- };
4370
- container.addEventListener('click', autoFullscreen, { once: true });
4371
- container.addEventListener('touchstart', autoFullscreen, { once: true });
4372
- }
4373
4424
 
4374
4425
  // Mouse wheel zoom with Ctrl
4375
4426
  container.addEventListener('wheel', (e) => {
@@ -4453,17 +4504,25 @@
4453
4504
  setupResponsiveToolbar();
4454
4505
  });
4455
4506
 
4456
- // Also handle resize for orientation changes
4507
+ // Also handle resize for orientation changes (debounced)
4508
+ let resizeTimer;
4457
4509
  window.addEventListener('resize', () => {
4458
- setupResponsiveToolbar();
4510
+ clearTimeout(resizeTimer);
4511
+ resizeTimer = setTimeout(setupResponsiveToolbar, 150);
4459
4512
  });
4460
4513
 
4461
4514
  // ==========================================
4462
- // PINCH-TO-ZOOM (Touch devices)
4515
+ // PINCH-TO-ZOOM (Touch devices) - Smooth CSS Transform
4463
4516
  // ==========================================
4464
4517
  let pinchStartDistance = 0;
4465
4518
  let pinchStartScale = 1;
4466
4519
  let isPinching = false;
4520
+ let pinchMidX = 0;
4521
+ let pinchMidY = 0;
4522
+ let pinchVisualRatio = 1;
4523
+
4524
+ // The viewer div that holds PDF pages
4525
+ const viewerDiv = document.getElementById('viewer');
4467
4526
 
4468
4527
  function getTouchDistance(touches) {
4469
4528
  const dx = touches[0].clientX - touches[1].clientX;
@@ -4471,11 +4530,40 @@
4471
4530
  return Math.sqrt(dx * dx + dy * dy);
4472
4531
  }
4473
4532
 
4533
+ function getTouchMidpoint(touches) {
4534
+ return {
4535
+ x: (touches[0].clientX + touches[1].clientX) / 2,
4536
+ y: (touches[0].clientY + touches[1].clientY) / 2
4537
+ };
4538
+ }
4539
+
4474
4540
  container.addEventListener('touchstart', (e) => {
4475
- if (e.touches.length === 2 && !currentTool) {
4541
+ if (e.touches.length === 2) {
4542
+ // Cancel any active drawing and clean up
4543
+ if (isDrawing && currentDrawingPage) {
4544
+ if (currentPath && currentPath.parentNode) currentPath.remove();
4545
+ currentPath = null;
4546
+ pathSegments = [];
4547
+ if (drawRAF) { cancelAnimationFrame(drawRAF); drawRAF = null; }
4548
+ isDrawing = false;
4549
+ currentSvg = null;
4550
+ currentDrawingPage = null;
4551
+ }
4476
4552
  isPinching = true;
4477
4553
  pinchStartDistance = getTouchDistance(e.touches);
4478
4554
  pinchStartScale = pdfViewer.currentScale;
4555
+ pinchVisualRatio = 1;
4556
+
4557
+ // Get midpoint relative to container
4558
+ const mid = getTouchMidpoint(e.touches);
4559
+ const rect = container.getBoundingClientRect();
4560
+ pinchMidX = mid.x - rect.left + container.scrollLeft;
4561
+ pinchMidY = mid.y - rect.top + container.scrollTop;
4562
+
4563
+ // Prepare for CSS transform - use will-change for GPU acceleration
4564
+ viewerDiv.style.willChange = 'transform';
4565
+ viewerDiv.style.transformOrigin = pinchMidX + 'px ' + pinchMidY + 'px';
4566
+
4479
4567
  e.preventDefault();
4480
4568
  }
4481
4569
  }, { passive: false });
@@ -4484,18 +4572,79 @@
4484
4572
  if (isPinching && e.touches.length === 2) {
4485
4573
  const dist = getTouchDistance(e.touches);
4486
4574
  const ratio = dist / pinchStartDistance;
4487
- const newScale = Math.min(Math.max(pinchStartScale * ratio, 0.5), 5.0);
4488
- pdfViewer.currentScale = newScale;
4575
+ // Clamp the target scale
4576
+ const targetScale = pinchStartScale * ratio;
4577
+ const clampedScale = Math.min(Math.max(targetScale, 0.5), 5.0);
4578
+ pinchVisualRatio = clampedScale / pinchStartScale;
4579
+
4580
+ // Apply CSS transform for instant smooth visual feedback (no re-render)
4581
+ viewerDiv.style.transform = 'scale(' + pinchVisualRatio + ')';
4582
+
4489
4583
  e.preventDefault();
4490
4584
  }
4491
4585
  }, { passive: false });
4492
4586
 
4493
4587
  container.addEventListener('touchend', (e) => {
4494
- if (e.touches.length < 2) {
4588
+ if (isPinching && e.touches.length < 2) {
4589
+ isPinching = false;
4590
+
4591
+ // Remove CSS transform
4592
+ viewerDiv.style.transform = '';
4593
+ viewerDiv.style.willChange = '';
4594
+ viewerDiv.style.transformOrigin = '';
4595
+
4596
+ // Apply actual scale (triggers PDF re-render only once)
4597
+ const finalScale = Math.min(Math.max(pinchStartScale * pinchVisualRatio, 0.5), 5.0);
4598
+ if (Math.abs(finalScale - pdfViewer.currentScale) > 0.01) {
4599
+ pdfViewer.currentScale = finalScale;
4600
+ }
4601
+ pinchVisualRatio = 1;
4602
+ }
4603
+ });
4604
+
4605
+ container.addEventListener('touchcancel', (e) => {
4606
+ if (isPinching) {
4495
4607
  isPinching = false;
4608
+ viewerDiv.style.transform = '';
4609
+ viewerDiv.style.willChange = '';
4610
+ viewerDiv.style.transformOrigin = '';
4611
+ pinchVisualRatio = 1;
4496
4612
  }
4497
4613
  });
4498
4614
 
4615
+ // ==========================================
4616
+ // TOUCH SCROLL BOUNDARY (Prevent exit on tablet)
4617
+ // ==========================================
4618
+ // On touch devices, prevent scroll from escaping the container
4619
+ // when at the top/bottom boundary. Only fullscreen button should exit.
4620
+ (function initTouchScrollBoundary() {
4621
+ if (!isTouch()) return;
4622
+
4623
+ let touchStartY = 0;
4624
+
4625
+ container.addEventListener('touchstart', (e) => {
4626
+ if (e.touches.length === 1) {
4627
+ touchStartY = e.touches[0].clientY;
4628
+ }
4629
+ }, { passive: true });
4630
+
4631
+ container.addEventListener('touchmove', (e) => {
4632
+ // Skip if pinching
4633
+ if (isPinching || e.touches.length !== 1) return;
4634
+
4635
+ const touchY = e.touches[0].clientY;
4636
+ const deltaY = touchStartY - touchY; // positive = scrolling down
4637
+
4638
+ const atTop = container.scrollTop <= 0;
4639
+ const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 1;
4640
+
4641
+ // Prevent overscroll at boundaries
4642
+ if ((atTop && deltaY < 0) || (atBottom && deltaY > 0)) {
4643
+ e.preventDefault();
4644
+ }
4645
+ }, { passive: false });
4646
+ })();
4647
+
4499
4648
  // ==========================================
4500
4649
  // CONTEXT MENU TOUCH HANDLING
4501
4650
  // ==========================================