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.
- package/lib/controllers.js +158 -14
- package/lib/gemini-chat.js +83 -67
- package/library.js +4 -5
- package/package.json +1 -1
- package/static/templates/admin/plugins/pdf-secure.tpl +41 -6
- package/static/viewer.html +244 -25
- package/test/image.png +0 -0
- package/static/lib/image.png +0 -0
package/lib/controllers.js
CHANGED
|
@@ -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
|
-
//
|
|
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:
|
|
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 >
|
|
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:
|
|
323
|
+
quota: updatedQuota,
|
|
185
324
|
});
|
|
186
325
|
} catch (err) {
|
|
187
326
|
console.error('[PDF-Secure] Chat error:', err.message, err.status || '', err.code || '');
|
|
188
|
-
|
|
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
|
-
|
|
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: [] });
|
package/lib/gemini-chat.js
CHANGED
|
@@ -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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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]
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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
|
-
<
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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>
|
package/static/viewer.html
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
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
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
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/lib/image.png
DELETED
|
Binary file
|