nodebb-plugin-pdf-secure2 1.3.7 → 1.3.9

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,37 @@ 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
+ console.log('[PDF-Secure] Quota settings: Premium', QUOTAS.premium.max, 'tokens/', QUOTAS.premium.window / 3600000, 'h, VIP', QUOTAS.vip.max, 'tokens/', QUOTAS.vip.window / 3600000, 'h');
42
+ };
43
+
19
44
  // DB-backed sliding window rate limiter
20
45
  // Returns { allowed, used, max } for quota visibility
21
46
  async function checkRateLimit(uid, tier) {
@@ -41,6 +66,104 @@ async function checkRateLimit(uid, tier) {
41
66
  }
42
67
  }
43
68
 
69
+ // Helper: parse token count from sorted set members and calculate totals
70
+ // Member format: "${timestamp}:${random}:${tokensUsed}"
71
+ function sumTokensFromMembers(members) {
72
+ let total = 0;
73
+ for (const m of members) {
74
+ const parts = (typeof m === 'string' ? m : m.value || '').split(':');
75
+ total += parseInt(parts[2], 10) || 0;
76
+ }
77
+ return total;
78
+ }
79
+
80
+ // Token-based 4-hour sliding window quota (DB-backed sorted set)
81
+ // Read-only check — does NOT add entries (call recordQuotaUsage after AI response)
82
+ // Returns { allowed, used, max, resetsIn }
83
+ async function checkQuota(uid, tier) {
84
+ const cfg = QUOTAS[tier] || QUOTAS.premium;
85
+ const key = `pdf-secure:quota:${uid}`;
86
+ const now = Date.now();
87
+ const windowStart = now - cfg.window;
88
+
89
+ try {
90
+ // Remove expired entries outside the 4-hour window
91
+ await db.sortedSetRemoveRangeByScore(key, '-inf', windowStart);
92
+ // Get all members to sum their token values
93
+ const members = await db.getSortedSetRange(key, 0, -1);
94
+ const totalTokens = sumTokensFromMembers(members);
95
+
96
+ // Calculate resetsIn from oldest entry
97
+ let resetsIn = 0;
98
+ if (members.length > 0) {
99
+ const oldest = await db.getSortedSetRangeWithScores(key, 0, 0);
100
+ if (oldest && oldest.length > 0) {
101
+ resetsIn = Math.max(0, (oldest[0].score + cfg.window) - now);
102
+ }
103
+ }
104
+
105
+ if (totalTokens >= cfg.max) {
106
+ return { allowed: false, used: totalTokens, max: cfg.max, resetsIn };
107
+ }
108
+ return { allowed: true, used: totalTokens, max: cfg.max, resetsIn };
109
+ } catch (err) {
110
+ return { allowed: true, used: 0, max: cfg.max, resetsIn: 0 };
111
+ }
112
+ }
113
+
114
+ // Record token usage after a successful AI response
115
+ // Returns updated { used, max, resetsIn }
116
+ async function recordQuotaUsage(uid, tier, tokensUsed) {
117
+ const cfg = QUOTAS[tier] || QUOTAS.premium;
118
+ const key = `pdf-secure:quota:${uid}`;
119
+ const now = Date.now();
120
+
121
+ try {
122
+ // Add entry with token count encoded in member string
123
+ await db.sortedSetAdd(key, now, `${now}:${crypto.randomBytes(4).toString('hex')}:${tokensUsed}`);
124
+
125
+ // Re-read total for accurate response
126
+ const members = await db.getSortedSetRange(key, 0, -1);
127
+ const totalTokens = sumTokensFromMembers(members);
128
+
129
+ let resetsIn = 0;
130
+ if (members.length > 0) {
131
+ const oldest = await db.getSortedSetRangeWithScores(key, 0, 0);
132
+ if (oldest && oldest.length > 0) {
133
+ resetsIn = Math.max(0, (oldest[0].score + cfg.window) - now);
134
+ }
135
+ }
136
+
137
+ return { used: totalTokens, max: cfg.max, resetsIn };
138
+ } catch (err) {
139
+ return { used: 0, max: cfg.max, resetsIn: 0 };
140
+ }
141
+ }
142
+
143
+ // Read-only quota usage (for initial UI display, does not increment)
144
+ async function getQuotaUsage(uid, tier) {
145
+ const cfg = QUOTAS[tier] || QUOTAS.premium;
146
+ const key = `pdf-secure:quota:${uid}`;
147
+ const now = Date.now();
148
+ const windowStart = now - cfg.window;
149
+
150
+ try {
151
+ await db.sortedSetRemoveRangeByScore(key, '-inf', windowStart);
152
+ const members = await db.getSortedSetRange(key, 0, -1);
153
+ const totalTokens = sumTokensFromMembers(members);
154
+ let resetsIn = 0;
155
+ if (members.length > 0) {
156
+ const oldest = await db.getSortedSetRangeWithScores(key, 0, 0);
157
+ if (oldest && oldest.length > 0) {
158
+ resetsIn = Math.max(0, (oldest[0].score + cfg.window) - now);
159
+ }
160
+ }
161
+ return { used: totalTokens, max: cfg.max, resetsIn };
162
+ } catch (err) {
163
+ return { used: 0, max: cfg.max, resetsIn: 0 };
164
+ }
165
+ }
166
+
44
167
  // AES-256-GCM encryption - replaces weak XOR obfuscation
