nodebb-plugin-pdf-secure2 1.3.8 → 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.
@@ -10,12 +10,36 @@ const db = require.main.require('./src/database');
10
10
 
11
11
  const Controllers = module.exports;
12
12
 
13
- // Rate limiting configuration (DB-backed sliding window)
13
+ // Rate limiting configuration (DB-backed sliding window — spam protection, not shown in UI)
14
14
  const RATE_LIMITS = {
15
15
  vip: { max: 50, window: 60 * 1000 },
16
16
  premium: { max: 25, window: 60 * 1000 },
17
17
  };
18
18
 
19
+ // 4-hour rolling window token quota (shown in UI quota bar as percentage)
20
+ // Tracks output tokens (candidatesTokenCount) only — fair regardless of PDF size
21
+ // Defaults — overridden by admin settings via initQuotaSettings()
22
+ const QUOTAS = {
23
+ vip: { max: 750000, window: 4 * 60 * 60 * 1000 }, // 750K output tokens / 4h
24
+ premium: { max: 200000, window: 4 * 60 * 60 * 1000 }, // 200K output tokens / 4h
25
+ };
26
+
27
+ // Apply admin-configured quota settings (called from library.js on init)
28
+ Controllers.initQuotaSettings = function (settings) {
29
+ if (!settings) return;
30
+ const premiumTokens = parseInt(settings.quotaPremiumTokens, 10);
31
+ const vipTokens = parseInt(settings.quotaVipTokens, 10);
32
+ const windowHours = parseInt(settings.quotaWindowHours, 10);
33
+
34
+ if (premiumTokens >= 10000 && premiumTokens <= 10000000) QUOTAS.premium.max = premiumTokens;
35
+ if (vipTokens >= 10000 && vipTokens <= 10000000) QUOTAS.vip.max = vipTokens;
36
+ if (windowHours >= 1 && windowHours <= 24) {
37
+ const windowMs = windowHours * 60 * 60 * 1000;
38
+ QUOTAS.premium.window = windowMs;
39
+ QUOTAS.vip.window = windowMs;
40
+ }
41
+ };
42
+
19
43
  // DB-backed sliding window rate limiter
20
44
  // Returns { allowed, used, max } for quota visibility
21
45
  async function checkRateLimit(uid, tier) {
@@ -41,6 +65,104 @@ async function checkRateLimit(uid, tier) {
41
65
  }
42
66
  }
43
67
 
68
+ // Helper: parse token count from sorted set members and calculate totals
69
+ // Member format: "${timestamp}:${random}:${tokensUsed}"
70
+ function sumTokensFromMembers(members) {
71
+ let total = 0;
72
+ for (const m of members) {
73
+ const parts = (typeof m === 'string' ? m : m.value || '').split(':');
74
+ total += parseInt(parts[2], 10) || 0;
75
+ }
76
+ return total;
77
+ }
78
+
79
+ // Token-based 4-hour sliding window quota (DB-backed sorted set)
80
+ // Read-only check — does NOT add entries (call recordQuotaUsage after AI response)
81
+ // Returns { allowed, used, max, resetsIn }
82
+ async function checkQuota(uid, tier) {
83
+ const cfg = QUOTAS[tier] || QUOTAS.premium;
84
+ const key = `pdf-secure:quota:${uid}`;
85
+ const now = Date.now();
86
+ const windowStart = now - cfg.window;
87
+
88
+ try {
89
+ // Remove expired entries outside the 4-hour window
90
+ await db.sortedSetRemoveRangeByScore(key, '-inf', windowStart);
91
+ // Get all members to sum their token values
92
+ const members = await db.getSortedSetRange(key, 0, -1);
93
+ const totalTokens = sumTokensFromMembers(members);
94
+
95
+ // Calculate resetsIn from oldest entry
96
+ let resetsIn = 0;
97
+ if (members.length > 0) {
98
+ const oldest = await db.getSortedSetRangeWithScores(key, 0, 0);
99
+ if (oldest && oldest.length > 0) {
100
+ resetsIn = Math.max(0, (oldest[0].score + cfg.window) - now);
101
+ }
102
+ }
103
+
104
+ if (totalTokens >= cfg.max) {
105
+ return { allowed: false, used: totalTokens, max: cfg.max, resetsIn };
106
+ }
107
+ return { allowed: true, used: totalTokens, max: cfg.max, resetsIn };
108
+ } catch (err) {
109
+ return { allowed: true, used: 0, max: cfg.max, resetsIn: 0 };
110
+ }
111
+ }
112
+
113
+ // Record token usage after a successful AI response
114
+ // Returns updated { used, max, resetsIn }
115
+ async function recordQuotaUsage(uid, tier, tokensUsed) {
116
+ const cfg = QUOTAS[tier] || QUOTAS.premium;
117
+ const key = `pdf-secure:quota:${uid}`;
118
+ const now = Date.now();
119
+
120
+ try {
121
+ // Add entry with token count encoded in member string
122
+ await db.sortedSetAdd(key, now, `${now}:${crypto.randomBytes(4).toString('hex')}:${tokensUsed}`);
123
+
124
+ // Re-read total for accurate response
125
+ const members = await db.getSortedSetRange(key, 0, -1);
126
+ const totalTokens = sumTokensFromMembers(members);
127
+
128
+ let resetsIn = 0;
129
+ if (members.length > 0) {
130
+ const oldest = await db.getSortedSetRangeWithScores(key, 0, 0);
131
+ if (oldest && oldest.length > 0) {
132
+ resetsIn = Math.max(0, (oldest[0].score + cfg.window) - now);
133
+ }
134
+ }
135
+
136
+ return { used: totalTokens, max: cfg.max, resetsIn };
137
+ } catch (err) {
138
+ return { used: 0, max: cfg.max, resetsIn: 0 };
139
+ }
140
+ }
141
+
142
+ // Read-only quota usage (for initial UI display, does not increment)
143
+ async function getQuotaUsage(uid, tier) {
144
+ const cfg = QUOTAS[tier] || QUOTAS.premium;
145
+ const key = `pdf-secure:quota:${uid}`;
146
+ const now = Date.now();
147
+ const windowStart = now - cfg.window;
148
+
149
+ try {
150
+ await db.sortedSetRemoveRangeByScore(key, '-inf', windowStart);
151
+ const members = await db.getSortedSetRange(key, 0, -1);
152
+ const totalTokens = sumTokensFromMembers(members);
153
+ let resetsIn = 0;
154
+ if (members.length > 0) {
155
+ const oldest = await db.getSortedSetRangeWithScores(key, 0, 0);
156
+ if (oldest && oldest.length > 0) {
157
+ resetsIn = Math.max(0, (oldest[0].score + cfg.window) - now);
158
+ }
159
+ }
160
+ return { used: totalTokens, max: cfg.max, resetsIn };
161
+ } catch (err) {
162
+ return { used: 0, max: cfg.max, resetsIn: 0 };
163
+ }
164
+ }
165
+
44
166
  // AES-256-GCM encryption - replaces weak XOR obfuscation
