nodebb-plugin-pdf-secure 1.2.18 → 1.2.20

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,52 @@
1
+ # Premium Gate Design
2
+
3
+ ## Problem
4
+ All PDFs are currently fully viewable by all users. We need to restrict non-Premium users to viewing only the first page, with a lock overlay prompting them to upgrade.
5
+
6
+ ## Requirements
7
+ - Users in the "Premium" NodeBB group see all PDF pages
8
+ - Users NOT in "Premium" see only the first page
9
+ - Locked pages show a warning overlay with:
10
+ - Lock icon + "Premium required" message
11
+ - Link to forumtest.ieu.app/premium
12
+ - Secondary message about uploading materials to become Premium
13
+ - Admins and Global Moderators always get full access
14
+
15
+ ## Approach: Server-Side Control
16
+
17
+ ### Server Changes
18
+
19
+ **library.js (viewer route, line ~83):**
20
+ - Replace `const isPremium = true;` with actual group membership check
21
+ - Use `groups.isMember(req.uid, 'Premium')`
22
+ - Admin + Global Mod bypass (already exists for direct access, reuse pattern)
23
+ - Inject `isPremium` and `totalPages` into `window.PDF_SECURE_CONFIG`
24
+
25
+ **lib/pdf-handler.js:**
26
+ - Add `getTotalPages(filename)` function
27
+ - Loads PDF, returns `srcDoc.getPageCount()`
28
+ - Used by viewer route to tell client how many pages exist
29
+
30
+ **lib/controllers.js:**
31
+ - No changes needed - already handles `isPremium` flag from nonce data
32
+
33
+ ### Client Changes
34
+
35
+ **static/viewer.html / viewer-app.js:**
36
+ - After PDF loads, if `isPremium === false`:
37
+ - Show only 1 page (server already sends only 1 page)
38
+ - Below the first page, render a lock overlay
39
+ - Overlay content:
40
+ - Lock icon (SVG)
41
+ - "Bu icerigi goruntulemek icin Premium uyelik gereklidir"
42
+ - "Premium Satin Al" button → forumtest.ieu.app/premium
43
+ - "Materyal yukleyerek de Premium olabilirsiniz!" secondary text
44
+
45
+ ### Flow
46
+ 1. User clicks PDF → viewer route called
47
+ 2. Server checks Premium group membership
48
+ 3. isPremium flag set in nonce + injected to viewer HTML
49
+ 4. If not premium: server sends single-page PDF via getSinglePagePdf()
50
+ 5. Client shows the page + lock overlay with upgrade prompt
51
+ 6. If premium: server sends full PDF via getFullPdf()
52
+ 7. Client shows all pages normally
@@ -0,0 +1,323 @@
1
+ # Premium Gate Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Restrict non-Premium users to viewing only the first PDF page, with a lock overlay prompting upgrade.
6
+
7
+ **Architecture:** Server checks user's "Premium" group membership, sends single-page PDF for non-premium users. Client renders a lock overlay below the first page showing upgrade prompts. Admin/Global Moderators always get full access.
8
+
9
+ **Tech Stack:** NodeBB plugin (Node.js), pdf-lib, PDF.js viewer, LESS/CSS
10
+
11
+ ---
12
+
13
+ ### Task 1: Add getTotalPages to pdf-handler
14
+
15
+ **Files:**
16
+ - Modify: `lib/pdf-handler.js`
17
+
18
+ **Step 1: Add getTotalPages function**
19
+
20
+ Add after `getSinglePagePdf` (after line 89):
21
+
22
+ ```javascript
23
+ PdfHandler.getTotalPages = async function (filename) {
24
+ const filePath = PdfHandler.resolveFilePath(filename);
25
+ if (!filePath) {
26
+ throw new Error('Invalid filename');
27
+ }
28
+
29
+ if (!fs.existsSync(filePath)) {
30
+ throw new Error('File not found');
31
+ }
32
+
33
+ const pdfBytes = await fs.promises.readFile(filePath);
34
+ const srcDoc = await PDFDocument.load(pdfBytes);
35
+ return srcDoc.getPageCount();
36
+ };
37
+ ```
38
+
39
+ **Step 2: Commit**
40
+
41
+ ```bash
42
+ git add lib/pdf-handler.js
43
+ git commit -m "feat: add getTotalPages to pdf-handler"
44
+ ```
45
+
46
+ ---
47
+
48
+ ### Task 2: Add Premium group check to viewer route
49
+
50
+ **Files:**
51
+ - Modify: `library.js` (lines 63-119, the viewer route)
52
+
53
+ **Step 1: Replace hardcoded isPremium with group check**
54
+
55
+ In `library.js`, replace line 83:
56
+ ```javascript
57
+ const isPremium = true;
58
+ ```
59
+
60
+ With:
61
+ ```javascript
62
+ // Check if user is Premium (admins/global mods always premium)
63
+ let isPremium = false;
64
+ if (req.uid) {
65
+ const [isAdmin, isGlobalMod, isPremiumMember] = await Promise.all([
66
+ groups.isMember(req.uid, 'administrators'),
67
+ groups.isMember(req.uid, 'Global Moderators'),
68
+ groups.isMember(req.uid, 'Premium'),
69
+ ]);
70
+ isPremium = isAdmin || isGlobalMod || isPremiumMember;
71
+ }
72
+ ```
73
+
74
+ **Step 2: Add totalPages to config injection**
75
+
76
+ In `library.js`, inside the viewer route handler, before the nonce generation, add:
77
+ ```javascript
78
+ // Get total page count for non-premium lock overlay
79
+ const pdfHandler = require('./lib/pdf-handler');
80
+ let totalPages = 1;
81
+ try {
82
+ totalPages = await pdfHandler.getTotalPages(safeName);
83
+ } catch (e) {
84
+ console.error('[PDF-Secure] Failed to get page count:', e.message);
85
+ }
86
+ ```
87
+
88
+ Note: Move the `pdfHandler` require to the top of the file (line 10 area) instead of inline.
89
+
90
+ **Step 3: Inject isPremium and totalPages into viewer config**
91
+
92
+ In the `window.PDF_SECURE_CONFIG` object (around line 108-114), add two new fields:
93
+ ```javascript
94
+ window.PDF_SECURE_CONFIG = {
95
+ filename: ${JSON.stringify(safeName)},
96
+ relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
97
+ csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
98
+ nonce: ${JSON.stringify(nonceData.nonce)},
99
+ dk: ${JSON.stringify(nonceData.xorKey)},
100
+ isPremium: ${JSON.stringify(isPremium)},
101
+ totalPages: ${JSON.stringify(totalPages)}
102
+ };
103
+ ```
104
+
105
+ **Step 4: Make the viewer route handler async**
106
+
107
+ The current route handler at line 64 is a sync callback: `router.get('/plugins/pdf-secure/viewer', (req, res) => {`
108
+
109
+ Change it to async: `router.get('/plugins/pdf-secure/viewer', async (req, res) => {`
110
+
111
+ **Step 5: Commit**
112
+
113
+ ```bash
114
+ git add library.js
115
+ git commit -m "feat: check Premium group membership in viewer route"
116
+ ```
117
+
118
+ ---
119
+
120
+ ### Task 3: Add lock overlay to viewer-app.js
121
+
122
+ **Files:**
123
+ - Modify: `static/viewer-app.js`
124
+
125
+ **Step 1: Add premium lock overlay after PDF loads**
126
+
127
+ In `autoLoadSecurePDF()`, after `await loadPDFFromBuffer(pdfBuffer);` (line 262), add the lock overlay logic. The config is still available at this point (it gets deleted on line 270).
128
+
129
+ Insert before `pdfBuffer = null;` (line 267):
130
+
131
+ ```javascript
132
+ // Premium Gate: Show lock overlay for non-premium users
133
+ if (config.isPremium === false && config.totalPages > 1) {
134
+ showPremiumLockOverlay(config.totalPages);
135
+ }
136
+ ```
137
+
138
+ **Step 2: Add showPremiumLockOverlay function**
139
+
140
+ Add this function inside the IIFE, before `autoLoadSecurePDF()`:
141
+
142
+ ```javascript
143
+ function showPremiumLockOverlay(totalPages) {
144
+ const viewerEl = document.getElementById('viewer');
145
+ if (!viewerEl) return;
146
+
147
+ const overlay = document.createElement('div');
148
+ overlay.id = 'premiumLockOverlay';
149
+ overlay.innerHTML = `
150
+ <div class="premium-lock-icon">
151
+ <svg viewBox="0 0 24 24" width="64" height="64" fill="#ffd700">
152
+ <path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1s3.1 1.39 3.1 3.1v2z"/>
153
+ </svg>
154
+ </div>
155
+ <div class="premium-lock-pages">
156
+ ${totalPages - 1} sayfa daha kilitli
157
+ </div>
158
+ <div class="premium-lock-message">
159
+ Bu icerigi goruntuleye devam etmek icin Premium uyelik gereklidir.
160
+ </div>
161
+ <a href="https://forumtest.ieu.app/premium" target="_blank" class="premium-lock-button">
162
+ Premium Satin Al
163
+ </a>
164
+ <div class="premium-lock-secondary">
165
+ Materyal yukleyerek de Premium olabilirsiniz!
166
+ </div>
167
+ `;
168
+
169
+ viewerEl.appendChild(overlay);
170
+ }
171
+ ```
172
+
173
+ **Step 3: Commit**
174
+
175
+ ```bash
176
+ git add static/viewer-app.js
177
+ git commit -m "feat: add premium lock overlay for non-premium users"
178
+ ```
179
+
180
+ ---
181
+
182
+ ### Task 4: Add lock overlay CSS styles
183
+
184
+ **Files:**
185
+ - Modify: `static/viewer.html` (inline styles section)
186
+
187
+ **Step 1: Add premium lock overlay styles**
188
+
189
+ Add these styles inside the `<style>` tag in viewer.html, before the closing `</style>`:
190
+
191
+ ```css
192
+ /* Premium Lock Overlay */
193
+ #premiumLockOverlay {
194
+ display: flex;
195
+ flex-direction: column;
196
+ align-items: center;
197
+ justify-content: center;
198
+ padding: 60px 20px;
199
+ text-align: center;
200
+ background: linear-gradient(180deg, rgba(31,31,31,0.95) 0%, rgba(20,20,20,0.98) 100%);
201
+ border-top: 2px solid rgba(255, 215, 0, 0.3);
202
+ min-height: 400px;
203
+ margin: 0 auto;
204
+ max-width: 100%;
205
+ }
206
+
207
+ .premium-lock-icon {
208
+ margin-bottom: 20px;
209
+ opacity: 0.9;
210
+ }
211
+
212
+ .premium-lock-pages {
213
+ font-size: 22px;
214
+ font-weight: 600;
215
+ color: #ffd700;
216
+ margin-bottom: 12px;
217
+ }
218
+
219
+ .premium-lock-message {
220
+ font-size: 16px;
221
+ color: #a0a0a0;
222
+ margin-bottom: 28px;
223
+ max-width: 400px;
224
+ line-height: 1.5;
225
+ }
226
+
227
+ .premium-lock-button {
228
+ display: inline-block;
229
+ padding: 14px 40px;
230
+ background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
231
+ color: #1a1a1a;
232
+ font-size: 16px;
233
+ font-weight: 700;
234
+ border-radius: 8px;
235
+ text-decoration: none;
236
+ transition: transform 0.2s, box-shadow 0.2s;
237
+ box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
238
+ }
239
+
240
+ .premium-lock-button:hover {
241
+ transform: translateY(-2px);
242
+ box-shadow: 0 6px 20px rgba(255, 215, 0, 0.4);
243
+ }
244
+
245
+ .premium-lock-secondary {
246
+ margin-top: 20px;
247
+ font-size: 14px;
248
+ color: #888;
249
+ font-style: italic;
250
+ }
251
+
252
+ @media (max-width: 768px) {
253
+ #premiumLockOverlay {
254
+ padding: 40px 16px;
255
+ min-height: 300px;
256
+ }
257
+
258
+ .premium-lock-pages {
259
+ font-size: 18px;
260
+ }
261
+
262
+ .premium-lock-message {
263
+ font-size: 14px;
264
+ }
265
+
266
+ .premium-lock-button {
267
+ padding: 12px 32px;
268
+ font-size: 14px;
269
+ }
270
+ }
271
+ ```
272
+
273
+ **Step 2: Commit**
274
+
275
+ ```bash
276
+ git add static/viewer.html
277
+ git commit -m "feat: add premium lock overlay CSS styles"
278
+ ```
279
+
280
+ ---
281
+
282
+ ### Task 5: Hide sidebar and page count for non-premium users
283
+
284
+ **Files:**
285
+ - Modify: `static/viewer-app.js`
286
+
287
+ **Step 1: Update page count display for non-premium**
288
+
289
+ In the `pagesinit` event handler (around line 360-362), update the page count to show total pages from config instead of the actual loaded pages:
290
+
291
+ Find the `eventBus.on('pagesinit', ...)` handler and wrap the pageCount update:
292
+
293
+ ```javascript
294
+ eventBus.on('pagesinit', () => {
295
+ pdfViewer.currentScaleValue = 'page-width';
296
+ // For non-premium, show "1 / totalPages" instead of "1 / 1"
297
+ const savedConfig = window._pdfSecurePremiumInfo;
298
+ if (savedConfig && !savedConfig.isPremium && savedConfig.totalPages > 1) {
299
+ document.getElementById('pageCount').textContent = `/ ${savedConfig.totalPages}`;
300
+ } else {
301
+ document.getElementById('pageCount').textContent = `/ ${pdfViewer.pagesCount}`;
302
+ }
303
+ });
304
+ ```
305
+
306
+ **Step 2: Save premium info before config is deleted**
307
+
308
+ In `autoLoadSecurePDF()`, right before the config delete line (`delete window.PDF_SECURE_CONFIG`), save the premium info:
309
+
310
+ ```javascript
311
+ // Save premium info for UI (page count display)
312
+ window._pdfSecurePremiumInfo = {
313
+ isPremium: config.isPremium,
314
+ totalPages: config.totalPages
315
+ };
316
+ ```
317
+
318
+ **Step 3: Commit**
319
+
320
+ ```bash
321
+ git add static/viewer-app.js
322
+ git commit -m "feat: show real total page count for non-premium users"
323
+ ```
@@ -6,6 +6,7 @@ const { PDFDocument } = require('pdf-lib');
6
6
  const nconf = require.main.require('nconf');
