nodebb-plugin-pdf-secure2 1.3.9 → 1.4.0

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.
@@ -38,7 +38,6 @@ Controllers.initQuotaSettings = function (settings) {
38
38
  QUOTAS.premium.window = windowMs;
39
39
  QUOTAS.vip.window = windowMs;
40
40
  }
41
- console.log('[PDF-Secure] Quota settings: Premium', QUOTAS.premium.max, 'tokens/', QUOTAS.premium.window / 3600000, 'h, VIP', QUOTAS.vip.max, 'tokens/', QUOTAS.vip.window / 3600000, 'h');
42
41
  };
43
42
 
44
43
  // DB-backed sliding window rate limiter
@@ -180,17 +179,13 @@ Controllers.renderAdminPage = function (req, res) {
180
179
  };
181
180
 
182
181
  Controllers.servePdfBinary = async function (req, res) {
183
- console.log('[PDF-Secure] servePdfBinary called - uid:', req.uid, 'nonce:', req.query.nonce ? 'present' : 'missing');
184
-
185
182
  // Authentication gate - require logged-in user
186
183
  if (!req.uid) {
187
- console.log('[PDF-Secure] servePdfBinary - REJECTED: no uid');
188
184
  return res.status(401).json({ error: 'Authentication required' });
189
185
  }
190
186
 
191
187
  const { nonce } = req.query;
192
188
  if (!nonce) {
193
- console.log('[PDF-Secure] servePdfBinary - REJECTED: no nonce');
194
189
  return res.status(400).json({ error: 'Missing nonce' });
195
190
  }
196
191
 
@@ -198,12 +193,9 @@ Controllers.servePdfBinary = async function (req, res) {
198
193
 
199
194
  const data = nonceStore.validate(nonce, uid);
200
195
  if (!data) {
201
- console.log('[PDF-Secure] servePdfBinary - REJECTED: invalid/expired nonce for uid:', uid);
202
196
  return res.status(403).json({ error: 'Invalid or expired nonce' });
203
197
  }
204
198
 
205
- console.log('[PDF-Secure] servePdfBinary - OK: file:', data.file, 'isPremium:', data.isPremium);
206
-
207
199
  try {
208
200
  // Server-side premium gate: non-premium users only get first page
209
201
  const pdfBuffer = data.isPremium
@@ -300,11 +292,15 @@ Controllers.handleChat = async function (req, res) {
300
292
  if (entry.role !== 'user' && entry.role !== 'model') {
301
293
  return res.status(400).json({ error: 'Invalid history role' });
302
294
  }
303
- if (typeof entry.text !== 'string' || entry.text.length > 4000) {
295
+ if (typeof entry.text !== 'string' || entry.text.length > 12000) {
304
296
  return res.status(400).json({ error: 'Invalid history text' });
305
297
  }
298
+ // Truncate overly long entries (AI responses can be up to ~4096 tokens ≈ 10K chars)
299
+ if (entry.text.length > 10000) {
300
+ entry.text = entry.text.slice(0, 10000);
301
+ }
306
302
  totalHistorySize += entry.text.length;
307
- if (totalHistorySize > 50000) {
303
+ if (totalHistorySize > 150000) {
308
304
  return res.status(400).json({ error: 'History too large' });
309
305
  }
310
306
  // Sanitize: strip null bytes and control characters
@@ -68,6 +68,20 @@ function sanitizeAiOutput(text) {
68
68
  const suggestionsCache = new Map(); // filename -> { suggestions, cachedAt }
69
69
  const SUGGESTIONS_TTL = 30 * 60 * 1000; // 30 minutes
70
70
 
71
+ // Cache for PDF summary responses — most expensive request type (~3000-4000 tokens)
72
+ // Same PDF summary is identical for all users, so cache aggressively
73
+ const summaryCache = new Map(); // filename -> { text, tokensUsed, cachedAt }
74
+ const SUMMARY_TTL = 60 * 60 * 1000; // 1 hour
75
+
76
+ // Patterns that indicate a summary request (Turkish + English)
77
+ const SUMMARY_PATTERNS = [
78
+ /\b(özet|özetle|özetini|özetini çıkar|özetле)\b/i,
79
+ /\b(summary|summarize|summarise|overview)\b/i,
80
+ /\bkapsamlı\b.*\b(özet|liste)\b/i,
81
+ /\bana\s+(noktalar|başlıklar|konular)\b/i,
82
+ /\bmadde\s+madde\b/i,
83
+ ];
84
+
71
85
  // Sanitize history text to prevent injection attacks
72
86
  function sanitizeHistoryText(text) {
73
87
  // Strip null bytes and control characters (except newline, tab)
@@ -80,8 +94,8 @@ function sanitizeHistoryText(text) {
80
94
 
81
95
  // Tier-based configuration
82
96
  const TIER_CONFIG = {
83
- vip: { maxHistory: 30, maxOutputTokens: 4096 },
84
- premium: { maxHistory: 20, maxOutputTokens: 2048 },
97
+ vip: { maxHistory: 30, maxOutputTokens: 4096, recentFullMessages: 6 },
98
+ premium: { maxHistory: 20, maxOutputTokens: 2048, recentFullMessages: 4 },
85
99
  };
86
100
 
87
101
  const MODEL_NAME = 'gemini-2.5-flash';
@@ -99,6 +113,11 @@ const cleanupTimer = setInterval(() => {
99
113
  fileUploadCache.delete(key);
100
114
  }
101
115
  }
116
+ for (const [key, entry] of summaryCache.entries()) {
117
+ if (now - entry.cachedAt > SUMMARY_TTL) {
118
+ summaryCache.delete(key);
119
+ }
120
+ }
102
121
  }, 10 * 60 * 1000);
103
122
  cleanupTimer.unref();
104
123
 
@@ -141,79 +160,32 @@ async function getOrUploadPdf(filename) {
141
160
  }
142
161
 
143
162
  // Upload to Gemini File API — file is stored server-side, only URI is sent per request
144
- console.log('[PDF-Secure] === FILE API UPLOAD START ===');
145
- console.log('[PDF-Secure] Filename:', filename);
146
- console.log('[PDF-Secure] FilePath:', filePath);
147
- console.log('[PDF-Secure] FileSize:', stats.size, 'bytes');
148
- console.log('[PDF-Secure] ai.files exists:', !!ai.files);
149
- console.log('[PDF-Secure] ai.files.upload exists:', typeof (ai.files && ai.files.upload));
150
-
151
163
  const fileBuffer = await fs.promises.readFile(filePath);
152
- console.log('[PDF-Secure] File read OK, buffer size:', fileBuffer.length);
153
164
 
154
- // Try upload with multiple approaches
165
+ // Try upload with multiple approaches (path string → Blob → Buffer)
155
166
  let uploadResult;
156
- const uploadErrors = [];
157
-
158
- // Approach 1: file path string
159
- console.log('[PDF-Secure] Trying approach 1: file path string...');
160
- try {
161
- uploadResult = await ai.files.upload({
162
- file: filePath,
163
- config: { mimeType: 'application/pdf', displayName: filename },
164
- });
165
- console.log('[PDF-Secure] Approach 1 SUCCESS! Result:', JSON.stringify(uploadResult).slice(0, 500));
166
- } catch (err1) {
167
- console.error('[PDF-Secure] Approach 1 FAILED:', err1.message);
168
- console.error('[PDF-Secure] Approach 1 error detail:', err1.status || '', err1.code || '', err1.stack?.split('\n').slice(0, 3).join(' | '));
169
- uploadErrors.push('path: ' + err1.message);
170
- }
171
-
172
- // Approach 2: Blob (Node 18+)
173
- if (!uploadResult) {
174
- console.log('[PDF-Secure] Trying approach 2: Blob...');
175
- console.log('[PDF-Secure] Blob available:', typeof Blob !== 'undefined');
176
- try {
177
- const blob = new Blob([fileBuffer], { type: 'application/pdf' });
178
- uploadResult = await ai.files.upload({
179
- file: blob,
180
- config: { mimeType: 'application/pdf', displayName: filename },
181
- });
182
- console.log('[PDF-Secure] Approach 2 SUCCESS! Result:', JSON.stringify(uploadResult).slice(0, 500));
183
- } catch (err2) {
184
- console.error('[PDF-Secure] Approach 2 FAILED:', err2.message);
185
- uploadErrors.push('blob: ' + err2.message);
186
- }
187
- }
167
+ const methods = [
168
+ () => ai.files.upload({ file: filePath, config: { mimeType: 'application/pdf', displayName: filename } }),
169
+ () => ai.files.upload({ file: new Blob([fileBuffer], { type: 'application/pdf' }), config: { mimeType: 'application/pdf', displayName: filename } }),
170
+ () => ai.files.upload({ file: fileBuffer, config: { mimeType: 'application/pdf', displayName: filename } }),
171
+ ];
188
172
 
189
- // Approach 3: Buffer directly
190
- if (!uploadResult) {
191
- console.log('[PDF-Secure] Trying approach 3: Buffer...');
173
+ for (const method of methods) {
174
+ if (uploadResult) break;
192
175
  try {
193
- uploadResult = await ai.files.upload({
194
- file: fileBuffer,
195
- config: { mimeType: 'application/pdf', displayName: filename },
196
- });
197
- console.log('[PDF-Secure] Approach 3 SUCCESS! Result:', JSON.stringify(uploadResult).slice(0, 500));
198
- } catch (err3) {
199
- console.error('[PDF-Secure] Approach 3 FAILED:', err3.message);
200
- uploadErrors.push('buffer: ' + err3.message);
176
+ uploadResult = await method();
177
+ } catch (err) {
178
+ // Try next method
201
179
  }
202
180
  }
203
181
 
204
182
  if (uploadResult && uploadResult.uri) {
205
- const fileUri = uploadResult.uri;
206
- const mimeType = uploadResult.mimeType || 'application/pdf';
207
- console.log('[PDF-Secure] === UPLOAD FINAL: SUCCESS ===', fileUri);
208
-
209
- fileUploadCache.set(filename, { fileUri, mimeType, cachedAt: Date.now() });
210
- return { type: 'fileData', fileUri, mimeType };
183
+ fileUploadCache.set(filename, { fileUri: uploadResult.uri, mimeType: uploadResult.mimeType || 'application/pdf', cachedAt: Date.now() });
184
+ return { type: 'fileData', fileUri: uploadResult.uri, mimeType: uploadResult.mimeType || 'application/pdf' };
211
185
  }
212
186
 
213
- // All upload methods failed
214
- console.error('[PDF-Secure] === UPLOAD FINAL: ALL FAILED ===');
215
- console.error('[PDF-Secure] Errors:', uploadErrors.join(' | '));
216
- console.error('[PDF-Secure] Falling back to inline base64 (will use many tokens!)');
187
+ // All upload methods failed — fallback to inline base64
188
+ console.error('[PDF-Secure] File API upload failed for', filename, '- falling back to inline base64');
217
189
  const base64 = fileBuffer.toString('base64');
218
190
  return { type: 'inlineData', mimeType: 'application/pdf', data: base64 };
219
191
  }
@@ -242,29 +214,52 @@ async function getPdfBase64(filename) {
242
214
  return base64;
243
215
  }
244
216
 
217
+ // Check if a question is a summary request
218
+ function isSummaryRequest(question, history) {
219
+ // Only cache first-time summary requests (no history = fresh summary)
220
+ if (history && history.length > 0) return false;
221
+ return SUMMARY_PATTERNS.some((p) => p.test(question));
222
+ }
223
+
245
224
  GeminiChat.chat = async function (filename, question, history, tier) {
246
225
  if (!ai) {
247
226
  throw new Error('AI chat is not configured');
248
227
  }
249
228
 
229
+ // Summary cache: same PDF summary is identical for all users
230
+ if (isSummaryRequest(question, history)) {
231
+ const cached = summaryCache.get(filename);
232
+ if (cached && Date.now() - cached.cachedAt < SUMMARY_TTL) {
233
+ return { text: cached.text, suspicious: false, tokensUsed: 0 };
234
+ }
235
+ }
236
+
250
237
  const config = TIER_CONFIG[tier] || TIER_CONFIG.premium;
251
238
  const pdfRef = await getOrUploadPdf(filename);
252
239
 
253
240
  // Build conversation contents from history (trimmed to last N entries)
241
+ // Cost optimization: only recent messages get full text, older ones are truncated
254
242
  const contents = [];
255
243
  if (Array.isArray(history)) {
256
244
  const trimmedHistory = history.slice(-config.maxHistory);
245
+ const recentStart = Math.max(0, trimmedHistory.length - config.recentFullMessages);
257
246
  // Start with 'model' as lastRole since the preamble ends with a model message
258
247
  let lastRole = 'model';
259
- for (const entry of trimmedHistory) {
248
+ for (let i = 0; i < trimmedHistory.length; i++) {
249
+ const entry = trimmedHistory[i];
260
250
  if (entry.role && entry.text) {
261
251
  const role = entry.role === 'user' ? 'user' : 'model';
262
252
  // Skip consecutive same-role entries (prevents injection via fake model responses)
263
253
  if (role === lastRole) continue;
264
254
  lastRole = role;
255
+ let text = sanitizeHistoryText(entry.text);
256
+ // Truncate older messages to save input tokens (~90% cost reduction on history)
257
+ if (i < recentStart && text.length > 500) {
258
+ text = text.slice(0, 500) + '...';
259
+ }
265
260
  contents.push({
266
261
  role,
267
- parts: [{ text: sanitizeHistoryText(entry.text) }],
262
+ parts: [{ text }],
268
263
  });
269
264
  }
270
265
  }
@@ -310,9 +305,27 @@ GeminiChat.chat = async function (filename, question, history, tier) {
310
305
  throw new Error('Empty response from AI');
311
306
  }
312
307
 
313
- const tokensUsed = response?.usageMetadata?.candidatesTokenCount || 0;
308
+ // Extract output token count — multiple fallbacks for SDK version compatibility
309
+ const usage = response?.usageMetadata || response?.usage_metadata || {};
310
+ let tokensUsed = usage.candidatesTokenCount
311
+ || usage.candidates_token_count
312
+ || usage.outputTokenCount
313
+ || usage.output_token_count
314
+ || 0;
315
+ // Last resort: estimate from text length if API didn't return token count
316
+ // (~4 chars per token is a reasonable approximation for multilingual text)
317
+ if (!tokensUsed && text.length > 0) {
318
+ tokensUsed = Math.ceil(text.length / 4);
319
+ }
320
+
314
321
  const result = sanitizeAiOutput(text);
315
322
  result.tokensUsed = tokensUsed;
323
+
324
+ // Cache summary responses (most expensive request type)
325
+ if (isSummaryRequest(question, history) && !result.suspicious) {
326
+ summaryCache.set(filename, { text: result.text, tokensUsed, cachedAt: Date.now() });
327
+ }
328
+
316
329
  return result;
317
330
  };
318
331
 
package/library.js CHANGED
@@ -121,7 +121,6 @@ plugin.init = async (params) => {
121
121
  // Lite: full PDF access but restricted UI (no annotations, sidebar, etc.)
122
122
  isLite = !isPremium && isLiteMember;
123
123
  }
124
- console.log('[PDF-Secure] Viewer request - uid:', req.uid, 'file:', safeName, 'isPremium:', isPremium, 'isVip:', isVip, 'isLite:', isLite);
125
124
 
126
125
  // Lite users get full PDF like premium (for nonce/server-side PDF data)
127
126
  const hasFullAccess = isPremium || isLite;
@@ -253,7 +252,6 @@ plugin.filterMetaTags = async (hookData) => {
253
252
 
254
253
  // Inject plugin config into client-side
255
254
  plugin.filterConfig = async function (data) {
256
- console.log('[PDF-Secure] filterConfig called - data exists:', !!data, 'config exists:', !!(data && data.config));
257
255
  return data;
258
256
  };
259
257
 
@@ -261,18 +259,15 @@ plugin.filterConfig = async function (data) {
261
259
  // This hides PDF URLs from: page source, API, RSS, ActivityPub
262
260
  plugin.transformPdfLinks = async (data) => {
263
261
  if (!data || !data.postData || !data.postData.content) {
264
- console.log('[PDF-Secure] transformPdfLinks - no data/postData/content, skipping');
265
262
  return data;
266
263
  }
267
264
 
268
- console.log('[PDF-Secure] transformPdfLinks - processing post tid:', data.postData.tid, 'pid:', data.postData.pid);
269
265
 
270
266
  // Regex to match PDF links: <a href="...xxx.pdf">text</a>
271
267
  // Captures: full URL path, filename, link text
272
268
  const pdfLinkRegex = /<a\s+[^>]*href=["']([^"']*\/([^"'\/]+\.pdf))["'][^>]*>([^<]*)<\/a>/gi;
273
269
 
274
270
  const matchCount = (data.postData.content.match(pdfLinkRegex) || []).length;
275
- console.log('[PDF-Secure] transformPdfLinks - found', matchCount, 'PDF links in post');
276
271
 
277
272
  data.postData.content = data.postData.content.replace(pdfLinkRegex, (match, fullPath, filename, linkText) => {
278
273
  // Decode filename to prevent double encoding (URL may already be encoded)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure2",
3
- "version": "1.3.9",
3
+ "version": "1.4.0",
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": {