nodebb-plugin-pdf-secure2 1.2.38 → 1.3.1
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 +125 -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 -5893
- package/static/lib/pdf-secure (1).pdf +0 -0
- 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, err.status || '', err.code || '');
|
|
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,125 @@
|
|
|
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
|
+
// In-memory cache for PDF base64 data (avoids re-reading from disk)
|
|
11
|
+
const pdfDataCache = new Map(); // filename -> { base64, cachedAt }
|
|
12
|
+
const PDF_DATA_TTL = 30 * 60 * 1000; // 30 minutes
|
|
13
|
+
|
|
14
|
+
const SYSTEM_INSTRUCTION = `You are a helpful assistant that answers questions about the provided PDF document.
|
|
15
|
+
Respond in the same language the user writes their question in.
|
|
16
|
+
Be concise and accurate. When referencing specific information, mention the relevant page or section when possible.`;
|
|
17
|
+
|
|
18
|
+
const MODEL_NAME = 'gemini-2.5-flash';
|
|
19
|
+
|
|
20
|
+
// Periodic cleanup
|
|
21
|
+
const cleanupTimer = setInterval(() => {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
for (const [key, entry] of pdfDataCache.entries()) {
|
|
24
|
+
if (now - entry.cachedAt > PDF_DATA_TTL) {
|
|
25
|
+
pdfDataCache.delete(key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}, 10 * 60 * 1000);
|
|
29
|
+
cleanupTimer.unref();
|
|
30
|
+
|
|
31
|
+
GeminiChat.init = function (apiKey) {
|
|
32
|
+
if (!apiKey) {
|
|
33
|
+
ai = null;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const { GoogleGenAI } = require('@google/genai');
|
|
38
|
+
ai = new GoogleGenAI({ apiKey });
|
|
39
|
+
console.log('[PDF-Secure] Gemini AI client initialized');
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error('[PDF-Secure] Failed to initialize Gemini client:', err.message);
|
|
42
|
+
ai = null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
GeminiChat.isAvailable = function () {
|
|
47
|
+
return !!ai;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Read PDF and cache base64 in memory
|
|
51
|
+
async function getPdfBase64(filename) {
|
|
52
|
+
const cached = pdfDataCache.get(filename);
|
|
53
|
+
if (cached && Date.now() - cached.cachedAt < PDF_DATA_TTL) {
|
|
54
|
+
return cached.base64;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const filePath = pdfHandler.resolveFilePath(filename);
|
|
58
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
59
|
+
throw new Error('File not found');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const fileBuffer = await fs.promises.readFile(filePath);
|
|
63
|
+
const base64 = fileBuffer.toString('base64');
|
|
64
|
+
|
|
65
|
+
pdfDataCache.set(filename, { base64, cachedAt: Date.now() });
|
|
66
|
+
return base64;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
GeminiChat.chat = async function (filename, question, history) {
|
|
70
|
+
if (!ai) {
|
|
71
|
+
throw new Error('AI chat is not configured');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const base64Data = await getPdfBase64(filename);
|
|
75
|
+
|
|
76
|
+
// Build conversation contents from history
|
|
77
|
+
const contents = [];
|
|
78
|
+
if (Array.isArray(history)) {
|
|
79
|
+
for (const entry of history) {
|
|
80
|
+
if (entry.role && entry.text) {
|
|
81
|
+
contents.push({
|
|
82
|
+
role: entry.role === 'user' ? 'user' : 'model',
|
|
83
|
+
parts: [{ text: entry.text }],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Add current question
|
|
90
|
+
contents.push({
|
|
91
|
+
role: 'user',
|
|
92
|
+
parts: [{ text: question }],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Always use inline PDF — single API call, no upload/cache overhead
|
|
96
|
+
const inlineContents = [
|
|
97
|
+
{
|
|
98
|
+
role: 'user',
|
|
99
|
+
parts: [
|
|
100
|
+
{ inlineData: { mimeType: 'application/pdf', data: base64Data } },
|
|
101
|
+
{ text: 'I am sharing a PDF document with you. Please use it to answer my questions.' },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
role: 'model',
|
|
106
|
+
parts: [{ text: 'I have received the PDF document. I am ready to answer your questions about it.' }],
|
|
107
|
+
},
|
|
108
|
+
...contents,
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const response = await ai.models.generateContent({
|
|
112
|
+
model: MODEL_NAME,
|
|
113
|
+
contents: inlineContents,
|
|
114
|
+
config: {
|
|
115
|
+
systemInstruction: SYSTEM_INSTRUCTION,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const text = response?.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
120
|
+
if (!text) {
|
|
121
|
+
throw new Error('Empty response from AI');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return text;
|
|
125
|
+
};
|