nodebb-plugin-pdf-secure 1.2.11 → 1.2.12

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.12",
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": {
@@ -1287,10 +1287,12 @@
1287
1287
  display: block !important;
1288
1288
  min-width: unset;
1289
1289
  box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
1290
+ pointer-events: none;
1290
1291
  }
1291
1292
 
1292
1293
  .toolDropdown.visible {
1293
1294
  transform: translateY(0);
1295
+ pointer-events: auto;
1294
1296
  }
1295
1297
 
1296
1298
  /* Responsive dropzone */
@@ -1454,10 +1456,12 @@
1454
1456
  display: block !important;
1455
1457
  min-width: unset;
1456
1458
  box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
1459
+ pointer-events: none;
1457
1460
  }
1458
1461
 
1459
1462
  .toolDropdown.visible {
1460
1463
  transform: translateY(0);
1464
+ pointer-events: auto;
1461
1465
  }
1462
1466
 
1463
1467
  /* Selection toolbar & toast above bottom bar */
@@ -2267,7 +2271,7 @@
2267
2271
  });
2268
2272
 
2269
2273
  eventBus.on('pagerendered', (evt) => {
2270
- if (annotationMode) injectAnnotationLayer(evt.pageNumber);
2274
+ injectAnnotationLayer(evt.pageNumber);
2271
2275
 
2272
2276
  // Rotation is handled natively by PDF.js via pagesRotation
2273
2277
  });
@@ -2350,7 +2354,17 @@
2350
2354
  const dropdownBackdrop = document.getElementById('dropdownBackdrop');
2351
2355
  const overflowDropdown = document.getElementById('overflowDropdown');
2352
2356
 
2357
+ // Swipe-to-dismiss controller (declared before closeAllDropdowns which references it)
2358
+ let swipeAbortController = null;
2359
+
2353
2360
  function closeAllDropdowns() {
2361
+ // Clean up swipe listeners
2362
+ if (swipeAbortController) { swipeAbortController.abort(); swipeAbortController = null; }
2363
+ // Reset inline transform from swipe gesture
2364
+ [highlightDropdown, drawDropdown, shapesDropdown, overflowDropdown].forEach(dd => {
2365
+ dd.style.transform = '';
2366
+ dd.style.transition = '';
2367
+ });
2354
2368
  highlightDropdown.classList.remove('visible');
2355
2369
  drawDropdown.classList.remove('visible');
2356
2370
  shapesDropdown.classList.remove('visible');
@@ -2374,10 +2388,52 @@
2374
2388
  // Show backdrop on mobile/tablet portrait
2375
2389
  if (useBottomSheet) {
2376
2390
  dropdownBackdrop.classList.add('visible');
2391
+ setupBottomSheetSwipe(dropdown);
2377
2392
  }
2378
2393
  }
2379
2394
  }
2380
2395
 
2396
+ // Bottom sheet swipe-to-dismiss (uses AbortController to prevent listener accumulation)
2397
+ function setupBottomSheetSwipe(dropdown) {
2398
+ // Abort previous swipe listeners if any
2399
+ if (swipeAbortController) swipeAbortController.abort();
2400
+ swipeAbortController = new AbortController();
2401
+ const signal = swipeAbortController.signal;
2402
+
2403
+ let startY = 0, currentY = 0, isDragging = false;
2404
+
2405
+ dropdown.addEventListener('touchstart', (e) => {
2406
+ const rect = dropdown.getBoundingClientRect();
2407
+ const touchY = e.touches[0].clientY;
2408
+ if (touchY - rect.top > 40 && dropdown.scrollTop > 0) return;
2409
+ startY = touchY;
2410
+ currentY = startY;
2411
+ isDragging = true;
2412
+ dropdown.style.transition = 'none';
2413
+ }, { signal });
2414
+
2415
+ dropdown.addEventListener('touchmove', (e) => {
2416
+ if (!isDragging) return;
2417
+ currentY = e.touches[0].clientY;
2418
+ const dy = currentY - startY;
2419
+ if (dy > 0) {
2420
+ dropdown.style.transform = `translateY(${dy}px)`;
2421
+ e.preventDefault();
2422
+ }
2423
+ }, { passive: false, signal });
2424
+
2425
+ dropdown.addEventListener('touchend', () => {
2426
+ if (!isDragging) return;
2427
+ isDragging = false;
2428
+ const dy = currentY - startY;
2429
+ dropdown.style.transition = '';
2430
+ if (dy > 80) {
2431
+ closeAllDropdowns();
2432
+ }
2433
+ dropdown.style.transform = '';
2434
+ }, { signal });
2435
+ }
2436
+
2381
2437
  // Backdrop click closes dropdowns
