nodebb-plugin-pdf-secure2 1.2.37 → 1.3.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.
@@ -0,0 +1,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:raw.githubusercontent.com)",
5
+ "WebFetch(domain:github.com)",
6
+ "Bash(dir:*)",
7
+ "Bash(npm pack:*)",
8
+ "Bash(tar:*)"
9
+ ]
10
+ }
11
+ }
@@ -1,11 +1,29 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
+ const path = require('path');
4
5
  const nonceStore = require('./nonce-store');
5
6
  const pdfHandler = require('./pdf-handler');
7
+ const geminiChat = require('./gemini-chat');
8
+ const groups = require.main.require('./src/groups');
6
9
 
7
10
  const Controllers = module.exports;
8
11
 
12
+ // Rate limiting: per-user message counter
13
+ const rateLimits = new Map(); // uid -> { count, windowStart }
14
+ const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
15
+ const RATE_LIMIT_MAX = 30; // messages per window
16
+
17
+ // Periodic cleanup of expired rate limit entries
18
+ setInterval(() => {
19
+ const now = Date.now();
20
+ for (const [uid, entry] of rateLimits.entries()) {
21
+ if (now - entry.windowStart > RATE_LIMIT_WINDOW * 2) {
22
+ rateLimits.delete(uid);
23
+ }
24
+ }
25
+ }, 5 * 60 * 1000).unref();
26
+
9
27
  // AES-256-GCM encryption - replaces weak XOR obfuscation
10
28
  function aesGcmEncrypt(buffer, key, iv) {
11
29
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
@@ -63,3 +81,90 @@ Controllers.servePdfBinary = async function (req, res) {
63
81
  return res.status(500).json({ error: 'Internal error' });
64
82
  }
65
83
  };
