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.
- package/lib/controllers.js +8 -12
- package/lib/nonce-store.js +24 -2
- package/library.js +44 -50
- package/package.json +1 -1
- package/static/lib/main.js +108 -27
- package/static/viewer.html +1589 -1540
package/lib/controllers.js
CHANGED
|
@@ -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
|
-
|
|
9
|
+
// Now uses dynamic key from nonce data
|
|
10
|
+
function partialXorEncode(buffer, xorKey) {
|
|
13
11
|
const data = Buffer.from(buffer);
|
|
14
|
-
const keyLen =
|
|
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] ^
|
|
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] ^
|
|
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': '
|
|
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);
|
package/lib/nonce-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
89
|
-
|
|
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
package/static/lib/main.js
CHANGED
|
@@ -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
|
|
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:#
|
|
86
|
-
<path d="
|
|
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;">
|
|
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
|
-
//
|
|
111
|
-
|
|
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',
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
placeholder
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
})();
|