nodebb-plugin-pdf-secure 1.0.1

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,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:raw.githubusercontent.com)",
5
+ "WebFetch(domain:github.com)"
6
+ ]
7
+ }
8
+ }
package/.gitattributes ADDED
@@ -0,0 +1,22 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
3
+
4
+ # Custom for Visual Studio
5
+ *.cs diff=csharp
6
+ *.sln merge=union
7
+ *.csproj merge=union
8
+ *.vbproj merge=union
9
+ *.fsproj merge=union
10
+ *.dbproj merge=union
11
+
12
+ # Standard to msysgit
13
+ *.doc diff=astextplain
14
+ *.DOC diff=astextplain
15
+ *.docx diff=astextplain
16
+ *.DOCX diff=astextplain
17
+ *.dot diff=astextplain
18
+ *.DOT diff=astextplain
19
+ *.pdf diff=astextplain
20
+ *.PDF diff=astextplain
21
+ *.rtf diff=astextplain
22
+ *.RTF diff=astextplain
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 NodeBB Inc. <sales@nodebb.org>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # Quickstart Plugin for NodeBB
2
+
3
+ A starter kit for quickly creating NodeBB plugins. Comes with a pre-setup SCSS file, server side JS script with an `static:app.load` hook, and a client-side script. Most plugins need at least one of the above, so this ought to save you some time. For a full list of hooks have a look at our [wiki page](https://github.com/NodeBB/NodeBB/wiki/Hooks), and for more information about creating plugins please visit our [documentation portal](https://docs.nodebb.org/).
4
+
5
+ Fork this or copy it, and using your favourite text editor find and replace all instances of `nodebb-plugin-quickstart` with `nodebb-plugin-your-plugins-name`. Change the author's name in the LICENSE and package.json files.
6
+
7
+ ## Hello World
8
+
9
+ Really simple, just edit `public/lib/main.js` and paste in `console.log('hello world');`, and that's it!
10
+
11
+ ## Installation
12
+
13
+ npm install nodebb-plugin-quickstart
14
+
15
+ ## Screenshots
16
+
17
+ Don't forget to add screenshots!
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ extends: ['@commitlint/config-angular'],
5
+ rules: {
6
+ 'header-max-length': [1, 'always', 72],
7
+ 'type-enum': [
8
+ 2,
9
+ 'always',
10
+ [
11
+ 'breaking',
12
+ 'build',
13
+ 'chore',
14
+ 'ci',
15
+ 'docs',
16
+ 'feat',
17
+ 'fix',
18
+ 'perf',
19
+ 'refactor',
20
+ 'revert',
21
+ 'style',
22
+ 'test',
23
+ ],
24
+ ],
25
+ },
26
+ };
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ import serverConfig from 'eslint-config-nodebb';
4
+ import publicConfig from 'eslint-config-nodebb/public';
5
+
6
+ export default [
7
+ ...publicConfig,
8
+ ...serverConfig,
9
+ ];
10
+
@@ -0,0 +1,7 @@
1
+ {
2
+ "view-pdf": "PDF anzeigen",
3
+ "premium-upgrade": "Upgrade auf Premium, um das vollständige Dokument anzuzeigen.",
4
+ "loading": "PDF wird geladen...",
5
+ "access-denied": "Zugriff verweigert",
6
+ "login-required": "Sie müssen angemeldet sein, um diese PDF anzuzeigen."
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "view-pdf": "View PDF",
3
+ "premium-upgrade": "Upgrade to Premium to view the full document.",
4
+ "loading": "Loading PDF...",
5
+ "access-denied": "Access denied",
6
+ "login-required": "You must be logged in to view this PDF."
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "view-pdf": "View PDF",
3
+ "premium-upgrade": "Upgrade to Premium to view the full document.",
4
+ "loading": "Loading PDF...",
5
+ "access-denied": "Access denied",
6
+ "login-required": "You must be logged in to view this PDF."
7
+ }
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const nonceStore = require('./nonce-store');
4
+ const pdfHandler = require('./pdf-handler');
5
+
6
+ const Controllers = module.exports;
7
+
8
+ Controllers.renderAdminPage = function (req, res) {
9
+ res.render('admin/plugins/pdf-secure', {
10
+ title: 'PDF Secure Viewer',
11
+ });
12
+ };
13
+
14
+ Controllers.servePdfBinary = async function (req, res) {
15
+ const { nonce } = req.query;
16
+ if (!nonce) {
17
+ return res.status(400).json({ error: 'Missing nonce' });
18
+ }
19
+
20
+ if (!req.uid) {
21
+ return res.status(401).json({ error: 'Not authenticated' });
22
+ }
23
+
24
+ const data = nonceStore.validate(nonce, req.uid);
25
+ if (!data) {
26
+ return res.status(403).json({ error: 'Invalid or expired nonce' });
27
+ }
28
+
29
+ try {
30
+ let pdfBuffer;
31
+ if (data.isPremium) {
32
+ pdfBuffer = await pdfHandler.getFullPdf(data.file);
33
+ } else {
34
+ pdfBuffer = await pdfHandler.getSinglePagePdf(data.file);
35
+ }
36
+
37
+ res.set({
38
+ 'Content-Type': 'application/octet-stream',
39
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private',
40
+ 'X-Content-Type-Options': 'nosniff',
41
+ 'Content-Disposition': 'inline',
42
+ });
43
+
44
+ return res.send(pdfBuffer);
45
+ } catch (err) {
46
+ if (err.message === 'File not found') {
47
+ return res.status(404).json({ error: 'PDF not found' });
48
+ }
49
+ return res.status(500).json({ error: 'Internal error' });
50
+ }
51
+ };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const { v4: uuidv4 } = require('uuid');
4
+
5
+ const store = new Map();
6
+ const NONCE_TTL = 30 * 1000; // 30 seconds
7
+ const CLEANUP_INTERVAL = 60 * 1000; // 60 seconds
8
+
9
+ // Periodic cleanup of expired nonces
10
+ setInterval(() => {
11
+ const now = Date.now();
12
+ for (const [nonce, data] of store.entries()) {
13
+ if (now - data.createdAt > NONCE_TTL) {
14
+ store.delete(nonce);
15
+ }
16
+ }
17
+ }, CLEANUP_INTERVAL).unref();
18
+
19
+ const NonceStore = module.exports;
20
+
21
+ NonceStore.generate = function (uid, file, isPremium) {
22
+ const nonce = uuidv4();
23
+ store.set(nonce, {
24
+ uid: uid,
25
+ file: file,
26
+ isPremium: isPremium,
27
+ createdAt: Date.now(),
28
+ });
29
+ return nonce;
30
+ };
31
+
32
+ NonceStore.validate = function (nonce, uid) {
33
+ const data = store.get(nonce);
34
+ if (!data) {
35
+ return null;
36
+ }
37
+
38
+ // Delete immediately (single-use)
39
+ store.delete(nonce);
40
+
41
+ // Check UID match
42
+ if (data.uid !== uid) {
43
+ return null;
44
+ }
45
+
46
+ // Check TTL
47
+ if (Date.now() - data.createdAt > NONCE_TTL) {
48
+ return null;
49
+ }
50
+
51
+ return data;
52
+ };
@@ -0,0 +1,89 @@
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 CACHE_TTL = 60 * 60 * 1000; // 1 hour
10
+
11
+ // Periodic cleanup of expired cache entries
12
+ setInterval(() => {
13
+ const now = Date.now();
14
+ for (const [key, entry] of singlePageCache.entries()) {
15
+ if (now - entry.createdAt > CACHE_TTL) {
16
+ singlePageCache.delete(key);
17
+ }
18
+ }
19
+ }, 10 * 60 * 1000).unref(); // cleanup every 10 minutes
20
+
21
+ const PdfHandler = module.exports;
22
+
23
+ PdfHandler.resolveFilePath = function (filename) {
24
+ // Sanitize: only allow basename (prevent directory traversal)
25
+ const safeName = path.basename(filename);
26
+ if (!safeName || safeName !== filename || safeName.includes('..')) {
27
+ return null;
28
+ }
29
+
30
+ const uploadPath = nconf.get('upload_path') || path.join(nconf.get('base_dir'), 'public', 'uploads');
31
+ const filePath = path.join(uploadPath, 'files', safeName);
32
+
33
+ // Verify the resolved path is still within the upload directory
34
+ const resolvedPath = path.resolve(filePath);
35
+ const resolvedUploadDir = path.resolve(path.join(uploadPath, 'files'));
36
+ if (!resolvedPath.startsWith(resolvedUploadDir)) {
37
+ return null;
38
+ }
39
+
40
+ return filePath;
41
+ };
42
+
43
+ PdfHandler.getFullPdf = async function (filename) {
44
+ const filePath = PdfHandler.resolveFilePath(filename);
45
+ if (!filePath) {
46
+ throw new Error('Invalid filename');
47
+ }
48
+
49
+ if (!fs.existsSync(filePath)) {
50
+ throw new Error('File not found');
51
+ }
52
+
53
+ return fs.promises.readFile(filePath);
54
+ };
55
+
56
+ PdfHandler.getSinglePagePdf = async function (filename) {
57
+ // Check cache first
58
+ const cached = singlePageCache.get(filename);
59
+ if (cached && (Date.now() - cached.createdAt < CACHE_TTL)) {
60
+ return cached.buffer;
61
+ }
62
+
63
+ const filePath = PdfHandler.resolveFilePath(filename);
64
+ if (!filePath) {
65
+ throw new Error('Invalid filename');
66
+ }
67
+
68
+ if (!fs.existsSync(filePath)) {
69
+ throw new Error('File not found');
70
+ }
71
+
72
+ const existingPdfBytes = await fs.promises.readFile(filePath);
73
+ const srcDoc = await PDFDocument.load(existingPdfBytes);
74
+
75
+ const newDoc = await PDFDocument.create();
76
+ const [copiedPage] = await newDoc.copyPages(srcDoc, [0]);
77
+ newDoc.addPage(copiedPage);
78
+
79
+ const pdfBytes = await newDoc.save();
80
+ const buffer = Buffer.from(pdfBytes);
81
+
82
+ // Cache the result
83
+ singlePageCache.set(filename, {
84
+ buffer: buffer,
85
+ createdAt: Date.now(),
86
+ });
87
+
88
+ return buffer;
89
+ };
package/library.js ADDED
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const meta = require.main.require('./src/meta');
4
+ const groups = require.main.require('./src/groups');
5
+ const routeHelpers = require.main.require('./src/routes/helpers');
6
+
7
+ const controllers = require('./lib/controllers');
8
+ const nonceStore = require('./lib/nonce-store');
9
+
10
+ const plugin = {};
11
+
12
+ plugin.init = async (params) => {
13
+ const { router, middleware } = params;
14
+
15
+ // PDF direct access blocker middleware
16
+ // Intercepts requests to uploaded PDF files and returns 403
17
+ router.get('/assets/uploads/files/:filename', (req, res, next) => {
18
+ if (req.params.filename && req.params.filename.toLowerCase().endsWith('.pdf')) {
19
+ return res.status(403).json({ error: 'Direct PDF access is not allowed. Use the secure viewer.' });
20
+ }
21
+ next();
22
+ });
23
+
24
+ // PDF binary endpoint (nonce-validated)
25
+ router.get('/api/v3/plugins/pdf-secure/pdf-data', middleware.ensureLoggedIn, controllers.servePdfBinary);
26
+
27
+ // Admin page route
28
+ routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
29
+ };
30
+
31
+ plugin.addRoutes = async ({ router, middleware, helpers }) => {
32
+ // Nonce generation endpoint
33
+ routeHelpers.setupApiRoute(router, 'get', '/pdf-secure/nonce', [middleware.ensureLoggedIn], async (req, res) => {
34
+ const { file } = req.query;
35
+ if (!file) {
36
+ return helpers.formatApiResponse(400, res, new Error('Missing file parameter'));
37
+ }
38
+
39
+ // Sanitize filename
40
+ const path = require('path');
41
+ const safeName = path.basename(file);
42
+ if (!safeName || !safeName.toLowerCase().endsWith('.pdf')) {
43
+ return helpers.formatApiResponse(400, res, new Error('Invalid file'));
44
+ }
45
+
46
+ // Get premium group name from settings (default: 'Premium')
47
+ const settings = await meta.settings.get('pdf-secure');
48
+ const premiumGroup = settings.premiumGroup || 'Premium';
49
+
50
+ // Check if user is in premium group
51
+ const isPremium = await groups.isMember(req.uid, premiumGroup);
52
+
53
+ const nonce = nonceStore.generate(req.uid, safeName, isPremium);
54
+
55
+ helpers.formatApiResponse(200, res, {
56
+ nonce: nonce,
57
+ isPremium: isPremium,
58
+ });
59
+ });
60
+ };
61
+
62
+ plugin.addAdminNavigation = (header) => {
63
+ header.plugins.push({
64
+ route: '/plugins/pdf-secure',
65
+ icon: 'fa-file-pdf-o',
66
+ name: 'PDF Secure Viewer',
67
+ });
68
+
69
+ return header;
70
+ };
71
+
72
+ module.exports = plugin;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "nodebb-plugin-pdf-secure",
3
+ "version": "1.0.1",
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,20 @@
1
+ {
2
+ "id": "nodebb-plugin-pdf-secure",
3
+ "url": "https://github.com/NodeBB/nodebb-plugin-pdf-secure",
4
+ "library": "./library.js",
5
+ "hooks": [
6
+ { "hook": "static:app.load", "method": "init" },
7
+ { "hook": "static:api.routes", "method": "addRoutes" },
8
+ { "hook": "filter:admin.header.build", "method": "addAdminNavigation" }
9
+ ],
10
+ "staticDirs": {
11
+ "static": "./static"
12
+ },
13
+ "less": [
14
+ "static/style.less"
15
+ ],
16
+ "scripts": [
17
+ "static/lib/main.js"
18
+ ],
19
+ "templates": "./static/templates"
20
+ }
package/renovate.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": [
3
+ "config:recommended"
4
+ ]
5
+ }
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ (async () => {
4
+ const hooks = await app.require('hooks');
5
+
6
+ hooks.on('action:ajaxify.end', () => {
7
+ interceptPdfLinks();
8
+ });
9
+
10
+ function interceptPdfLinks() {
11
+ const postContents = document.querySelectorAll('[component="post/content"]');
12
+ postContents.forEach((content) => {
13
+ const pdfLinks = content.querySelectorAll('a[href$=".pdf"], a[href$=".PDF"]');
14
+ pdfLinks.forEach((link) => {
15
+ // Skip already processed links
16
+ if (link.dataset.pdfSecure) return;
17
+ link.dataset.pdfSecure = 'true';
18
+
19
+ // Extract filename from href
20
+ const href = link.getAttribute('href');
21
+ const parts = href.split('/');
22
+ const filename = parts[parts.length - 1];
23
+
24
+ // Create button replacement
25
+ const btn = document.createElement('button');
26
+ btn.type = 'button';
27
+ btn.className = 'btn btn-sm btn-outline-primary pdf-secure-btn';
28
+ btn.innerHTML = '<i class="fa fa-file-pdf-o"></i> ' + (link.textContent || filename);
29
+ btn.title = 'View PDF securely';
30
+ btn.dataset.filename = filename;
31
+
32
+ btn.addEventListener('click', (e) => {
33
+ e.preventDefault();
34
+ e.stopPropagation();
35
+ openSecureViewer(filename);
36
+ });
37
+
38
+ link.replaceWith(btn);
39
+ });
40
+ });
41
+ }
42
+
43
+ async function openSecureViewer(filename) {
44
+ try {
45
+ // Request nonce from server
46
+ const response = await fetch(
47
+ `${config.relative_path}/api/v3/plugins/pdf-secure/nonce?file=${encodeURIComponent(filename)}`,
48
+ {
49
+ credentials: 'same-origin',
50
+ headers: {
51
+ 'x-csrf-token': config.csrf_token,
52
+ },
53
+ }
54
+ );
55
+
56
+ if (!response.ok) {
57
+ if (response.status === 401) {
58
+ app.alertError('You must be logged in to view PDFs.');
59
+ return;
60
+ }
61
+ app.alertError('Failed to load PDF viewer.');
62
+ return;
63
+ }
64
+
65
+ const result = await response.json();
66
+ const { nonce, isPremium } = result.response;
67
+
68
+ // Create overlay with iframe
69
+ const overlay = document.createElement('div');
70
+ overlay.className = 'pdf-secure-overlay';
71
+ overlay.id = 'pdf-secure-overlay';
72
+
73
+ const iframe = document.createElement('iframe');
74
+ const viewerUrl = `${config.relative_path}/plugins/nodebb-plugin-pdf-secure/static/lib/viewer.html?nonce=${encodeURIComponent(nonce)}&premium=${isPremium}&apiBase=${encodeURIComponent(config.relative_path)}`;
75
+ iframe.src = viewerUrl;
76
+ iframe.className = 'pdf-secure-iframe';
77
+ iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
78
+
79
+ overlay.appendChild(iframe);
80
+ document.body.appendChild(overlay);
81
+
82
+ // Listen for close message from viewer
83
+ function onMessage(e) {
84
+ if (e.data && e.data.type === 'pdf-viewer-close') {
85
+ closeOverlay();
86
+ }
87
+ }
88
+ window.addEventListener('message', onMessage);
89
+
90
+ // Close on Escape key
91
+ function onKeydown(e) {
92
+ if (e.key === 'Escape') {
93
+ closeOverlay();
94
+ }
95
+ }
96
+ document.addEventListener('keydown', onKeydown);
97
+
98
+ function closeOverlay() {
99
+ window.removeEventListener('message', onMessage);
100
+ document.removeEventListener('keydown', onKeydown);
101
+ const el = document.getElementById('pdf-secure-overlay');
102
+ if (el) el.remove();
103
+ }
104
+ } catch (err) {
105
+ app.alertError('Failed to open PDF viewer.');
106
+ }
107
+ }
108
+ })();