2382
2438
  dropdownBackdrop.addEventListener('click', () => {
2383
2439
  closeAllDropdowns();
@@ -2881,7 +2937,7 @@
2881
2937
  const scaleY = vb.scaleY;
2882
2938
 
2883
2939
  if (currentTool === 'eraser') {
2884
- eraseAt(svg, x, y, scaleX);
2940
+ eraseAt(svg, x, y, scaleX, coords.clientX, coords.clientY);
2885
2941
  // Performance: Don't save here - isDrawing is true, stopDraw will save on mouseup
2886
2942
  return;
2887
2943
  }
@@ -2982,7 +3038,7 @@
2982
3038
  const scaleX = vb.scaleX;
2983
3039
 
2984
3040
  if (currentTool === 'eraser') {
2985
- eraseAt(svg, x, y, scaleX);
3041
+ eraseAt(svg, x, y, scaleX, coords.clientX, coords.clientY);
2986
3042
  // Performance: Don't save on every mousemove - let mouseup handle it
2987
3043
  return;
2988
3044
  }
@@ -3390,13 +3446,12 @@
3390
3446
  });
3391
3447
  }
3392
3448
 
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) {
3449
+ function eraseAt(svg, x, y, scale = 1, clientX, clientY) {
3450
+ // Use elementsFromPoint for rotation-aware hit testing
3451
+ const annotationTags = new Set(['path', 'text', 'rect', 'ellipse', 'line']);
3452
+ const elements = document.elementsFromPoint(clientX, clientY);
3453
+ elements.forEach(el => {
3454
+ if (el.closest('.annotationLayer') === svg && annotationTags.has(el.tagName)) {
3400
3455
  el.remove();
3401
3456
  }
3402
3457
  });
@@ -4322,12 +4377,16 @@
4322
4377
  // ERGONOMIC FEATURES
4323
4378
  // ==========================================
4324
4379
 
4325
- // Fullscreen toggle function
4380
+ // Fullscreen toggle function (with webkit fallback for iOS Safari)
4326
4381
  function toggleFullscreen() {
4327
- if (document.fullscreenElement) {
4328
- document.exitFullscreen();
4382
+ const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
4383
+ if (fsEl) {
4384
+ if (document.exitFullscreen) document.exitFullscreen();
4385
+ else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
4329
4386
  } else {
4330
- document.documentElement.requestFullscreen().catch(() => { });
4387
+ const el = document.documentElement;
4388
+ if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
4389
+ else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
4331
4390
  }
4332
4391
  }
4333
4392
 
@@ -4335,7 +4394,8 @@
4335
4394
  function updateFullscreenIcon() {
4336
4395
  const icon = document.getElementById('fullscreenIcon');
4337
4396
  const btn = document.getElementById('fullscreenBtn');
4338
- if (document.fullscreenElement) {
4397
+ const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
4398
+ if (fsEl) {
4339
4399
  icon.innerHTML = '<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>';
4340
4400
  btn.classList.add('active');
4341
4401
  } else {
@@ -4345,31 +4405,12 @@
4345
4405
  }
4346
4406
 
4347
4407
  document.addEventListener('fullscreenchange', updateFullscreenIcon);
4408
+ document.addEventListener('webkitfullscreenchange', updateFullscreenIcon);
4348
4409
 
4349
4410
  // Fullscreen button click
4350
4411
  document.getElementById('fullscreenBtn').onclick = () => toggleFullscreen();
4351
4412
 
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
4413
 
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
4414
 
4374
4415
  // Mouse wheel zoom with Ctrl
4375
4416
  container.addEventListener('wheel', (e) => {
@@ -4453,9 +4494,11 @@
4453
4494
  setupResponsiveToolbar();
4454
4495
  });
4455
4496
 
4456
- // Also handle resize for orientation changes
4497
+ // Also handle resize for orientation changes (debounced)
4498
+ let resizeTimer;
4457
4499
  window.addEventListener('resize', () => {
4458
- setupResponsiveToolbar();
4500
+ clearTimeout(resizeTimer);
4501
+ resizeTimer = setTimeout(setupResponsiveToolbar, 150);
4459
4502
  });
4460
4503
 
4461
4504
  // ==========================================
@@ -4472,7 +4515,18 @@
4472
4515
  }
4473
4516
 
4474
4517
  container.addEventListener('touchstart', (e) => {
4475
- if (e.touches.length === 2 && !currentTool) {
4518
+ if (e.touches.length === 2) {
4519
+ // Cancel any active drawing and clean up
4520
+ if (isDrawing && currentDrawingPage) {
4521
+ // Remove incomplete path
4522
+ if (currentPath && currentPath.parentNode) currentPath.remove();
4523
+ currentPath = null;
4524
+ pathSegments = [];
4525
+ if (drawRAF) { cancelAnimationFrame(drawRAF); drawRAF = null; }
4526
+ isDrawing = false;
4527
+ currentSvg = null;
4528
+ currentDrawingPage = null;
4529
+ }
4476
4530
  isPinching = true;
4477
4531
  pinchStartDistance = getTouchDistance(e.touches);
4478
4532
  pinchStartScale = pdfViewer.currentScale;