7
7
 
8
8
  const singlePageCache = new Map();
9
+ const pageCountCache = new Map();
9
10
  const CACHE_TTL = 60 * 60 * 1000; // 1 hour
10
11
 
11
12
  // Periodic cleanup of expired cache entries
@@ -16,6 +17,11 @@ setInterval(() => {
16
17
  singlePageCache.delete(key);
17
18
  }
18
19
  }
20
+ for (const [key, entry] of pageCountCache.entries()) {
21
+ if (now - entry.createdAt > CACHE_TTL) {
22
+ pageCountCache.delete(key);
23
+ }
24
+ }
19
25
  }, 10 * 60 * 1000).unref(); // cleanup every 10 minutes
20
26
 
21
27
  const PdfHandler = module.exports;
@@ -72,6 +78,10 @@ PdfHandler.getSinglePagePdf = async function (filename) {
72
78
  const existingPdfBytes = await fs.promises.readFile(filePath);
73
79
  const srcDoc = await PDFDocument.load(existingPdfBytes);
74
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
+
75
85
  const newDoc = await PDFDocument.create();
76
86
  const [copiedPage] = await newDoc.copyPages(srcDoc, [0]);
77
87
  newDoc.addPage(copiedPage);
@@ -87,3 +97,26 @@ PdfHandler.getSinglePagePdf = async function (filename) {
87
97
 
88
98
  return buffer;
89
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 CHANGED
@@ -61,7 +61,7 @@ plugin.init = async (params) => {
61
61
  routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
62
62
 
63
63
  // Viewer page route (fullscreen Mozilla PDF.js viewer, guests allowed)
64
- router.get('/plugins/pdf-secure/viewer', (req, res) => {
64
+ router.get('/plugins/pdf-secure/viewer', async (req, res) => {
65
65
  const { file } = req.query;
66
66
  if (!file) {
67
67
  return res.status(400).send('Missing file parameter');
@@ -78,10 +78,20 @@ plugin.init = async (params) => {
78
78
  return res.status(500).send('Viewer not available');
79
79
  }
80
80
 
81
- // Generate nonce + key HERE (in viewer route)
82
- // This way the key is ONLY embedded in HTML, never in a separate API response
83
- const isPremium = true;
84
- const nonceData = nonceStore.generate(req.uid || 0, safeName, isPremium);
81
+ // Check if user is Premium (admins/global mods always premium)
82
+ let isPremium = false;
83
+ if (req.uid) {
84
+ const [isAdmin, isGlobalMod, isPremiumMember] = await Promise.all([
85
+ groups.isMember(req.uid, 'administrators'),
86
+ groups.isMember(req.uid, 'Global Moderators'),
87
+ groups.isMember(req.uid, 'Premium'),
88
+ ]);
89
+ isPremium = isAdmin || isGlobalMod || isPremiumMember;
90
+ }
91
+
92
+ // Always send full PDF (client-side premium gating)
93
+ // isPremium flag is sent to client for UI control only
94
+ const nonceData = nonceStore.generate(req.uid || 0, safeName, true);
85
95
 
86
96
  // Serve the viewer template with comprehensive security headers
87
97
  res.set({
@@ -110,7 +120,8 @@ plugin.init = async (params) => {
110
120
  relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
111
121
  csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
112
122
  nonce: ${JSON.stringify(nonceData.nonce)},
113
- dk: ${JSON.stringify(nonceData.xorKey)}
123
+ dk: ${JSON.stringify(nonceData.xorKey)},
124
+ isPremium: ${JSON.stringify(isPremium)}
114
125
  };
115
126
  </script>
116
127
  </head>`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure",
3
- "version": "1.2.18",
3
+ "version": "1.2.20",
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": {