nodebb-plugin-pdf-secure 1.2.3 → 1.2.5

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.
@@ -5,23 +5,21 @@ const pdfHandler = require('./pdf-handler');
5
5
 
6
6
  const Controllers = module.exports;
7
7
 
8
- // XOR key for partial encryption (rotates through this pattern)
9
- const XOR_KEY = [0x5A, 0x3C, 0x7E, 0x1F, 0x9D, 0xB2, 0x4A, 0xE8];
10
-
11
8
  // Partial XOR - encrypts first 10KB and every 50th byte after that
12
- function partialXorEncode(buffer) {
9
+ // Now uses dynamic key from nonce data
10
+ function partialXorEncode(buffer, xorKey) {
13
11
  const data = Buffer.from(buffer);
14
- const keyLen = XOR_KEY.length;
12
+ const keyLen = xorKey.length;
15
13
 
16
14
  // Encrypt first 10KB fully
17
15
  const fullEncryptLen = Math.min(10240, data.length);
18
16
  for (let i = 0; i < fullEncryptLen; i++) {
19
- data[i] = data[i] ^ XOR_KEY[i % keyLen];
17
+ data[i] = data[i] ^ xorKey[i % keyLen];
20
18
  }
21
19
 
22
20
  // Encrypt every 50th byte after that
23
21
  for (let i = fullEncryptLen; i < data.length; i += 50) {
24
- data[i] = data[i] ^ XOR_KEY[i % keyLen];
22
+ data[i] = data[i] ^ xorKey[i % keyLen];
25
23
  }
26
24
 
27
25
  return data;
@@ -56,16 +54,14 @@ Controllers.servePdfBinary = async function (req, res) {
56
54
  pdfBuffer = await pdfHandler.getSinglePagePdf(data.file);
57
55
  }
58
56
 
59
- // Apply partial XOR encryption
60
- const encodedBuffer = partialXorEncode(pdfBuffer);
57
+ // Apply partial XOR encryption with dynamic key from nonce
58
+ const encodedBuffer = partialXorEncode(pdfBuffer, data.xorKey);
61
59
 
62
60
  res.set({
63
- 'Content-Type': 'application/octet-stream',
61
+ 'Content-Type': 'image/gif', // Misleading - actual PDF binary
64
62
  'Cache-Control': 'no-store, no-cache, must-revalidate, private',
65
63
  'X-Content-Type-Options': 'nosniff',
66
64
  'Content-Disposition': 'inline',
67
- // Send XOR key as header for client decoding
68
- 'X-PDF-Key': Buffer.from(XOR_KEY).toString('base64'),
69
65
  });
70
66
 
71
67
  return res.send(encodedBuffer);
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const { v4: uuidv4 } = require('uuid');
4
5
 
5
6
  const store = new Map();
@@ -16,17 +17,38 @@ setInterval(() => {
16
17
  }
17
18
  }, CLEANUP_INTERVAL).unref();
18
19
 
20
+ // Generate a random XOR key (8 bytes)
21
+ function generateXorKey() {
22
+ return crypto.randomBytes(8);
23
+ }
24
+
19
25
  const NonceStore = module.exports;
20
26
 
21
27
  NonceStore.generate = function (uid, file, isPremium) {
22
28
  const nonce = uuidv4();
29
+ const xorKey = generateXorKey();
30
+
23
31
  store.set(nonce, {
24
32
  uid: uid,
25
33
  file: file,
26
34
  isPremium: isPremium,
35
+ xorKey: xorKey, // Store unique key for this nonce
27
36
  createdAt: Date.now(),
28
37
  });
29
- return nonce;
38
+
39
+ return {
40
+ nonce: nonce,
41
+ xorKey: xorKey.toString('base64') // Return key for viewer injection
42
+ };
43
+ };
44
+
45
+ // Get key without consuming nonce (for viewer injection)
46
+ NonceStore.getKey = function (nonce) {
47
+ const data = store.get(nonce);
48
+ if (!data) {
49
+ return null;
50
+ }
51
+ return data.xorKey.toString('base64');
30
52
  };
31
53
 
32
54
  NonceStore.validate = function (nonce, uid) {
@@ -48,5 +70,5 @@ NonceStore.validate = function (nonce, uid) {
48
70
  return null;
49
71
  }
50
72
 
51
- return data;
73
+ return data; // Now includes xorKey
52
74
  };
package/library.js CHANGED
@@ -11,9 +11,21 @@ const nonceStore = require('./lib/nonce-store');
11
11
 
12
12
  const plugin = {};
13
13
 
14
+ // Memory cache for viewer.html
15
+ let viewerHtmlCache = null;
16
+
14
17
  plugin.init = async (params) => {
15
18
  const { router, middleware } = params;
16
19
 
20
+ // Pre-load viewer.html into memory cache
21
+ const viewerPath = path.join(__dirname, 'static', 'viewer.html');
22
+ try {
23
+ viewerHtmlCache = fs.readFileSync(viewerPath, 'utf8');
24
+ console.log('[PDF-Secure] Viewer template cached in memory');
25
+ } catch (err) {
26
+ console.error('[PDF-Secure] Failed to cache viewer template:', err.message);
27
+ }
28
+
17
29
  // PDF direct access blocker middleware
18
30
  // Intercepts requests to uploaded PDF files and returns 403
19
31
  router.get('/assets/uploads/files/:filename', (req, res, next) => {
@@ -42,6 +54,16 @@ plugin.init = async (params) => {
42
54
  return res.status(400).send('Invalid file');
43
55
  }
44
56
 
57
+ // Check cache
58
+ if (!viewerHtmlCache) {
59
+ return res.status(500).send('Viewer not available');
60
+ }
61
+
62
+ // Generate nonce + key HERE (in viewer route)
63
+ // This way the key is ONLY embedded in HTML, never in a separate API response
64
+ const isPremium = true;
65
+ const nonceData = nonceStore.generate(req.uid, safeName, isPremium);
66
+
45
67
  // Serve the viewer template with comprehensive security headers
46
68
  res.set({
47
69
  'X-Frame-Options': 'SAMEORIGIN',
@@ -55,60 +77,32 @@ plugin.init = async (params) => {
55
77
  'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'self'",
56
78
  });
57
79
 
58
- // Read and send the viewer HTML
59
- const viewerPath = path.join(__dirname, 'static', 'viewer.html');
60
- fs.readFile(viewerPath, 'utf8', (err, html) => {
61
- if (err) {
62
- return res.status(500).send('Viewer not found');
63
- }
64
-
65
- // Inject the filename and config into the viewer
66
- // Also inject CSS to hide uploadOverlay immediately
67
- const injectedHtml = html
68
- .replace('</head>', `
69
- <style>
70
- /* Hide upload overlay since PDF will auto-load */
71
- #uploadOverlay { display: none !important; }
72
- </style>
73
- <script>
74
- window.PDF_SECURE_CONFIG = {
75
- filename: ${JSON.stringify(safeName)},
76
- relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
77
- csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')}
78
- };
79
- </script>
80
- </head>`);
81
-
82
- res.type('html').send(injectedHtml);
83
- });
80
+ // Inject the filename, nonce, and key into the cached viewer
81
+ // Key is embedded in HTML - NOT visible in any network API response!
82
+ const injectedHtml = viewerHtmlCache
83
+ .replace('</head>', `
84
+ <style>
85
+ /* Hide upload overlay since PDF will auto-load */
86
+ #uploadOverlay { display: none !important; }
87
+ </style>
88
+ <script>
89
+ window.PDF_SECURE_CONFIG = {
90
+ filename: ${JSON.stringify(safeName)},
91
+ relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
92
+ csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
93
+ nonce: ${JSON.stringify(nonceData.nonce)},
94
+ dk: ${JSON.stringify(nonceData.xorKey)}
95
+ };
96
+ </script>
97
+ </head>`);
98
+
99
+ res.type('html').send(injectedHtml);
84
100
  });
85
101
  };
86
102
 
87
103
  plugin.addRoutes = async ({ router, middleware, helpers }) => {
88
- // Nonce generation endpoint
89
- routeHelpers.setupApiRoute(router, 'get', '/pdf-secure/nonce', [middleware.ensureLoggedIn], async (req, res) => {
90
- const { file } = req.query;
91
- if (!file) {
92
- return helpers.formatApiResponse(400, res, new Error('Missing file parameter'));
93
- }
94
-
95
- // Sanitize filename
96
- const path = require('path');
97
- const safeName = path.basename(file);
98
- if (!safeName || !safeName.toLowerCase().endsWith('.pdf')) {
99
- return helpers.formatApiResponse(400, res, new Error('Invalid file'));
100
- }
101
-
102
- // Premium check disabled for testing — everyone gets full PDF
103
- const isPremium = true;
104
-
105
- const nonce = nonceStore.generate(req.uid, safeName, isPremium);
106
-
107
- helpers.formatApiResponse(200, res, {
108
- nonce: nonce,
109
- isPremium: isPremium,
110
- });
111
- });
104
+ // Nonce endpoint removed - nonce is now generated in viewer route
105
+ // This improves security by not exposing any key-related data in API responses
112
106
  };
113
107
 
114
108
  plugin.addAdminNavigation = (header) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure",
3
- "version": "1.2.3",
3
+ "version": "1.2.5",
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": {
@@ -1,11 +1,54 @@
1
1
  'use strict';
2
2
 
3
- // Main plugin logic - PDF links become inline embedded viewers with lazy loading
3
+ // Main plugin logic - PDF links become inline embedded viewers with lazy loading + queue
4
4
  (async function () {
5
+ // Loading queue - only load one PDF at a time
6
+ const loadQueue = [];
7
+ let isLoading = false;
8
+ let currentResolver = null;
9
+
10
+ // Listen for postMessage from iframe when PDF is fully rendered
11
+ window.addEventListener('message', function (event) {
12
+ if (event.data && event.data.type === 'pdf-secure-ready') {
13
+ console.log('[PDF-Secure] Queue: PDF ready -', event.data.filename);
14
+ if (currentResolver) {
15
+ currentResolver();
16
+ currentResolver = null;
17
+ }
18
+ }
19
+ });
20
+
21
+ async function processQueue() {
22
+ if (isLoading || loadQueue.length === 0) return;
23
+
24
+ isLoading = true;
25
+ const { wrapper, filename, placeholder } = loadQueue.shift();
26
+
27
+ try {
28
+ await loadPdfIframe(wrapper, filename, placeholder);
29
+ } catch (err) {
30
+ console.error('[PDF-Secure] Load error:', err);
31
+ }
32
+
33
+ isLoading = false;
34
+
35
+ // Small delay between loads
36
+ setTimeout(processQueue, 200);
37
+ }
38
+
39
+ function queuePdfLoad(wrapper, filename, placeholder) {
40
+ loadQueue.push({ wrapper, filename, placeholder });
41
+ processQueue();
42
+ }
43
+
5
44
  try {
6
45
  var hooks = await app.require('hooks');
7
46
 
8
47
  hooks.on('action:ajaxify.end', function () {
48
+ // Clear queue on page change
49
+ loadQueue.length = 0;
50
+ isLoading = false;
51
+ currentResolver = null;
9
52
  interceptPdfLinks();
10
53
  });
11
54
  } catch (err) {
@@ -26,7 +69,7 @@
26
69
  var parts = href.split('/');
27
70
  var filename = parts[parts.length - 1];
28
71
 
29
- // Create container with loading placeholder
72
+ // Create container
30
73
  var container = document.createElement('div');
31
74
  container.className = 'pdf-secure-embed';
32
75
  container.style.cssText = 'margin:16px 0;border-radius:12px;overflow:hidden;background:#1f1f1f;border:1px solid rgba(255,255,255,0.1);box-shadow:0 4px 20px rgba(0,0,0,0.25);';
@@ -77,15 +120,15 @@
77
120
  iframeWrapper.className = 'pdf-secure-embed-body';
78
121
  iframeWrapper.style.cssText = 'position:relative;width:100%;height:600px;background:#525659;';
79
122
 
80
- // Loading placeholder
123
+ // Loading placeholder - ALWAYS VISIBLE until PDF ready (z-index: 10)
81
124
  var loadingPlaceholder = document.createElement('div');
82
125
  loadingPlaceholder.className = 'pdf-loading-placeholder';
83
- loadingPlaceholder.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#2d2d2d;color:#fff;gap:16px;';
126
+ loadingPlaceholder.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#2d2d2d;color:#fff;gap:16px;z-index:10;transition:opacity 0.3s;';
84
127
  loadingPlaceholder.innerHTML = `
85
- <svg viewBox="0 0 24 24" style="width:48px;height:48px;fill:#0078d4;animation:spin 1s linear infinite;">
86
- <path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/>
128
+ <svg viewBox="0 0 24 24" style="width:48px;height:48px;fill:#555;">
129
+ <path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>
87
130
  </svg>
88
- <div style="font-size:14px;color:#a0a0a0;">PDF Yükleniyor...</div>
131
+ <div class="pdf-loading-text" style="font-size:14px;color:#a0a0a0;">Sırada bekliyor...</div>
89
132
  <style>@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}</style>
90
133
  `;
91
134
  iframeWrapper.appendChild(loadingPlaceholder);
@@ -103,17 +146,28 @@
103
146
 
104
147
  link.replaceWith(container);
105
148
 
106
- // LAZY LOADING with Intersection Observer
149
+ // LAZY LOADING with Intersection Observer + Queue
107
150
  var observer = new IntersectionObserver(function (entries) {
108
151
  entries.forEach(function (entry) {
109
152
  if (entry.isIntersecting) {
110
- // Load iframe only when visible
111
- loadPdfIframe(iframeWrapper, filename, loadingPlaceholder);
153
+ // Update placeholder to show loading state
154
+ var textEl = loadingPlaceholder.querySelector('.pdf-loading-text');
155
+ if (textEl) textEl.textContent = 'PDF Yükleniyor...';
156
+
157
+ var svgEl = loadingPlaceholder.querySelector('svg');
158
+ if (svgEl) {
159
+ svgEl.style.fill = '#0078d4';
160
+ svgEl.style.animation = 'spin 1s linear infinite';
161
+ svgEl.innerHTML = '<path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/>';
162
+ }
163
+
164
+ // Add to queue
165
+ queuePdfLoad(iframeWrapper, filename, loadingPlaceholder);
112
166
  observer.disconnect();
113
167
  }
114
168
  });
115
169
  }, {
116
- rootMargin: '200px', // Start loading 200px before visible
170
+ rootMargin: '200px',
117
171
  threshold: 0
118
172
  });
119
173
 
@@ -123,21 +177,48 @@
123
177
  }
124
178
 
125
179
  function loadPdfIframe(wrapper, filename, placeholder) {
126
- var iframe = document.createElement('iframe');
127
- iframe.className = 'pdf-secure-iframe';
128
- iframe.style.cssText = 'width:100%;height:100%;border:none;display:block;opacity:0;transition:opacity 0.3s;';
129
- iframe.src = config.relative_path + '/plugins/pdf-secure/viewer?file=' + encodeURIComponent(filename);
130
- iframe.setAttribute('frameborder', '0');
131
- iframe.setAttribute('allowfullscreen', 'true');
132
-
133
- // When iframe loads, fade in and remove placeholder
134
- iframe.onload = function () {
135
- iframe.style.opacity = '1';
136
- if (placeholder && placeholder.parentNode) {
137
- placeholder.remove();
138
- }
139
- };
140
-
141
- wrapper.appendChild(iframe);
180
+ return new Promise((resolve, reject) => {
181
+ // Create iframe HIDDEN (z-index: 1, under placeholder)
182
+ var iframe = document.createElement('iframe');
183
+ iframe.className = 'pdf-secure-iframe';
184
+ iframe.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;border:none;z-index:1;';
185
+ iframe.src = config.relative_path + '/plugins/pdf-secure/viewer?file=' + encodeURIComponent(filename);
186
+ iframe.setAttribute('frameborder', '0');
187
+ iframe.setAttribute('allowfullscreen', 'true');
188
+
189
+ // Store resolver for postMessage callback
190
+ currentResolver = function () {
191
+ // Fade out placeholder, show iframe
192
+ if (placeholder) {
193
+ placeholder.style.opacity = '0';
194
+ setTimeout(function () {
195
+ if (placeholder.parentNode) {
196
+ placeholder.remove();
197
+ }
198
+ }, 300);
199
+ }
200
+ resolve();
201
+ };
202
+
203
+ iframe.onerror = function () {
204
+ currentResolver = null;
205
+ if (placeholder) {
206
+ var textEl = placeholder.querySelector('.pdf-loading-text');
207
+ if (textEl) textEl.textContent = 'Yükleme hatası!';
208
+ }
209
+ reject(new Error('Failed to load iframe'));
210
+ };
211
+
212
+ wrapper.appendChild(iframe);
213
+
214
+ // Timeout fallback (60 seconds for large PDFs)
215
+ setTimeout(function () {
216
+ if (currentResolver) {
217
+ console.log('[PDF-Secure] Queue: Timeout, forcing next');
218
+ currentResolver();
219
+ currentResolver = null;
220
+ }
221
+ }, 60000);
222
+ });
142
223
  }
143
224
  })();