nodebb-plugin-pdf-secure2 1.2.35 → 1.2.37

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/library.js CHANGED
@@ -1,249 +1,246 @@
1
- 'use strict';
2
-
3
- const path = require('path');
4
- const fs = require('fs');
5
- const meta = require.main.require('./src/meta');
6
- const groups = require.main.require('./src/groups');
7
- const routeHelpers = require.main.require('./src/routes/helpers');
8
-
9
- const controllers = require('./lib/controllers');
10
- const nonceStore = require('./lib/nonce-store');
11
- const pdfHandler = require('./lib/pdf-handler');
12
-
13
- const plugin = {};
14
-
15
- // Memory cache for viewer.html
16
- let viewerHtmlCache = null;
17
-
18
- plugin.init = async (params) => {
19
- const { router, middleware } = params;
20
-
21
- // Pre-load viewer.html into memory cache
22
- const viewerPath = path.join(__dirname, 'static', 'viewer.html');
23
- try {
24
- viewerHtmlCache = fs.readFileSync(viewerPath, 'utf8');
25
- console.log('[PDF-Secure] Viewer template cached in memory');
26
- } catch (err) {
27
- console.error('[PDF-Secure] Failed to cache viewer template:', err.message);
28
- }
29
-
30
- // Double slash bypass protection - catches /uploads//files/ attempts
31
- router.use((req, res, next) => {
32
- if (req.path.includes('//') && req.path.toLowerCase().includes('.pdf')) {
33
- return res.status(403).json({ error: 'Invalid path' });
34
- }
35
- next();
36
- });
37
-
38
- // PDF direct access blocker middleware
39
- // Intercepts requests to uploaded PDF files and returns 403
40
- // Admin and Global Moderators can bypass this restriction
41
- router.get('/assets/uploads/files/:filename', async (req, res, next) => {
42
- if (req.params.filename && req.params.filename.toLowerCase().endsWith('.pdf')) {
43
- // Admin ve Global Mod'lar direkt erişebilsin
44
- if (req.uid) {
45
- const [isAdmin, isGlobalMod, isVip] = await Promise.all([
46
- groups.isMember(req.uid, 'administrators'),
47
- groups.isMember(req.uid, 'Global Moderators'),
48
- groups.isMember(req.uid, 'VIP'),
49
- ]);
50
- if (isAdmin || isGlobalMod || isVip) {
51
- return next();
52
- }
53
- }
54
- return res.status(403).json({ error: 'Direct PDF access is not allowed. Use the secure viewer.' });
55
- }
56
- next();
57
- });
58
-
59
- // PDF binary endpoint (nonce-validated, authentication required)
60
- router.get('/api/v3/plugins/pdf-secure/pdf-data', controllers.servePdfBinary);
61
-
62
- // Admin page route
63
- routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
64
-
65
- // Viewer page route (fullscreen Mozilla PDF.js viewer, authentication required)
66
- router.get('/plugins/pdf-secure/viewer', async (req, res) => {
67
- // Authentication gate - require logged-in user
68
- if (!req.uid) {
69
- return res.status(401).json({ error: 'Authentication required' });
70
- }
71
-
72
- const { file } = req.query;
73
- if (!file) {
74
- return res.status(400).send('Missing file parameter');
75
- }
76
-
77
- // Sanitize filename
78
- const safeName = path.basename(file);
79
- if (!safeName || !safeName.toLowerCase().endsWith('.pdf')) {
80
- return res.status(400).send('Invalid file');
81
- }
82
-
83
- // Check cache
84
- if (!viewerHtmlCache) {
85
- return res.status(500).send('Viewer not available');
86
- }
87
-
88
- // Check if user is Premium or Lite (admins/global mods always premium)
89
- let isPremium = false;
90
- let isLite = false;
91
- if (req.uid) {
92
- const [isAdmin, isGlobalMod, isPremiumMember, isVipMember, isLiteMember] = await Promise.all([
93
- groups.isMember(req.uid, 'administrators'),
94
- groups.isMember(req.uid, 'Global Moderators'),
95
- groups.isMember(req.uid, 'Premium'),
96
- groups.isMember(req.uid, 'VIP'),
97
- groups.isMember(req.uid, 'Lite'),
98
- ]);
99
- isPremium = isAdmin || isGlobalMod || isPremiumMember || isVipMember;
100
- // Lite: full PDF access but restricted UI (no annotations, sidebar, etc.)
101
- isLite = !isPremium && isLiteMember;
102
- }
103
-
104
- // Lite users get full PDF like premium (for nonce/server-side PDF data)
105
- const hasFullAccess = isPremium || isLite;
106
- const nonceData = nonceStore.generate(req.uid, safeName, hasFullAccess);
107
-
108
- // For non-premium users, get total page count so client can show lock overlay
109
- let totalPages = 1;
110
- if (!hasFullAccess) {
111
- try {
112
- totalPages = await pdfHandler.getTotalPages(safeName);
113
- } catch (err) {
114
- console.error('[PDF-Secure] Failed to get page count:', err.message);
115
- }
116
- }
117
-
118
- // Serve the viewer template with comprehensive security headers
119
- res.set({
120
- 'X-Frame-Options': 'SAMEORIGIN',
121
- 'X-Content-Type-Options': 'nosniff',
122
- 'X-XSS-Protection': '1; mode=block',
123
- 'Cache-Control': 'no-store, no-cache, must-revalidate, private, max-age=0',
124
- 'Pragma': 'no-cache',
125
- 'Expires': '0',
126
- 'Referrer-Policy': 'no-referrer',
127
- 'Permissions-Policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
128
- '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: https://i.ibb.co; connect-src 'self'; frame-ancestors 'self'",
129
- });
130
-
131
- // Inject the filename, nonce, and key into the cached viewer
132
- // Key is embedded in HTML - NOT visible in any network API response!
133
- const injectedHtml = viewerHtmlCache
134
- .replace('</head>', `
135
- <style>
136
- /* Hide upload overlay since PDF will auto-load */
137
- #uploadOverlay { display: none !important; }
138
- </style>
139
- <script>
140
- window.PDF_SECURE_CONFIG = {
141
- filename: ${JSON.stringify(safeName)},
142
- relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
143
- csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
144
- nonce: ${JSON.stringify(nonceData.nonce)},
145
- dk: ${JSON.stringify(nonceData.dk)},
146
- iv: ${JSON.stringify(nonceData.iv)},
147
- isPremium: ${JSON.stringify(isPremium)},
148
- isLite: ${JSON.stringify(isLite)},
149
- uid: ${JSON.stringify(req.uid)},
150
- totalPages: ${JSON.stringify(totalPages)}
151
- };
152
- </script>
153
- </head>`);
154
-
155
- res.type('html').send(injectedHtml);
156
- });
157
- };
158
-
159
- plugin.addRoutes = async ({ router, middleware, helpers }) => {
160
- // Nonce endpoint removed - nonce is now generated in viewer route
161
- // This improves security by not exposing any key-related data in API responses
162
- };
163
-
164
- plugin.addAdminNavigation = (header) => {
165
- header.plugins.push({
166
- route: '/plugins/pdf-secure',
167
- icon: 'fa-file-pdf-o',
168
- name: 'PDF Secure Viewer',
169
- });
170
-
171
- return header;
172
- };
173
-
174
- // Filter meta tags to hide PDF URLs and filenames
175
- plugin.filterMetaTags = async (hookData) => {
176
- if (!hookData || !hookData.tags) {
177
- return hookData;
178
- }
179
-
180
- // Admin/Global Moderator bypass - no filtering for privileged users
181
- if (hookData.req && hookData.req.uid) {
182
- const [isAdmin, isGlobalMod] = await Promise.all([
183
- groups.isMember(hookData.req.uid, 'administrators'),
184
- groups.isMember(hookData.req.uid, 'Global Moderators'),
185
- ]);
186
- if (isAdmin || isGlobalMod) {
187
- return hookData;
188
- }
189
- }
190
-
191
- // Filter out PDF-related meta tags
192
- hookData.tags = hookData.tags.filter(tag => {
193
- // Remove og:image and og:image:url if it contains .pdf
194
- if ((tag.property === 'og:image' || tag.property === 'og:image:url') && tag.content && tag.content.toLowerCase().includes('.pdf')) {
195
- return false;
196
- }
197
- // Remove twitter:image if it contains .pdf
198
- if (tag.name === 'twitter:image' && tag.content && tag.content.toLowerCase().includes('.pdf')) {
199
- return false;
200
- }
201
- return true;
202
- });
203
-
204
- // Sanitize description to hide .pdf extensions
205
- hookData.tags = hookData.tags.map(tag => {
206
- if ((tag.name === 'description' || tag.property === 'og:description') && tag.content) {
207
- // Replace .pdf extension with empty string in description
208
- tag.content = tag.content.replace(/\.pdf/gi, '');
209
- }
210
- return tag;
211
- });
212
-
213
- return hookData;
214
- };
215
-
216
- // Transform PDF links to secure placeholders (server-side)
217
- // This hides PDF URLs from: page source, API, RSS, ActivityPub
218
- plugin.transformPdfLinks = async (data) => {
219
- if (!data || !data.postData || !data.postData.content) {
220
- return data;
221
- }
222
-
223
- // Regex to match PDF links: <a href="...xxx.pdf">text</a>
224
- // Captures: full URL path, filename, link text
225
- const pdfLinkRegex = /<a\s+[^>]*href=["']([^"']*\/([^"'\/]+\.pdf))["'][^>]*>([^<]*)<\/a>/gi;
226
-
227
- data.postData.content = data.postData.content.replace(pdfLinkRegex, (match, fullPath, filename, linkText) => {
228
- // Decode filename to prevent double encoding (URL may already be encoded)
229
- let decodedFilename;
230
- try { decodedFilename = decodeURIComponent(filename); }
231
- catch (e) { decodedFilename = filename; }
232
-
233
- // Sanitize for HTML attribute
234
- const safeFilename = decodedFilename.replace(/[<>"'&]/g, '');
235
- const displayName = linkText.trim() || safeFilename;
236
-
237
- // Return secure placeholder div instead of actual link
238
- return `<div class="pdf-secure-placeholder" data-filename="${safeFilename}">
239
- <svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:#e81224;vertical-align:middle;margin-right:8px;">
240
- <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"/>
241
- </svg>
242
- <span>${displayName}</span>
243
- </div>`;
244
- });
245
-
246
- return data;
247
- };
248
-
249
- module.exports = plugin;
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const meta = require.main.require('./src/meta');
6
+ const groups = require.main.require('./src/groups');
7
+ const routeHelpers = require.main.require('./src/routes/helpers');
8
+
9
+ const controllers = require('./lib/controllers');
10
+ const nonceStore = require('./lib/nonce-store');
11
+ const pdfHandler = require('./lib/pdf-handler');
12
+
13
+ const plugin = {};
14
+
15
+ // Memory cache for viewer.html
16
+ let viewerHtmlCache = null;
17
+
18
+ plugin.init = async (params) => {
19
+ const { router, middleware } = params;
20
+
21
+ // Pre-load viewer.html into memory cache
22
+ const viewerPath = path.join(__dirname, 'static', 'viewer.html');
23
+ try {
24
+ viewerHtmlCache = fs.readFileSync(viewerPath, 'utf8');
25
+ } catch (err) {
26
+ }
27
+
28
+ // Double slash bypass protection - catches /uploads//files/ attempts
29
+ router.use((req, res, next) => {
30
+ if (req.path.includes('//') && req.path.toLowerCase().includes('.pdf')) {
31
+ return res.status(403).json({ error: 'Invalid path' });
32
+ }
33
+ next();
34
+ });
35
+
36
+ // PDF direct access blocker middleware
37
+ // Intercepts requests to uploaded PDF files and returns 403
38
+ // Admin and Global Moderators can bypass this restriction
39
+ router.get('/assets/uploads/files/:filename', async (req, res, next) => {
40
+ if (req.params.filename && req.params.filename.toLowerCase().endsWith('.pdf')) {
41
+ // Admin ve Global Mod'lar direkt erişebilsin
42
+ if (req.uid) {
43
+ const [isAdmin, isGlobalMod, isVip] = await Promise.all([
44
+ groups.isMember(req.uid, 'administrators'),
45
+ groups.isMember(req.uid, 'Global Moderators'),
46
+ groups.isMember(req.uid, 'VIP'),
47
+ ]);
48
+ if (isAdmin || isGlobalMod || isVip) {
49
+ return next();
50
+ }
51
+ }
52
+ return res.status(403).json({ error: 'Direct PDF access is not allowed. Use the secure viewer.' });
53
+ }
54
+ next();
55
+ });
56
+
57
+ // PDF binary endpoint (nonce-validated, authentication required)
58
+ router.get('/api/v3/plugins/pdf-secure/pdf-data', controllers.servePdfBinary);
59
+
60
+ // Admin page route
61
+ routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
62
+
63
+ // Viewer page route (fullscreen Mozilla PDF.js viewer, authentication required)
64
+ router.get('/plugins/pdf-secure/viewer', async (req, res) => {
65
+ // Authentication gate - require logged-in user
66
+ if (!req.uid) {
67
+ return res.status(401).json({ error: 'Authentication required' });
68
+ }
69
+
70
+ const { file } = req.query;
71
+ if (!file) {
72
+ return res.status(400).send('Missing file parameter');
73
+ }
74
+
75
+ // Sanitize filename
76
+ const safeName = path.basename(file);
77
+ if (!safeName || !safeName.toLowerCase().endsWith('.pdf')) {
78
+ return res.status(400).send('Invalid file');
79
+ }
80
+
81
+ // Check cache
82
+ if (!viewerHtmlCache) {
83
+ return res.status(500).send('Viewer not available');
84
+ }
85
+
86
+ // Check if user is Premium or Lite (admins/global mods always premium)
87
+ let isPremium = false;
88
+ let isLite = false;
89
+ if (req.uid) {
90
+ const [isAdmin, isGlobalMod, isPremiumMember, isVipMember, isLiteMember] = await Promise.all([
91
+ groups.isMember(req.uid, 'administrators'),
92
+ groups.isMember(req.uid, 'Global Moderators'),
93
+ groups.isMember(req.uid, 'Premium'),
94
+ groups.isMember(req.uid, 'VIP'),
95
+ groups.isMember(req.uid, 'Lite'),
96
+ ]);
97
+ isPremium = isAdmin || isGlobalMod || isPremiumMember || isVipMember;
98
+ // Lite: full PDF access but restricted UI (no annotations, sidebar, etc.)
99
+ isLite = !isPremium && isLiteMember;
100
+ }
101
+
102
+ // Lite users get full PDF like premium (for nonce/server-side PDF data)
103
+ const hasFullAccess = isPremium || isLite;
104
+ const nonceData = nonceStore.generate(req.uid, safeName, hasFullAccess);
105
+
106
+ // For non-premium users, get total page count so client can show lock overlay
107
+ let totalPages = 1;
108
+ if (!hasFullAccess) {
109
+ try {
110
+ totalPages = await pdfHandler.getTotalPages(safeName);
111
+ } catch (err) {
112
+ }
113
+ }
114
+
115
+ // Serve the viewer template with comprehensive security headers
116
+ res.set({
117
+ 'X-Frame-Options': 'SAMEORIGIN',
118
+ 'X-Content-Type-Options': 'nosniff',
119
+ 'X-XSS-Protection': '1; mode=block',
120
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private, max-age=0',
121
+ 'Pragma': 'no-cache',
122
+ 'Expires': '0',
123
+ 'Referrer-Policy': 'no-referrer',
124
+ 'Permissions-Policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
125
+ '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: https://i.ibb.co; connect-src 'self'; frame-ancestors 'self'",
126
+ });
127
+
128
+ // Inject the filename, nonce, and key into the cached viewer
129
+ // Key is embedded in HTML - NOT visible in any network API response!
130
+ const injectedHtml = viewerHtmlCache
131
+ .replace('</head>', `
132
+ <style>
133
+ /* Hide upload overlay since PDF will auto-load */
134
+ #uploadOverlay { display: none !important; }
135
+ </style>
136
+ <script>
137
+ window.PDF_SECURE_CONFIG = {
138
+ filename: ${JSON.stringify(safeName)},
139
+ relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
140
+ csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
141
+ nonce: ${JSON.stringify(nonceData.nonce)},
142
+ dk: ${JSON.stringify(nonceData.dk)},
143
+ iv: ${JSON.stringify(nonceData.iv)},
144
+ isPremium: ${JSON.stringify(isPremium)},
145
+ isLite: ${JSON.stringify(isLite)},
146
+ uid: ${JSON.stringify(req.uid)},
147
+ totalPages: ${JSON.stringify(totalPages)}
148
+ };
149
+ </script>
150
+ </head>`);
151
+
152
+ res.type('html').send(injectedHtml);
153
+ });
154
+ };
155
+
156
+ plugin.addRoutes = async ({ router, middleware, helpers }) => {
157
+ // Nonce endpoint removed - nonce is now generated in viewer route
158
+ // This improves security by not exposing any key-related data in API responses
159
+ };
160
+
161
+ plugin.addAdminNavigation = (header) => {
162
+ header.plugins.push({
163
+ route: '/plugins/pdf-secure',
164
+ icon: 'fa-file-pdf-o',
165
+ name: 'PDF Secure Viewer',
166
+ });
167
+
168
+ return header;
169
+ };
170
+
171
+ // Filter meta tags to hide PDF URLs and filenames
172
+ plugin.filterMetaTags = async (hookData) => {
173
+ if (!hookData || !hookData.tags) {
174
+ return hookData;
175
+ }
176
+
177
+ // Admin/Global Moderator bypass - no filtering for privileged users
178
+ if (hookData.req && hookData.req.uid) {
179
+ const [isAdmin, isGlobalMod] = await Promise.all([
180
+ groups.isMember(hookData.req.uid, 'administrators'),
181
+ groups.isMember(hookData.req.uid, 'Global Moderators'),
182
+ ]);
183
+ if (isAdmin || isGlobalMod) {
184
+ return hookData;
185
+ }
186
+ }
187
+
188
+ // Filter out PDF-related meta tags
189
+ hookData.tags = hookData.tags.filter(tag => {
190
+ // Remove og:image and og:image:url if it contains .pdf
191
+ if ((tag.property === 'og:image' || tag.property === 'og:image:url') && tag.content && tag.content.toLowerCase().includes('.pdf')) {
192
+ return false;
193
+ }
194
+ // Remove twitter:image if it contains .pdf
195
+ if (tag.name === 'twitter:image' && tag.content && tag.content.toLowerCase().includes('.pdf')) {
196
+ return false;
197
+ }
198
+ return true;
199
+ });
200
+
201
+ // Sanitize description to hide .pdf extensions
202
+ hookData.tags = hookData.tags.map(tag => {
203
+ if ((tag.name === 'description' || tag.property === 'og:description') && tag.content) {
204
+ // Replace .pdf extension with empty string in description
205
+ tag.content = tag.content.replace(/\.pdf/gi, '');
206
+ }
207
+ return tag;
208
+ });
209
+
210
+ return hookData;
211
+ };
212
+
213
+ // Transform PDF links to secure placeholders (server-side)
214
+ // This hides PDF URLs from: page source, API, RSS, ActivityPub
215
+ plugin.transformPdfLinks = async (data) => {
216
+ if (!data || !data.postData || !data.postData.content) {
217
+ return data;
218
+ }
219
+
220
+ // Regex to match PDF links: <a href="...xxx.pdf">text</a>
221
+ // Captures: full URL path, filename, link text
222
+ const pdfLinkRegex = /<a\s+[^>]*href=["']([^"']*\/([^"'\/]+\.pdf))["'][^>]*>([^<]*)<\/a>/gi;
223
+
224
+ data.postData.content = data.postData.content.replace(pdfLinkRegex, (match, fullPath, filename, linkText) => {
225
+ // Decode filename to prevent double encoding (URL may already be encoded)
226
+ let decodedFilename;
227
+ try { decodedFilename = decodeURIComponent(filename); }
228
+ catch (e) { decodedFilename = filename; }
229
+
230
+ // Sanitize for HTML attribute
231
+ const safeFilename = decodedFilename.replace(/[<>"'&]/g, '');
232
+ const displayName = linkText.trim() || safeFilename;
233
+
234
+ // Return secure placeholder div instead of actual link
235
+ return `<div class="pdf-secure-placeholder" data-filename="${safeFilename}">
236
+ <svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:#e81224;vertical-align:middle;margin-right:8px;">
237
+ <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"/>
238
+ </svg>
239
+ <span>${displayName}</span>
240
+ </div>`;
241
+ });
242
+
243
+ return data;
244
+ };
245
+
246
+ module.exports = plugin;
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.37",
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": {