45
167
  function aesGcmEncrypt(buffer, key, iv) {
46
168
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
@@ -57,17 +179,13 @@ Controllers.renderAdminPage = function (req, res) {
57
179
  };
58
180
 
59
181
  Controllers.servePdfBinary = async function (req, res) {
60
- console.log('[PDF-Secure] servePdfBinary called - uid:', req.uid, 'nonce:', req.query.nonce ? 'present' : 'missing');
61
-
62
182
  // Authentication gate - require logged-in user
63
183
  if (!req.uid) {
64
- console.log('[PDF-Secure] servePdfBinary - REJECTED: no uid');
65
184
  return res.status(401).json({ error: 'Authentication required' });
66
185
  }
67
186
 
68
187
  const { nonce } = req.query;
69
188
  if (!nonce) {
70
- console.log('[PDF-Secure] servePdfBinary - REJECTED: no nonce');
71
189
  return res.status(400).json({ error: 'Missing nonce' });
72
190
  }
73
191
 
@@ -75,12 +193,9 @@ Controllers.servePdfBinary = async function (req, res) {
75
193
 
76
194
  const data = nonceStore.validate(nonce, uid);
77
195
  if (!data) {
78
- console.log('[PDF-Secure] servePdfBinary - REJECTED: invalid/expired nonce for uid:', uid);
79
196
  return res.status(403).json({ error: 'Invalid or expired nonce' });
80
197
  }
81
198
 
82
- console.log('[PDF-Secure] servePdfBinary - OK: file:', data.file, 'isPremium:', data.isPremium);
83
-
84
199
  try {
85
200
  // Server-side premium gate: non-premium users only get first page
86
201
  const pdfBuffer = data.isPremium
@@ -132,10 +247,21 @@ Controllers.handleChat = async function (req, res) {
132
247
  const isVip = isVipMember || isAdmin;
133
248
  const tier = isVip ? 'vip' : 'premium';
134
249
 
135
- // Rate limiting (DB-backed sliding window, survives restarts and works across cluster)
250
+ // 4-hour rolling window quota check (visible to user in quota bar)
251
+ const quotaResult = await checkQuota(req.uid, tier);
252
+ if (!quotaResult.allowed) {
253
+ return res.status(429).json({
254
+ error: 'Mesaj kotanız doldu.',
255
+ quotaExhausted: true,
256
+ quota: { used: quotaResult.used, max: quotaResult.max, resetsIn: quotaResult.resetsIn },
257
+ tier,
258
+ });
259
+ }
260
+
261
+ // Spam rate limiting (DB-backed 60s sliding window, not shown in UI)
136
262
  const rateResult = await checkRateLimit(req.uid, tier);
137
263
  if (!rateResult.allowed) {
138
- return res.status(429).json({ error: 'Çok hızlı mesaj gönderiyorsunuz. Lütfen biraz bekleyin.', quota: { used: rateResult.used, max: rateResult.max } });
264
+ return res.status(429).json({ error: 'Çok hızlı mesaj gönderiyorsunuz. Lütfen biraz bekleyin.', quota: { used: quotaResult.used, max: quotaResult.max, resetsIn: quotaResult.resetsIn } });
139
265
  }
140
266
 
141
267
  // Body validation
@@ -158,6 +284,7 @@ Controllers.handleChat = async function (req, res) {
158
284
  return res.status(400).json({ error: 'Invalid history (max 50 entries)' });
159
285
  }
160
286
  if (history) {
287
+ let totalHistorySize = 0;
161
288
  for (const entry of history) {
162
289
  if (!entry || typeof entry !== 'object') {
163
290
  return res.status(400).json({ error: 'Invalid history entry' });
@@ -165,9 +292,17 @@ Controllers.handleChat = async function (req, res) {
165
292
  if (entry.role !== 'user' && entry.role !== 'model') {
166
293
  return res.status(400).json({ error: 'Invalid history role' });
167
294
  }
168
- if (typeof entry.text !== 'string' || entry.text.length > 4000) {
295
+ if (typeof entry.text !== 'string' || entry.text.length > 12000) {
169
296
  return res.status(400).json({ error: 'Invalid history text' });
170
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
+ }
302
+ totalHistorySize += entry.text.length;
303
+ if (totalHistorySize > 150000) {
304
+ return res.status(400).json({ error: 'History too large' });
305
+ }
171
306
  // Sanitize: strip null bytes and control characters
172
307
  entry.text = entry.text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
173
308
  // Collapse excessive whitespace (padding attack prevention)
@@ -178,14 +313,19 @@ Controllers.handleChat = async function (req, res) {
178
313
 
179
314
  try {
180
315
  const result = await geminiChat.chat(safeName, trimmedQuestion, history || [], tier);
316
+ // Record actual token usage after successful AI response
317
+ // Sanitize: clamp to [0, 1000000] to prevent NaN/negative/absurd values
318
+ const tokensUsed = Math.max(0, Math.min(parseInt(result.tokensUsed, 10) || 0, 1000000));
319
+ const updatedQuota = await recordQuotaUsage(req.uid, tier, tokensUsed);
181
320
  return res.json({
182
321
  answer: result.text,
183
322
  injectionWarning: result.suspicious || false,
184
- quota: { used: rateResult.used, max: rateResult.max },
323
+ quota: updatedQuota,
185
324
  });
186
325
  } catch (err) {
187
326
  console.error('[PDF-Secure] Chat error:', err.message, err.status || '', err.code || '');
188
- const quota = { used: rateResult.used, max: rateResult.max };
327
+ // On error, no tokens were used return pre-call quota
328
+ const quota = { used: quotaResult.used, max: quotaResult.max, resetsIn: quotaResult.resetsIn };
189
329
 
190
330
  if (err.message === 'File not found') {
191
331
  return res.status(404).json({ error: 'PDF bulunamadı.', quota });
@@ -224,6 +364,9 @@ Controllers.getSuggestions = async function (req, res) {
224
364
  return res.status(403).json({ error: 'Premium/VIP only' });
225
365
  }
226
366
 
367
+ const isVip = isVipMember || isAdmin;
368
+ const tier = isVip ? 'vip' : 'premium';
369
+
227
370
  // Simple rate limit: 1 request per 12 seconds per user
228
371
  const now = Date.now();
229
372
  const lastReq = suggestionsRateLimit.get(req.uid) || 0;
@@ -243,7 +386,8 @@ Controllers.getSuggestions = async function (req, res) {
243
386
 
244
387
  try {
245
388
  const suggestions = await geminiChat.generateSuggestions(safeName);
246
- return res.json({ suggestions });
389
+ const quotaUsage = await getQuotaUsage(req.uid, tier);
390
+ return res.json({ suggestions, quota: quotaUsage });
247
391
  } catch (err) {
248
392
  console.error('[PDF-Secure] Suggestions error:', err.message);
249
393
  return res.json({ suggestions: [] });
@@ -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 uploadErrors = [];
157
-
158
- // Approach 1: file path string
159
- console.log('[PDF-Secure] Trying approach 1: file path string...');
160
- try {
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
- // Approach 3: Buffer directly
190
- if (!uploadResult) {
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 ai.files.upload({
194
- file: fileBuffer,
195
- config: { mimeType: 'application/pdf', displayName: filename },
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
- const fileUri = uploadResult.uri;
206
- const mimeType = uploadResult.mimeType || 'application/pdf';
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] === UPLOAD FINAL: ALL FAILED ===');
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 (const entry of trimmedHistory) {
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: sanitizeHistoryText(entry.text) }],
262
+ parts: [{ text }],
268
263
  });
269
264
  }
270
265
  }
@@ -310,7 +305,28 @@ GeminiChat.chat = async function (filename, question, history, tier) {
310
305
  throw new Error('Empty response from AI');
311
306
  }
312
307
 
313
- return sanitizeAiOutput(text);
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
+
321
+ const result = sanitizeAiOutput(text);
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
+
329
+ return result;
314
330
  };
315
331
 
316
332
  // Generate AI-powered question suggestions for a PDF
package/library.js CHANGED
@@ -73,6 +73,9 @@ plugin.init = async (params) => {
73
73
  geminiChat.init(pluginSettings.geminiApiKey);
74
74
  }
75
75
 
76
+ // Apply admin-configured quota settings
77
+ controllers.initQuotaSettings(pluginSettings);
78
+
76
79
  const watermarkEnabled = pluginSettings.watermarkEnabled === 'on';
77
80
 
78
81
  // Admin page route
@@ -118,7 +121,6 @@ plugin.init = async (params) => {
118
121
  // Lite: full PDF access but restricted UI (no annotations, sidebar, etc.)
119
122
  isLite = !isPremium && isLiteMember;
120
123
  }
121
- console.log('[PDF-Secure] Viewer request - uid:', req.uid, 'file:', safeName, 'isPremium:', isPremium, 'isVip:', isVip, 'isLite:', isLite);
122
124
 
123
125
  // Lite users get full PDF like premium (for nonce/server-side PDF data)
124
126
  const hasFullAccess = isPremium || isLite;
@@ -158,6 +160,7 @@ plugin.init = async (params) => {
158
160
  isPremium,
159
161
  isVip,
160
162
  isLite,
163
+ tier: isVip ? 'vip' : (isPremium ? 'premium' : 'free'),
161
164
  uid: req.uid,
162
165
  totalPages,
163
166
  chatEnabled: geminiChat.isAvailable(),
@@ -249,7 +252,6 @@ plugin.filterMetaTags = async (hookData) => {
249
252
 
250
253
  // Inject plugin config into client-side
251
254
  plugin.filterConfig = async function (data) {
252
- console.log('[PDF-Secure] filterConfig called - data exists:', !!data, 'config exists:', !!(data && data.config));
253
255
  return data;
254
256
  };
255
257
 
@@ -257,18 +259,15 @@ plugin.filterConfig = async function (data) {
257
259
  // This hides PDF URLs from: page source, API, RSS, ActivityPub
258
260
  plugin.transformPdfLinks = async (data) => {
259
261
  if (!data || !data.postData || !data.postData.content) {
260
- console.log('[PDF-Secure] transformPdfLinks - no data/postData/content, skipping');
261
262
  return data;
262
263
  }
263
264
 
264
- console.log('[PDF-Secure] transformPdfLinks - processing post tid:', data.postData.tid, 'pid:', data.postData.pid);
265
265
 
266
266
  // Regex to match PDF links: <a href="...xxx.pdf">text</a>
267
267
  // Captures: full URL path, filename, link text
268
268
  const pdfLinkRegex = /<a\s+[^>]*href=["']([^"']*\/([^"'\/]+\.pdf))["'][^>]*>([^<]*)<\/a>/gi;
269
269
 
270
270
  const matchCount = (data.postData.content.match(pdfLinkRegex) || []).length;
271
- console.log('[PDF-Secure] transformPdfLinks - found', matchCount, 'PDF links in post');
272
271
 
273
272
  data.postData.content = data.postData.content.replace(pdfLinkRegex, (match, fullPath, filename, linkText) => {
274
273
  // Decode filename to prevent double encoding (URL may already be encoded)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure2",
3
- "version": "1.3.8",
3
+ "version": "1.4.0",
4
4
  "description": "Secure PDF viewer plugin for NodeBB - prevents downloading, enables canvas-only rendering with Premium group support",
5
5
  "main": "library.js",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  Grup Adi
42
42
  </label>
43
43
  <input type="text" id="vipGroup" name="vipGroup" data-key="vipGroup" title="VIP Group Name" class="form-control" placeholder="VIP" value="VIP">
44
- <div class="form-text">Premium ozellikleri + VIP rozeti, yuksek token/rate limitleri.</div>
44
+ <div class="form-text">Premium ozellikleri + VIP rozeti, yuksek token limitleri.</div>
45
45
  </div>
46
46
 
47
47
  <div class="mb-0">
@@ -96,12 +96,47 @@
96
96
  </div>
97
97
  </div>
98
98
 
99
- <div class="alert alert-light border d-flex gap-2 mb-0" role="alert" style="font-size:13px;">
100
- <svg viewBox="0 0 24 24" style="width:18px;height:18px;fill:#0d6efd;flex-shrink:0;margin-top:1px;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
99
+ <hr class="my-3">
100
+ <h6 class="fw-semibold mb-3" style="font-size:13px;">Token Kota Ayarlari</h6>
101
+
102
+ <div class="row g-3 mb-3">
103
+ <div class="col-6">
104
+ <label class="form-label fw-medium" for="quotaPremiumTokens" style="font-size:13px;">
105
+ <span class="badge text-bg-primary me-1" style="font-size:9px;">PREMIUM</span>
106
+ Token Limiti
107
+ </label>
108
+ <div class="input-group input-group-sm">
109
+ <input type="number" id="quotaPremiumTokens" name="quotaPremiumTokens" data-key="quotaPremiumTokens" class="form-control" placeholder="200000" min="10000" step="10000">
110
+ <span class="input-group-text" style="font-size:12px;">token</span>
111
+ </div>
112
+ <div class="form-text" style="font-size:11px;">Varsayilan: 200.000 (~100 mesaj)</div>
113
+ </div>
114
+ <div class="col-6">
115
+ <label class="form-label fw-medium" for="quotaVipTokens" style="font-size:13px;">
116
+ <span class="badge me-1" style="font-size:9px;background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff;">VIP</span>
117
+ Token Limiti
118
+ </label>
119
+ <div class="input-group input-group-sm">
120
+ <input type="number" id="quotaVipTokens" name="quotaVipTokens" data-key="quotaVipTokens" class="form-control" placeholder="750000" min="10000" step="10000">
121
+ <span class="input-group-text" style="font-size:12px;">token</span>
122
+ </div>
123
+ <div class="form-text" style="font-size:11px;">Varsayilan: 750.000 (~375 mesaj)</div>
124
+ </div>
125
+ </div>
126
+ <div class="mb-3">
127
+ <label class="form-label fw-medium" for="quotaWindowHours" style="font-size:13px;">Pencere Suresi</label>
128
+ <div class="input-group input-group-sm" style="max-width:180px;">
129
+ <input type="number" id="quotaWindowHours" name="quotaWindowHours" data-key="quotaWindowHours" class="form-control" placeholder="4" min="1" max="24" step="1">
130
+ <span class="input-group-text" style="font-size:12px;">saat</span>
131
+ </div>
132
+ <div class="form-text" style="font-size:11px;">Varsayilan: 4 saat. Kota bu sure icerisinde sifirlanir.</div>
133
+ </div>
134
+
135
+ <div class="alert alert-light border d-flex gap-2 mb-0" role="alert" style="font-size:12px;">
136
+ <svg viewBox="0 0 24 24" style="width:16px;height:16px;fill:#0d6efd;flex-shrink:0;margin-top:1px;"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
101
137
  <div>
102
- <strong>Tier farklari:</strong><br>
103
- <strong>Premium</strong> 25 mesaj/dk, 2048 token, 20 gecmis<br>
104
- <strong>VIP</strong> — 50 mesaj/dk, 4096 token, 30 gecmis
138
+ Sadece output (yanit) tokenleri sayilir. PDF ve soru tokenleri dahil degildir.<br>
139
+ Ortalama mesaj ~1000-2000 token, uzun ozet ~3000-4000 token tuketir.
105
140
  </div>
106
141
  </div>
107
142
  </div>
@@ -1048,6 +1048,8 @@
1048
1048
  background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
1049
1049
  color: #1a1a1a;
1050
1050
  text-decoration: none;
1051
+ border: none;
1052
+ cursor: pointer;
1051
1053
  border-radius: 10px;
1052
1054
  font-size: 14px;
1053
1055
  font-weight: 700;
@@ -1092,6 +1094,7 @@
1092
1094
  color: #ccc;
1093
1095
  font-size: 13px;
1094
1096
  font-weight: 600;
1097
+ cursor: pointer;
1095
1098
  border-radius: 10px;
1096
1099
  border: 1.5px solid rgba(255, 255, 255, 0.15);
1097
1100
  text-decoration: none;
@@ -1105,6 +1108,59 @@
1105
1108
  background: rgba(255, 255, 255, 0.05);
1106
1109
  }
1107
1110
 
1111
+ /* Quota exhausted upsell (in-chat) */
1112
+ .chatQuotaExhausted {
1113
+ display: flex;
1114
+ flex-direction: column;
1115
+ align-items: center;
1116
+ text-align: center;
1117
+ padding: 24px 16px;
1118
+ margin: 8px;
1119
+ background: rgba(255, 215, 0, 0.06);
1120
+ border: 1px solid rgba(255, 215, 0, 0.15);
1121
+ border-radius: 12px;
1122
+ }
1123
+ .quotaExhaustedIcon { margin-bottom: 10px; opacity: 0.9; }
1124
+ .quotaExhaustedTitle {
1125
+ font-size: 16px;
1126
+ font-weight: 700;
1127
+ color: #ffd700;
1128
+ margin-bottom: 6px;
1129
+ }
1130
+ .quotaExhaustedText {
1131
+ font-size: 13px;
1132
+ color: #a0a0a0;
1133
+ line-height: 1.5;
1134
+ margin-bottom: 14px;
1135
+ max-width: 240px;
1136
+ }
1137
+ .quotaExhaustedText strong { color: #ffd700; }
1138
+ .quotaExhaustedBtn {
1139
+ display: inline-flex;
1140
+ align-items: center;
1141
+ gap: 6px;
1142
+ padding: 10px 24px;
1143
+ background: linear-gradient(135deg, #ffd700, #ffaa00);
1144
+ color: #1a1a1a;
1145
+ text-decoration: none;
1146
+ border: none;
1147
+ cursor: pointer;
1148
+ border-radius: 10px;
1149
+ font-size: 14px;
1150
+ font-weight: 700;
1151
+ transition: transform 0.2s, box-shadow 0.2s;
1152
+ box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
1153
+ }
1154
+ .quotaExhaustedBtn:hover {
1155
+ transform: translateY(-2px);
1156
+ box-shadow: 0 6px 20px rgba(255, 215, 0, 0.4);
1157
+ }
1158
+ .quotaExhaustedNote {
1159
+ font-size: 11px;
1160
+ color: #666;
1161
+ margin-top: 10px;
1162
+ }
1163
+
1108
1164
  /* PRO badge on chat button for non-premium */
1109
1165
  #chatBtn {
1110
1166
  position: relative;
@@ -2800,7 +2856,7 @@
2800
2856
  <span id="chatQuotaText"></span>
2801
2857
  </div>
2802
2858
  <div id="chatInputArea">
2803
- <textarea id="chatInput" placeholder="Bu PDF hakkında bir soru sorun..." rows="1"></textarea>
2859
+ <textarea id="chatInput" placeholder="Bu PDF hakkında bir soru sorun..." rows="1" maxlength="2000"></textarea>
2804
2860
  <button id="chatSendBtn">
2805
2861
  <svg viewBox="0 0 24 24">
2806
2862
  <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
@@ -4035,13 +4091,28 @@
4035
4091
  var quotaFillEl = document.getElementById('chatQuotaFill');
4036
4092
  var quotaTextEl = document.getElementById('chatQuotaText');
4037
4093
 
4038
- function updateQuotaBar(used, max) {
4094
+ // Format milliseconds to human-readable time string
4095
+ function formatResetsIn(ms) {
4096
+ if (!ms || ms <= 0) return '';
4097
+ var totalMin = Math.ceil(ms / 60000);
4098
+ if (totalMin < 60) return totalMin + 'dk';
4099
+ var h = Math.floor(totalMin / 60);
4100
+ var m = totalMin % 60;
4101
+ return m > 0 ? (h + 's ' + m + 'dk') : (h + 's');
4102
+ }
4103
+
4104
+ function updateQuotaBar(used, max, resetsIn) {
4039
4105
  if (!quotaBarEl || !max) return;
4040
- var remaining = Math.max(0, max - used);
4041
- var pct = Math.min(100, (used / max) * 100);
4106
+ var pct = Math.min(100, Math.round((used / max) * 100));
4042
4107
 
4043
4108
  quotaFillEl.style.width = pct + '%';
4044
- quotaTextEl.textContent = remaining + '/' + max;
4109
+
4110
+ // Show percentage only (no raw token numbers — users don't need to know)
4111
+ var text = '%' + pct + ' kullan\u0131ld\u0131';
4112
+ if (used > 0 && resetsIn > 0) {
4113
+ text += ' \u2022 ' + formatResetsIn(resetsIn) + ' sonra yenilenir';
4114
+ }
4115
+ quotaTextEl.textContent = text;
4045
4116
 
4046
4117
  // Color states
4047
4118
  quotaFillEl.classList.remove('warning', 'critical');
@@ -4098,6 +4169,14 @@
4098
4169
  } else {
4099
4170
  existingChips.forEach(function (c) { c.classList.remove('loading'); });
4100
4171
  }
4172
+ // Show initial quota bar from suggestions response
4173
+ if (data.quota) {
4174
+ updateQuotaBar(data.quota.used, data.quota.max, data.quota.resetsIn);
4175
+ // Proactive lock: if quota already exhausted on page load, lock input immediately
4176
+ if (data.quota.max > 0 && data.quota.used >= data.quota.max) {
4177
+ showQuotaExhaustedUpsell(_cfg.tier || 'premium', data.quota.resetsIn);
4178
+ }
4179
+ }
4101
4180
  })
4102
4181
  .catch(function () {
4103
4182
  existingChips.forEach(function (c) { c.classList.remove('loading'); });
@@ -4126,26 +4205,158 @@
4126
4205
  function showChatUpsell() {
4127
4206
  var uid = (_cfg && _cfg.uid) || 0;
4128
4207
  var checkoutUrl = 'https://forum.ieu.app/pay/checkout?uid=' + uid;
4129
- const upsell = document.createElement('div');
4208
+ var materialUrl = 'https://forum.ieu.app/material-info';
4209
+
4210
+ // Build using DOM methods — MutationObserver strips <a> tags
4211
+ var upsell = document.createElement('div');
4130
4212
  upsell.className = 'chatUpsell';
4131
- upsell.innerHTML = '<div class="chatUpsellIcon">' +
4132
- '<svg viewBox="0 0 24 24" width="48" height="48" fill="#ffd700">' +
4133
- '<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>' +
4134
- '</svg></div>' +
4135
- '<div class="chatUpsellTitle">PDF Chat</div>' +
4136
- '<div class="chatUpsellText">PDF\'ler hakkında <strong>yapay zeka</strong> ile sohbet edebilir, sorular sorabilirsiniz. Bu özellik <strong>Premium</strong> ve <strong>VIP</strong> üyelere özeldir.</div>' +
4137
- '<div class="chatUpsellActions">' +
4138
- '<a href="' + checkoutUrl + '" target="_blank" class="chatUpsellBtn">' +
4139
- '<svg viewBox="0 0 24 24" width="18" height="18" fill="#1a1a1a"><path d="M16 18v2H8v-2h8zM11 7.99V16h2V7.99h3L12 4l-4 3.99h3z"/></svg>' +
4140
- 'Hesabını Yükselt</a>' +
4141
- '<div class="chatUpsellDivider">ya da</div>' +
4142
- '<a href="https://forum.ieu.app/material-info" target="_blank" class="chatUpsellBtnSecondary">' +
4143
- '<svg viewBox="0 0 24 24" width="16" height="16" fill="#ccc"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11zM8 15.01l1.41 1.41L11 14.84V19h2v-4.16l1.59 1.59L16 15.01 12.01 11 8 15.01z"/></svg>' +
4144
- 'Materyal Yükle</a>' +
4145
- '</div>';
4213
+
4214
+ // Icon
4215
+ var iconDiv = document.createElement('div');
4216
+ iconDiv.className = 'chatUpsellIcon';
4217
+ var iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
4218
+ iconSvg.setAttribute('viewBox', '0 0 24 24');
4219
+ iconSvg.setAttribute('width', '48');
4220
+ iconSvg.setAttribute('height', '48');
4221
+ iconSvg.setAttribute('fill', '#ffd700');
4222
+ var iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
4223
+ iconPath.setAttribute('d', 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z');
4224
+ iconSvg.appendChild(iconPath);
4225
+ iconDiv.appendChild(iconSvg);
4226
+ upsell.appendChild(iconDiv);
4227
+
4228
+ // Title
4229
+ var title = document.createElement('div');
4230
+ title.className = 'chatUpsellTitle';
4231
+ title.textContent = 'PDF Chat';
4232
+ upsell.appendChild(title);
4233
+
4234
+ // Text
4235
+ var text = document.createElement('div');
4236
+ text.className = 'chatUpsellText';
4237
+ text.innerHTML = 'PDF\'ler hakk\u0131nda <strong>yapay zeka</strong> ile sohbet edebilir, sorular sorabilirsiniz. Bu \u00f6zellik <strong>Premium</strong> ve <strong>VIP</strong> \u00fcyelere \u00f6zeldir.';
4238
+ upsell.appendChild(text);
4239
+
4240
+ // Actions
4241
+ var actions = document.createElement('div');
4242
+ actions.className = 'chatUpsellActions';
4243
+
4244
+ // Primary button (upgrade)
4245
+ var btn1 = document.createElement('button');
4246
+ btn1.className = 'chatUpsellBtn';
4247
+ var btn1Svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
4248
+ btn1Svg.setAttribute('viewBox', '0 0 24 24');
4249
+ btn1Svg.setAttribute('width', '18');
4250
+ btn1Svg.setAttribute('height', '18');
4251
+ btn1Svg.setAttribute('fill', '#1a1a1a');
4252
+ var btn1Path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
4253
+ btn1Path.setAttribute('d', 'M16 18v2H8v-2h8zM11 7.99V16h2V7.99h3L12 4l-4 3.99h3z');
4254
+ btn1Svg.appendChild(btn1Path);
4255
+ btn1.appendChild(btn1Svg);
4256
+ btn1.appendChild(document.createTextNode(' Hesab\u0131n\u0131 Y\u00fckselt'));
4257
+ btn1.addEventListener('click', function () { window.open(checkoutUrl, '_blank'); });
4258
+ actions.appendChild(btn1);
4259
+
4260
+ // Divider
4261
+ var divider = document.createElement('div');
4262
+ divider.className = 'chatUpsellDivider';
4263
+ divider.textContent = 'ya da';
4264
+ actions.appendChild(divider);
4265
+
4266
+ // Secondary button (upload material)
4267
+ var btn2 = document.createElement('button');
4268
+ btn2.className = 'chatUpsellBtnSecondary';
4269
+ var btn2Svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
4270
+ btn2Svg.setAttribute('viewBox', '0 0 24 24');
4271
+ btn2Svg.setAttribute('width', '16');
4272
+ btn2Svg.setAttribute('height', '16');
4273
+ btn2Svg.setAttribute('fill', '#ccc');
4274
+ var btn2Path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
4275
+ btn2Path.setAttribute('d', 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11zM8 15.01l1.41 1.41L11 14.84V19h2v-4.16l1.59 1.59L16 15.01 12.01 11 8 15.01z');
4276
+ btn2Svg.appendChild(btn2Path);
4277
+ btn2.appendChild(btn2Svg);
4278
+ btn2.appendChild(document.createTextNode(' Materyal Y\u00fckle'));
4279
+ btn2.addEventListener('click', function () { window.open(materialUrl, '_blank'); });
4280
+ actions.appendChild(btn2);
4281
+
4282
+ upsell.appendChild(actions);
4146
4283
  chatMessagesEl.appendChild(upsell);
4147
4284
  }
4148
4285
 
4286
+ function showQuotaExhaustedUpsell(tier, resetsIn) {
4287
+ var uid = (_cfg && _cfg.uid) || 0;
4288
+ var checkoutUrl = 'https://forum.ieu.app/pay/checkout?uid=' + uid;
4289
+ var resetText = resetsIn ? formatResetsIn(resetsIn) : '~4 saat';
4290
+ var msg = document.createElement('div');
4291
+ msg.className = 'chatMsg chatQuotaExhausted';
4292
+
4293
+ // Build using DOM methods to avoid MutationObserver stripping <a> tags
4294
+ var icon = document.createElement('div');
4295
+ icon.className = 'quotaExhaustedIcon';
4296
+ var iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
4297
+ iconSvg.setAttribute('viewBox', '0 0 24 24');
4298
+ iconSvg.setAttribute('width', '36');
4299
+ iconSvg.setAttribute('height', '36');
4300
+ var iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
4301
+
4302
+ if (tier === 'vip') {
4303
+ iconSvg.setAttribute('fill', '#f59e0b');
4304
+ iconPath.setAttribute('d', 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z');
4305
+ } else {
4306
+ iconSvg.setAttribute('fill', '#ffd700');
4307
+ iconPath.setAttribute('d', 'M12 2L9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61z');
4308
+ }
4309
+ iconSvg.appendChild(iconPath);
4310
+ icon.appendChild(iconSvg);
4311
+ msg.appendChild(icon);
4312
+
4313
+ var title = document.createElement('div');
4314
+ title.className = 'quotaExhaustedTitle';
4315
+ title.textContent = 'Mesaj kotas\u0131 doldu';
4316
+ msg.appendChild(title);
4317
+
4318
+ var text = document.createElement('div');
4319
+ text.className = 'quotaExhaustedText';
4320
+ if (tier === 'vip') {
4321
+ text.textContent = 'Mesaj hakk\u0131n\u0131z\u0131 kulland\u0131n\u0131z. ' + resetText + ' sonra s\u0131f\u0131rlan\u0131r.';
4322
+ } else {
4323
+ text.innerHTML = 'Kullan\u0131m hakk\u0131n\u0131z doldu. <strong>VIP</strong>\u2019e y\u00fckselterek \u00e7ok daha fazla kullan\u0131m hakk\u0131 kazan\u0131n!';
4324
+ }
4325
+ msg.appendChild(text);
4326
+
4327
+ if (tier !== 'vip') {
4328
+ // Use <button> + addEventListener instead of <a> to avoid
4329
+ // MutationObserver stripping (it removes <a> tags as dangerous)
4330
+ var btn = document.createElement('button');
4331
+ btn.className = 'quotaExhaustedBtn';
4332
+ var btnSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
4333
+ btnSvg.setAttribute('viewBox', '0 0 24 24');
4334
+ btnSvg.setAttribute('width', '18');
4335
+ btnSvg.setAttribute('height', '18');
4336
+ btnSvg.setAttribute('fill', '#1a1a1a');
4337
+ var btnPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
4338
+ btnPath.setAttribute('d', 'M16 18v2H8v-2h8zM11 7.99V16h2V7.99h3L12 4l-4 3.99h3z');
4339
+ btnSvg.appendChild(btnPath);
4340
+ btn.appendChild(btnSvg);
4341
+ btn.appendChild(document.createTextNode(' Hesab\u0131n\u0131 Y\u00fckselt'));
4342
+ btn.addEventListener('click', function () { window.open(checkoutUrl, '_blank'); });
4343
+ msg.appendChild(btn);
4344
+ }
4345
+
4346
+ var note = document.createElement('div');
4347
+ note.className = 'quotaExhaustedNote';
4348
+ note.textContent = resetText + ' sonra s\u0131f\u0131rlan\u0131r';
4349
+ msg.appendChild(note);
4350
+
4351
+ chatMessagesEl.appendChild(msg);
4352
+ chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
4353
+
4354
+ // Disable input area
4355
+ chatInputEl.disabled = true;
4356
+ chatSendBtnEl.disabled = true;
4357
+ chatInputEl.placeholder = 'Mesaj kotas\u0131 doldu';
4358
+ }
4359
+
4149
4360
  async function sendChatMessage() {
4150
4361
  if (!chatIsPremium) return;
4151
4362
  if (chatSending) return;
@@ -4180,7 +4391,12 @@
4180
4391
  const data = await resp.json();
4181
4392
  // Update quota bar from response
4182
4393
  if (data.quota) {
4183
- updateQuotaBar(data.quota.used, data.quota.max);
4394
+ updateQuotaBar(data.quota.used, data.quota.max, data.quota.resetsIn);
4395
+ }
4396
+ // Daily quota exhausted — show upsell
4397
+ if (resp.status === 429 && data.quotaExhausted) {
4398
+ showQuotaExhaustedUpsell(data.tier, data.quota && data.quota.resetsIn);
4399
+ return;
4184
4400
  }
4185
4401
  if (resp.ok && data.answer) {
4186
4402
  addChatMessage('ai', data.answer, data.injectionWarning);
@@ -4198,9 +4414,12 @@
4198
4414
  addChatMessage('error', 'Bağlantı hatası. Tekrar deneyin.');
4199
4415
  } finally {
4200
4416
  chatSending = false;
4201
- chatSendBtnEl.disabled = false;
4202
- updateSendBtnState();
4203
- chatInputEl.focus();
4417
+ // Don't re-enable if quota exhausted (input was permanently disabled)
4418
+ if (!chatInputEl.disabled) {
4419
+ chatSendBtnEl.disabled = false;
4420
+ updateSendBtnState();
4421
+ chatInputEl.focus();
4422
+ }
4204
4423
  }
4205
4424
  }
4206
4425
 
package/test/image.png ADDED
Binary file
Binary file