nodebb-plugin-pdf-secure2 1.2.30

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.
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { PDFDocument } = require('pdf-lib');
6
+ const nconf = require.main.require('nconf');
7
+
8
+ const singlePageCache = new Map();
9
+ const pageCountCache = new Map();
10
+ const CACHE_TTL = 60 * 60 * 1000; // 1 hour
11
+
12
+ // Periodic cleanup of expired cache entries
13
+ setInterval(() => {
14
+ const now = Date.now();
15
+ for (const [key, entry] of singlePageCache.entries()) {
16
+ if (now - entry.createdAt > CACHE_TTL) {
17
+ singlePageCache.delete(key);
18
+ }
19
+ }
20
+ for (const [key, entry] of pageCountCache.entries()) {
21
+ if (now - entry.createdAt > CACHE_TTL) {
22
+ pageCountCache.delete(key);
23
+ }
24
+ }
25
+ }, 10 * 60 * 1000).unref(); // cleanup every 10 minutes
26
+
27
+ const PdfHandler = module.exports;
28
+
29
+ PdfHandler.resolveFilePath = function (filename) {
30
+ // Sanitize: only allow basename (prevent directory traversal)
31
+ const safeName = path.basename(filename);
32
+ if (!safeName || safeName !== filename || safeName.includes('..')) {
33
+ return null;
34
+ }
35
+
36
+ const uploadPath = nconf.get('upload_path') || path.join(nconf.get('base_dir'), 'public', 'uploads');
37
+ const filePath = path.join(uploadPath, 'files', safeName);
38
+
39
+ // Verify the resolved path is still within the upload directory
40
+ const resolvedPath = path.resolve(filePath);
41
+ const resolvedUploadDir = path.resolve(path.join(uploadPath, 'files'));
42
+ if (!resolvedPath.startsWith(resolvedUploadDir)) {
43
+ return null;
44
+ }
45
+
46
+ return filePath;
47
+ };
48
+
49
+ PdfHandler.getFullPdf = async function (filename) {
50
+ const filePath = PdfHandler.resolveFilePath(filename);
51
+ if (!filePath) {
52
+ throw new Error('Invalid filename');
53
+ }
54
+
55
+ if (!fs.existsSync(filePath)) {
56
+ throw new Error('File not found');
57
+ }
58
+
59
+ return fs.promises.readFile(filePath);
60
+ };
61
+
62
+ PdfHandler.getSinglePagePdf = async function (filename) {
63
+ // Check cache first
64
+ const cached = singlePageCache.get(filename);
65
+ if (cached && (Date.now() - cached.createdAt < CACHE_TTL)) {
66
+ return cached.buffer;
67
+ }
68
+
69
+ const filePath = PdfHandler.resolveFilePath(filename);
70
+ if (!filePath) {
71
+ throw new Error('Invalid filename');
72
+ }
73
+
74
+ if (!fs.existsSync(filePath)) {
75
+ throw new Error('File not found');
76
+ }
77
+
78
+ const existingPdfBytes = await fs.promises.readFile(filePath);
79
+ const srcDoc = await PDFDocument.load(existingPdfBytes);
80
+
81
+ // Cache page count while we have the document loaded (avoids double read)
82
+ const totalPages = srcDoc.getPageCount();
83
+ pageCountCache.set(filename, { count: totalPages, createdAt: Date.now() });
84
+
85
+ const newDoc = await PDFDocument.create();
86
+ const [copiedPage] = await newDoc.copyPages(srcDoc, [0]);
87
+ newDoc.addPage(copiedPage);
88
+
89
+ const pdfBytes = await newDoc.save();
90
+ const buffer = Buffer.from(pdfBytes);
91
+
92
+ // Cache the result
93
+ singlePageCache.set(filename, {
94
+ buffer: buffer,
95
+ createdAt: Date.now(),
96
+ });
97
+
98
+ return buffer;
99
+ };
100
+
101
+ PdfHandler.getTotalPages = async function (filename) {
102
+ const cached = pageCountCache.get(filename);
103
+ if (cached && (Date.now() - cached.createdAt < CACHE_TTL)) {
104
+ return cached.count;
105
+ }
106
+
107
+ const filePath = PdfHandler.resolveFilePath(filename);
108
+ if (!filePath) {
109
+ throw new Error('Invalid filename');
110
+ }
111
+
112
+ if (!fs.existsSync(filePath)) {
113
+ throw new Error('File not found');
114
+ }
115
+
116
+ const pdfBytes = await fs.promises.readFile(filePath);
117
+ const srcDoc = await PDFDocument.load(pdfBytes);
118
+ const count = srcDoc.getPageCount();
119
+
120
+ pageCountCache.set(filename, { count, createdAt: Date.now() });
121
+ return count;
122
+ };
package/library.js ADDED
@@ -0,0 +1,249 @@
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:; 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;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "nodebb-plugin-pdf-secure2",
3
+ "version": "1.2.30",
4
+ "description": "Secure PDF viewer plugin for NodeBB - prevents downloading, enables canvas-only rendering with Premium group support",
5
+ "main": "library.js",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/nodebb/nodebb-plugin-pdf-secure"
9
+ },
10
+ "scripts": {
11
+ "lint": "eslint ."
12
+ },
13
+ "keywords": [
14
+ "nodebb",
15
+ "plugin",
16
+ "pdf",
17
+ "secure",
18
+ "viewer"
19
+ ],
20
+ "husky": {
21
+ "hooks": {
22
+ "pre-commit": "lint-staged",
23
+ "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
24
+ }
25
+ },
26
+ "lint-staged": {
27
+ "*.js": [
28
+ "eslint --fix",
29
+ "git add"
30
+ ]
31
+ },
32
+ "license": "MIT",
33
+ "bugs": {
34
+ "url": "https://github.com/nodebb/nodebb-plugin-pdf-secure/issues"
35
+ },
36
+ "readmeFilename": "README.md",
37
+ "nbbpm": {
38
+ "compatibility": "^3.2.0"
39
+ },
40
+ "dependencies": {
41
+ "pdf-lib": "^1.17.1",
42
+ "uuid": "^11.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "@commitlint/cli": "20.4.1",
46
+ "@commitlint/config-angular": "20.4.1",
47
+ "eslint": "9.39.2",
48
+ "eslint-config-nodebb": "1.1.11",
49
+ "husky": "9.1.7",
50
+ "lint-staged": "16.2.7",
51
+ "pdfjs-dist": "^4.9.155"
52
+ }
53
+ }
package/plugin.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "id": "nodebb-plugin-pdf-secure",
3
+ "url": "https://github.com/NodeBB/nodebb-plugin-pdf-secure",
4
+ "library": "./library.js",
5
+ "hooks": [
6
+ {
7
+ "hook": "static:app.load",
8
+ "method": "init"
9
+ },
10
+ {
11
+ "hook": "static:api.routes",
12
+ "method": "addRoutes"
13
+ },
14
+ {
15
+ "hook": "filter:admin.header.build",
16
+ "method": "addAdminNavigation"
17
+ },
18
+ {
19
+ "hook": "filter:meta.getMetaTags",
20
+ "method": "filterMetaTags"
21
+ },
22
+ {
23
+ "hook": "filter:parse.post",
24
+ "method": "transformPdfLinks"
25
+ }
26
+ ],
27
+ "staticDirs": {
28
+ "static": "./static"
29
+ },
30
+ "less": [
31
+ "static/style.less"
32
+ ],
33
+ "scripts": [
34
+ "static/lib/main.js"
35
+ ],
36
+ "templates": "./static/templates"
37
+ }
package/renovate.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": [
3
+ "config:recommended"
4
+ ]
5
+ }
Binary file