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.
- package/lib/controllers.js +6 -10
- package/lib/gemini-chat.js +80 -67
- package/library.js +0 -5
- package/package.json +1 -1
package/lib/controllers.js
CHANGED
|
@@ -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 >
|
|
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 >
|
|
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
|
package/lib/gemini-chat.js
CHANGED
|
@@ -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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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]
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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