84
+
85
+ Controllers.handleChat = async function (req, res) {
86
+ // Authentication gate
87
+ if (!req.uid) {
88
+ return res.status(401).json({ error: 'Authentication required' });
89
+ }
90
+
91
+ // Check AI availability
92
+ if (!geminiChat.isAvailable()) {
93
+ return res.status(503).json({ error: 'AI chat is not configured' });
94
+ }
95
+
96
+ // Premium/VIP group check
97
+ const [isAdmin, isGlobalMod, isPremiumMember, isVipMember] = await Promise.all([
98
+ groups.isMember(req.uid, 'administrators'),
99
+ groups.isMember(req.uid, 'Global Moderators'),
100
+ groups.isMember(req.uid, 'Premium'),
101
+ groups.isMember(req.uid, 'VIP'),
102
+ ]);
103
+ const isPremium = isAdmin || isGlobalMod || isPremiumMember || isVipMember;
104
+ if (!isPremium) {
105
+ return res.status(403).json({ error: 'Premium membership required for AI chat' });
106
+ }
107
+
108
+ // Rate limiting
109
+ const now = Date.now();
110
+ let userRate = rateLimits.get(req.uid);
111
+ if (!userRate || now - userRate.windowStart > RATE_LIMIT_WINDOW) {
112
+ userRate = { count: 0, windowStart: now };
113
+ rateLimits.set(req.uid, userRate);
114
+ }
115
+ userRate.count += 1;
116
+ if (userRate.count > RATE_LIMIT_MAX) {
117
+ return res.status(429).json({ error: 'Rate limit exceeded. Please wait a moment.' });
118
+ }
119
+
120
+ // Body validation
121
+ const { filename, question, history } = req.body;
122
+
123
+ if (!filename || typeof filename !== 'string') {
124
+ return res.status(400).json({ error: 'Missing or invalid filename' });
125
+ }
126
+ const safeName = path.basename(filename);
127
+ if (!safeName || safeName !== filename || !safeName.toLowerCase().endsWith('.pdf')) {
128
+ return res.status(400).json({ error: 'Invalid filename' });
129
+ }
130
+
131
+ const trimmedQuestion = typeof question === 'string' ? question.trim() : '';
132
+ if (!trimmedQuestion || trimmedQuestion.length > 2000) {
133
+ return res.status(400).json({ error: 'Question is required (max 2000 characters)' });
134
+ }
135
+
136
+ if (history && (!Array.isArray(history) || history.length > 50)) {
137
+ return res.status(400).json({ error: 'Invalid history (max 50 entries)' });
138
+ }
139
+ if (history) {
140
+ for (const entry of history) {
141
+ if (!entry || typeof entry !== 'object') {
142
+ return res.status(400).json({ error: 'Invalid history entry' });
143
+ }
144
+ if (entry.role !== 'user' && entry.role !== 'model') {
145
+ return res.status(400).json({ error: 'Invalid history role' });
146
+ }
147
+ if (typeof entry.text !== 'string' || entry.text.length > 4000) {
148
+ return res.status(400).json({ error: 'Invalid history text' });
149
+ }
150
+ }
151
+ }
152
+
153
+ try {
154
+ const answer = await geminiChat.chat(safeName, trimmedQuestion, history || []);
155
+ return res.json({ answer });
156
+ } catch (err) {
157
+ console.error('[PDF-Secure] Chat error:', err.message);
158
+
159
+ if (err.message === 'File not found') {
160
+ return res.status(404).json({ error: 'PDF not found' });
161
+ }
162
+ if (err.status === 429 || err.message.includes('rate limit') || err.message.includes('quota')) {
163
+ return res.status(429).json({ error: 'AI service rate limit exceeded. Please try again later.' });
164
+ }
165
+ if (err.status === 401 || err.status === 403 || err.message.includes('API key')) {
166
+ return res.status(503).json({ error: 'AI service configuration error' });
167
+ }
168
+ return res.status(500).json({ error: 'Failed to get AI response. Please try again.' });
169
+ }
170
+ };
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const pdfHandler = require('./pdf-handler');
5
+
6
+ const GeminiChat = module.exports;
7
+
8
+ let ai = null;
9
+
10
+ // Cache maps
11
+ const fileCache = new Map(); // filename -> {fileUri, uploadedAt}
12
+ const contextCache = new Map(); // filename -> {cacheName, expiresAt}
13
+ const uploadPromises = new Map(); // filename -> Promise (deduplication)
14
+
15
+ const FILE_TTL = 48 * 60 * 60 * 1000; // 48 hours (Gemini Files API limit)
16
+ const CONTEXT_TTL = 30 * 60 * 1000; // 30 minutes
17
+ const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
18
+ const SMALL_PDF_THRESHOLD = 8; // pages - skip caching for small PDFs
19
+
20
+ const SYSTEM_INSTRUCTION = `You are a helpful assistant that answers questions about the provided PDF document.
21
+ Respond in the same language the user writes their question in.
22
+ Be concise and accurate. When referencing specific information, mention the relevant page or section when possible.`;
23
+
24
+ const MODEL_NAME = 'gemini-2.5-flash';
25
+
26
+ // Periodic cleanup of expired entries
27
+ const cleanupTimer = setInterval(() => {
28
+ const now = Date.now();
29
+ for (const [key, entry] of fileCache.entries()) {
30
+ if (now - entry.uploadedAt > FILE_TTL) {
31
+ fileCache.delete(key);
32
+ }
33
+ }
34
+ for (const [key, entry] of contextCache.entries()) {
35
+ if (now > entry.expiresAt) {
36
+ contextCache.delete(key);
37
+ }
38
+ }
39
+ }, CLEANUP_INTERVAL);
40
+ cleanupTimer.unref();
41
+
42
+ GeminiChat.init = function (apiKey) {
43
+ if (!apiKey) {
44
+ ai = null;
45
+ return;
46
+ }
47
+ try {
48
+ const { GoogleGenAI } = require('@google/genai');
49
+ ai = new GoogleGenAI({ apiKey });
50
+ console.log('[PDF-Secure] Gemini AI client initialized');
51
+ } catch (err) {
52
+ console.error('[PDF-Secure] Failed to initialize Gemini client:', err.message);
53
+ ai = null;
54
+ }
55
+ };
56
+
57
+ GeminiChat.isAvailable = function () {
58
+ return !!ai;
59
+ };
60
+
61
+ async function uploadFile(filename) {
62
+ const filePath = pdfHandler.resolveFilePath(filename);
63
+ if (!filePath || !fs.existsSync(filePath)) {
64
+ throw new Error('File not found');
65
+ }
66
+
67
+ const fileBuffer = await fs.promises.readFile(filePath);
68
+ const uploadResult = await ai.files.upload({
69
+ file: new Blob([fileBuffer], { type: 'application/pdf' }),
70
+ config: { displayName: filename },
71
+ });
72
+
73
+ // Wait for file processing
74
+ let file = uploadResult;
75
+ while (file.state === 'PROCESSING') {
76
+ await new Promise(r => setTimeout(r, 2000));
77
+ file = await ai.files.get({ name: file.name });
78
+ }
79
+
80
+ if (file.state === 'FAILED') {
81
+ throw new Error('File processing failed');
82
+ }
83
+
84
+ return file;
85
+ }
86
+
87
+ async function getPageCount(filename) {
88
+ try {
89
+ return await pdfHandler.getTotalPages(filename);
90
+ } catch {
91
+ return 0;
92
+ }
93
+ }
94
+
95
+ GeminiChat.ensureCache = async function (filename) {
96
+ // Check existing context cache
97
+ const cached = contextCache.get(filename);
98
+ if (cached && Date.now() < cached.expiresAt) {
99
+ return { cacheName: cached.cacheName, useCache: true };
100
+ }
101
+
102
+ // Check if PDF is small enough to skip caching
103
+ const pageCount = await getPageCount(filename);
104
+ if (pageCount > 0 && pageCount < SMALL_PDF_THRESHOLD) {
105
+ // Small PDF: use inline approach
106
+ return { filename, useCache: false };
107
+ }
108
+
109
+ // Deduplicate concurrent upload requests for the same file
110
+ if (uploadPromises.has(filename)) {
111
+ return uploadPromises.get(filename);
112
+ }
113
+
114
+ const promise = (async () => {
115
+ try {
116
+ // Ensure file is uploaded
117
+ let fileEntry = fileCache.get(filename);
118
+ if (!fileEntry || Date.now() - fileEntry.uploadedAt > FILE_TTL) {
119
+ const uploaded = await uploadFile(filename);
120
+ fileEntry = { fileUri: uploaded.uri, uploadedAt: Date.now() };
121
+ fileCache.set(filename, fileEntry);
122
+ console.log('[PDF-Secure] File uploaded to Gemini:', filename);
123
+ }
124
+
125
+ // Create context cache
126
+ const cache = await ai.caches.create({
127
+ model: MODEL_NAME,
128
+ config: {
129
+ contents: [{
130
+ role: 'user',
131
+ parts: [{ fileData: { fileUri: fileEntry.fileUri, mimeType: 'application/pdf' } }],
132
+ }],
133
+ systemInstruction: SYSTEM_INSTRUCTION,
134
+ ttl: `${CONTEXT_TTL / 1000}s`,
135
+ },
136
+ });
137
+
138
+ const cacheEntry = {
139
+ cacheName: cache.name,
140
+ expiresAt: Date.now() + CONTEXT_TTL,
141
+ };
142
+ contextCache.set(filename, cacheEntry);
143
+ console.log('[PDF-Secure] Context cache created for:', filename);
144
+ return { cacheName: cache.name, useCache: true };
145
+ } finally {
146
+ uploadPromises.delete(filename);
147
+ }
148
+ })();
149
+
150
+ uploadPromises.set(filename, promise);
151
+ return promise;
152
+ };
153
+
154
+ GeminiChat.chat = async function (filename, question, history) {
155
+ if (!ai) {
156
+ throw new Error('AI chat is not configured');
157
+ }
158
+
159
+ const cacheInfo = await GeminiChat.ensureCache(filename);
160
+
161
+ // Build conversation contents from history
162
+ const contents = [];
163
+ if (Array.isArray(history)) {
164
+ for (const entry of history) {
165
+ if (entry.role && entry.text) {
166
+ contents.push({
167
+ role: entry.role === 'user' ? 'user' : 'model',
168
+ parts: [{ text: entry.text }],
169
+ });
170
+ }
171
+ }
172
+ }
173
+
174
+ // Add current question
175
+ contents.push({
176
+ role: 'user',
177
+ parts: [{ text: question }],
178
+ });
179
+
180
+ let response;
181
+
182
+ if (cacheInfo.useCache) {
183
+ // Use cached context
184
+ response = await ai.models.generateContent({
185
+ model: MODEL_NAME,
186
+ contents,
187
+ config: {
188
+ cachedContent: cacheInfo.cacheName,
189
+ },
190
+ });
191
+ } else {
192
+ // Inline PDF for small files
193
+ const filePath = pdfHandler.resolveFilePath(filename);
194
+ if (!filePath || !fs.existsSync(filePath)) {
195
+ throw new Error('File not found');
196
+ }
197
+ const fileBuffer = await fs.promises.readFile(filePath);
198
+ const base64Data = fileBuffer.toString('base64');
199
+
200
+ // Prepend PDF as first message
201
+ const inlineContents = [
202
+ {
203
+ role: 'user',
204
+ parts: [
205
+ { inlineData: { mimeType: 'application/pdf', data: base64Data } },
206
+ { text: 'I am sharing a PDF document with you. Please use it to answer my questions.' },
207
+ ],
208
+ },
209
+ {
210
+ role: 'model',
211
+ parts: [{ text: 'I have received the PDF document. I am ready to answer your questions about it.' }],
212
+ },
213
+ ...contents,
214
+ ];
215
+
216
+ response = await ai.models.generateContent({
217
+ model: MODEL_NAME,
218
+ contents: inlineContents,
219
+ config: {
220
+ systemInstruction: SYSTEM_INSTRUCTION,
221
+ },
222
+ });
223
+ }
224
+
225
+ const text = response?.candidates?.[0]?.content?.parts?.[0]?.text;
226
+ if (!text) {
227
+ throw new Error('Empty response from AI');
228
+ }
229
+
230
+ return text;
231
+ };