45
168
  function aesGcmEncrypt(buffer, key, iv) {
46
169
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
@@ -132,10 +255,21 @@ Controllers.handleChat = async function (req, res) {
132
255
  const isVip = isVipMember || isAdmin;
133
256
  const tier = isVip ? 'vip' : 'premium';
134
257
 
135
- // Rate limiting (DB-backed sliding window, survives restarts and works across cluster)
258
+ // 4-hour rolling window quota check (visible to user in quota bar)
259
+ const quotaResult = await checkQuota(req.uid, tier);
260
+ if (!quotaResult.allowed) {
261
+ return res.status(429).json({
262
+ error: 'Mesaj kotanız doldu.',
263
+ quotaExhausted: true,
264
+ quota: { used: quotaResult.used, max: quotaResult.max, resetsIn: quotaResult.resetsIn },
265
+ tier,
266
+ });
267
+ }
268
+
269
+ // Spam rate limiting (DB-backed 60s sliding window, not shown in UI)
136
270
  const rateResult = await checkRateLimit(req.uid, tier);
137
271
  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 } });
272
+ 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
273
  }
140
274
 
141
275
  // Body validation
@@ -158,6 +292,7 @@ Controllers.handleChat = async function (req, res) {
158
292
  return res.status(400).json({ error: 'Invalid history (max 50 entries)' });
159
293
  }
