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.
- package/.claude/settings.local.json +11 -0
- package/lib/controllers.js +105 -0
- package/lib/gemini-chat.js +231 -0
- package/library.js +257 -246
- package/package.json +2 -1
- package/plugin.json +4 -1
- package/static/lib/admin.js +25 -0
- package/static/lib/main.js +468 -468
- package/static/lib/pdf.min.mjs +20 -20
- package/static/lib/pdf.worker.min.mjs +20 -20
- package/static/templates/admin/plugins/pdf-secure.tpl +14 -5
- package/static/viewer-app.js +2 -2
- package/static/viewer.html +6353 -5883
- package/static/lib/viewer.js +0 -161
- package/static/viewer-yedek.html +0 -4548
package/lib/controllers.js
CHANGED
|
@@ -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
|
+
};
|