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.
@@ -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, 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
+ };