160
294
  if (history) {
295
+ let totalHistorySize = 0;
161
296
  for (const entry of history) {
162
297
  if (!entry || typeof entry !== 'object') {
163
298
  return res.status(400).json({ error: 'Invalid history entry' });
@@ -168,6 +303,10 @@ Controllers.handleChat = async function (req, res) {
168
303
  if (typeof entry.text !== 'string' || entry.text.length > 4000) {
169
304
  return res.status(400).json({ error: 'Invalid history text' });
170
305
  }
306
+ totalHistorySize += entry.text.length;
307
+ if (totalHistorySize > 50000) {
308
+ return res.status(400).json({ error: 'History too large' });
309
+ }
171
310
  // Sanitize: strip null bytes and control characters
172
311
  entry.text = entry.text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
173
312
  // Collapse excessive whitespace (padding attack prevention)
@@ -178,14 +317,19 @@ Controllers.handleChat = async function (req, res) {
178
317
 
179
318
  try {
180
319
  const result = await geminiChat.chat(safeName, trimmedQuestion, history || [], tier);
320
+ // Record actual token usage after successful AI response
321
+ // Sanitize: clamp to [0, 1000000] to prevent NaN/negative/absurd values
322
+ const tokensUsed = Math.max(0, Math.min(parseInt(result.tokensUsed, 10) || 0, 1000000));
323
+ const updatedQuota = await recordQuotaUsage(req.uid, tier, tokensUsed);
181
324
  return res.json({
182
325
  answer: result.text,
183
326
  injectionWarning: result.suspicious || false,
184
- quota: { used: rateResult.used, max: rateResult.max },
327
+ quota: updatedQuota,
185
328
  });
186
329
  } catch (err) {
187
330
  console.error('[PDF-Secure] Chat error:', err.message, err.status || '', err.code || '');
188
- const quota = { used: rateResult.used, max: rateResult.max };
331
+ // On error, no tokens were used return pre-call quota
332
+ const quota = { used: quotaResult.used, max: quotaResult.max, resetsIn: quotaResult.resetsIn };
189
333
 
190
334
  if (err.message === 'File not found') {
191
335
  return res.status(404).json({ error: 'PDF bulunamadı.', quota });
@@ -224,6 +368,9 @@ Controllers.getSuggestions = async function (req, res) {
224
368
  return res.status(403).json({ error: 'Premium/VIP only' });
225
369
  }
226
370
 
371
+ const isVip = isVipMember || isAdmin;
372
+ const tier = isVip ? 'vip' : 'premium';
373
+
227
374
  // Simple rate limit: 1 request per 12 seconds per user
228
375
  const now = Date.now();
229
376
  const lastReq = suggestionsRateLimit.get(req.uid) || 0;
@@ -243,7 +390,8 @@ Controllers.getSuggestions = async function (req, res) {
243
390
 
244
391
  try {
245
392
  const suggestions = await geminiChat.generateSuggestions(safeName);
246
- return res.json({ suggestions });
393
+ const quotaUsage = await getQuotaUsage(req.uid, tier);
394
+ return res.json({ suggestions, quota: quotaUsage });
247
395
  } catch (err) {
248
396
  console.error('[PDF-Secure] Suggestions error:', err.message);
249
397
  return res.json({ suggestions: [] });
@@ -140,25 +140,82 @@ async function getOrUploadPdf(filename) {
140
140
  throw new Error('PDF too large for AI chat');
141
141
  }
142
142
 
143
+ // 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
+ const fileBuffer = await fs.promises.readFile(filePath);
152
+ console.log('[PDF-Secure] File read OK, buffer size:', fileBuffer.length);
153
+
154
+ // Try upload with multiple approaches
155
+ let uploadResult;
156
+ const uploadErrors = [];
157
+
158
+ // Approach 1: file path string
159
+ console.log('[PDF-Secure] Trying approach 1: file path string...');
143
160
  try {
144
- // Upload to Gemini File API — file is stored server-side, only URI is sent per request
145
- const uploadResult = await ai.files.upload({
161
+ uploadResult = await ai.files.upload({
146
162
  file: filePath,
147
- config: { mimeType: 'application/pdf' },
163
+ config: { mimeType: 'application/pdf', displayName: filename },
148
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
+ }
149
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
+ }
188
+
189
+ // Approach 3: Buffer directly
190
+ if (!uploadResult) {
191
+ console.log('[PDF-Secure] Trying approach 3: Buffer...');
192
+ 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);
201
+ }
202
+ }
203
+
204
+ if (uploadResult && uploadResult.uri) {
150
205
  const fileUri = uploadResult.uri;
151
206
  const mimeType = uploadResult.mimeType || 'application/pdf';
152
- console.log('[PDF-Secure] File API upload OK:', filename, '→', fileUri);
207
+ console.log('[PDF-Secure] === UPLOAD FINAL: SUCCESS ===', fileUri);
153
208
 
154
209
  fileUploadCache.set(filename, { fileUri, mimeType, cachedAt: Date.now() });
155
210
  return { type: 'fileData', fileUri, mimeType };
156
- } catch (uploadErr) {
157
- // Fallback: use inline base64 (works but uses many tokens)
158
- console.warn('[PDF-Secure] File API upload failed, falling back to inline:', uploadErr.message);
159
- const base64 = await getPdfBase64(filename);
160
- return { type: 'inlineData', mimeType: 'application/pdf', data: base64 };
161
211
  }
212
+
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!)');
217
+ const base64 = fileBuffer.toString('base64');
218
+ return { type: 'inlineData', mimeType: 'application/pdf', data: base64 };
162
219
  }
163
220
 
164
221
  // Read PDF and cache base64 in memory (fallback for File API failure)
@@ -253,7 +310,10 @@ GeminiChat.chat = async function (filename, question, history, tier) {
253
310
  throw new Error('Empty response from AI');
254
311
  }
255
312
 
256
- return sanitizeAiOutput(text);
313
+ const tokensUsed = response?.usageMetadata?.candidatesTokenCount || 0;
314
+ const result = sanitizeAiOutput(text);
315
+ result.tokensUsed = tokensUsed;
316
+ return result;
257
317
  };
258
318
 
259
319
  // 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
@@ -158,6 +161,7 @@ plugin.init = async (params) => {
158
161
  isPremium,
159
162
  isVip,
160
163
  isLite,
164
+ tier: isVip ? 'vip' : (isPremium ? 'premium' : 'free'),
161
165
  uid: req.uid,
162
166
  totalPages,
163
167
  chatEnabled: geminiChat.isAvailable(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure2",
3
- "version": "1.3.7",
3
+ "version": "1.3.9",
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
package/static/image.png DELETED
Binary file