nodebb-plugin-pdf-secure 1.2.29 → 1.2.30

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/image.png ADDED
Binary file
@@ -1,28 +1,18 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const nonceStore = require('./nonce-store');
4
5
  const pdfHandler = require('./pdf-handler');
5
6
 
6
7
  const Controllers = module.exports;
7
8
 
8
- // Partial XOR - encrypts first 10KB and every 50th byte after that
9
- // Now uses dynamic key from nonce data
10
- function partialXorEncode(buffer, xorKey) {
11
- const data = Buffer.from(buffer);
12
- const keyLen = xorKey.length;
13
-
14
- // Encrypt first 10KB fully
15
- const fullEncryptLen = Math.min(10240, data.length);
16
- for (let i = 0; i < fullEncryptLen; i++) {
17
- data[i] = data[i] ^ xorKey[i % keyLen];
18
- }
19
-
20
- // Encrypt every 50th byte after that
21
- for (let i = fullEncryptLen; i < data.length; i += 50) {
22
- data[i] = data[i] ^ xorKey[i % keyLen];
23
- }
24
-
25
- return data;
9
+ // AES-256-GCM encryption - replaces weak XOR obfuscation
10
+ function aesGcmEncrypt(buffer, key, iv) {
11
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
12
+ const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
13
+ const authTag = cipher.getAuthTag(); // 16 bytes
14
+ // Format: [authTag (16 bytes)] [ciphertext]
15
+ return Buffer.concat([authTag, encrypted]);
26
16
  }
27
17
 
28
18
  Controllers.renderAdminPage = function (req, res) {
@@ -32,12 +22,17 @@ Controllers.renderAdminPage = function (req, res) {
32
22
  };
33
23
 
34
24
  Controllers.servePdfBinary = async function (req, res) {
25
+ // Authentication gate - require logged-in user
26
+ if (!req.uid) {
27
+ return res.status(401).json({ error: 'Authentication required' });
28
+ }
29
+
35
30
  const { nonce } = req.query;
36
31
  if (!nonce) {
37
32
  return res.status(400).json({ error: 'Missing nonce' });
38
33
  }
39
34
 
40
- const uid = req.uid || 0; // Guest uid = 0
35
+ const uid = req.uid;
41
36
 
42
37
  const data = nonceStore.validate(nonce, uid);
43
38
  if (!data) {
@@ -45,14 +40,16 @@ Controllers.servePdfBinary = async function (req, res) {
45
40
  }
46
41
 
47
42
  try {
48
- // Always send full PDF - page restriction is handled client-side
49
- const pdfBuffer = await pdfHandler.getFullPdf(data.file);
43
+ // Server-side premium gate: non-premium users only get first page
44
+ const pdfBuffer = data.isPremium
45
+ ? await pdfHandler.getFullPdf(data.file)
46
+ : await pdfHandler.getSinglePagePdf(data.file);
50
47
 
51
- // Apply partial XOR encryption with dynamic key from nonce
52
- const encodedBuffer = partialXorEncode(pdfBuffer, data.xorKey);
48
+ // Apply AES-256-GCM encryption
49
+ const encodedBuffer = aesGcmEncrypt(pdfBuffer, data.encKey, data.encIv);
53
50
 
54
51
  res.set({
55
- 'Content-Type': 'image/gif', // Misleading - actual PDF binary
52
+ 'Content-Type': 'application/octet-stream',
56
53
  'Cache-Control': 'no-store, no-cache, must-revalidate, private',
57
54
  'X-Content-Type-Options': 'nosniff',
58
55
  'Content-Disposition': 'inline',
@@ -17,40 +17,36 @@ setInterval(() => {
17
17
  }
18
18
  }, CLEANUP_INTERVAL).unref();
19
19
 
20
- // Generate a random XOR key (8 bytes)
21
- function generateXorKey() {
22
- return crypto.randomBytes(8);
20
+ // Generate AES-256-GCM key (32 bytes) and IV (12 bytes)
21
+ function generateEncryptionParams() {
22
+ return {
23
+ key: crypto.randomBytes(32),
24
+ iv: crypto.randomBytes(12),
25
+ };
23
26
  }
24
27
 
25
28
  const NonceStore = module.exports;
26
29
 
27
30
  NonceStore.generate = function (uid, file, isPremium) {
28
31
  const nonce = uuidv4();
29
- const xorKey = generateXorKey();
32
+ const { key, iv } = generateEncryptionParams();
30
33
 
31
34
  store.set(nonce, {
32
35
  uid: uid,
33
36
  file: file,
34
37
  isPremium: isPremium,
35
- xorKey: xorKey, // Store unique key for this nonce
38
+ encKey: key, // AES-256-GCM key
39
+ encIv: iv, // GCM initialization vector
36
40
  createdAt: Date.now(),
37
41
  });
38
42
 
39
43
  return {
40
44
  nonce: nonce,
41
- xorKey: xorKey.toString('base64') // Return key for viewer injection
45
+ dk: key.toString('base64'), // decryption key for viewer
46
+ iv: iv.toString('base64'), // IV for viewer
42
47
  };
43
48
  };
44
49
 
45
- // Get key without consuming nonce (for viewer injection)
46
- NonceStore.getKey = function (nonce) {
47
- const data = store.get(nonce);
48
- if (!data) {
49
- return null;
50
- }
51
- return data.xorKey.toString('base64');
52
- };
53
-
54
50
  NonceStore.validate = function (nonce, uid) {
55
51
  const data = store.get(nonce);
56
52
  if (!data) {
@@ -70,5 +66,5 @@ NonceStore.validate = function (nonce, uid) {
70
66
  return null;
71
67
  }
72
68
 
73
- return data; // Now includes xorKey
69
+ return data; // Includes encKey and encIv for AES-256-GCM
74
70
  };
package/library.js CHANGED
@@ -55,14 +55,19 @@ plugin.init = async (params) => {
55
55
  next();
56
56
  });
57
57
 
58
- // PDF binary endpoint (nonce-validated, guests allowed)
58
+ // PDF binary endpoint (nonce-validated, authentication required)
59
59
  router.get('/api/v3/plugins/pdf-secure/pdf-data', controllers.servePdfBinary);
60
60
 
61
61
  // Admin page route
62
62
  routeHelpers.setupAdminPageRoute(router, '/admin/plugins/pdf-secure', controllers.renderAdminPage);
63
63
 
64
- // Viewer page route (fullscreen Mozilla PDF.js viewer, guests allowed)
64
+ // Viewer page route (fullscreen Mozilla PDF.js viewer, authentication required)
65
65
  router.get('/plugins/pdf-secure/viewer', async (req, res) => {
66
+ // Authentication gate - require logged-in user
67
+ if (!req.uid) {
68
+ return res.status(401).json({ error: 'Authentication required' });
69
+ }
70
+
66
71
  const { file } = req.query;
67
72
  if (!file) {
68
73
  return res.status(400).send('Missing file parameter');
@@ -79,10 +84,10 @@ plugin.init = async (params) => {
79
84
  return res.status(500).send('Viewer not available');
80
85
  }
81
86
 
82
- // Check if user is Premium or Lite (admins/global mods always premium)
83
- let isPremium = false;
84
- let isLite = false;
85
- if (req.uid) {
87
+ try {
88
+ // Check if user is Premium or Lite (admins/global mods always premium)
89
+ let isPremium = false;
90
+ let isLite = false;
86
91
  const [isAdmin, isGlobalMod, isPremiumMember, isVipMember, isLiteMember] = await Promise.all([
87
92
  groups.isMember(req.uid, 'administrators'),
88
93
  groups.isMember(req.uid, 'Global Moderators'),
@@ -93,48 +98,52 @@ plugin.init = async (params) => {
93
98
  isPremium = isAdmin || isGlobalMod || isPremiumMember || isVipMember;
94
99
  // Lite: full PDF access but restricted UI (no annotations, sidebar, etc.)
95
100
  isLite = !isPremium && isLiteMember;
96
- }
97
101
 
98
- // Lite users get full PDF like premium (for nonce/server-side PDF data)
99
- const hasFullAccess = isPremium || isLite;
100
- const nonceData = nonceStore.generate(req.uid || 0, safeName, hasFullAccess);
101
-
102
- // Serve the viewer template with comprehensive security headers
103
- res.set({
104
- 'X-Frame-Options': 'SAMEORIGIN',
105
- 'X-Content-Type-Options': 'nosniff',
106
- 'X-XSS-Protection': '1; mode=block',
107
- 'Cache-Control': 'no-store, no-cache, must-revalidate, private, max-age=0',
108
- 'Pragma': 'no-cache',
109
- 'Expires': '0',
110
- 'Referrer-Policy': 'no-referrer',
111
- 'Permissions-Policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
112
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'self'",
113
- });
114
-
115
- // Inject the filename, nonce, and key into the cached viewer
116
- // Key is embedded in HTML - NOT visible in any network API response!
117
- const injectedHtml = viewerHtmlCache
118
- .replace('</head>', `
119
- <style>
120
- /* Hide upload overlay since PDF will auto-load */
121
- #uploadOverlay { display: none !important; }
122
- </style>
123
- <script>
124
- window.PDF_SECURE_CONFIG = {
125
- filename: ${JSON.stringify(safeName)},
126
- relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
127
- csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
128
- nonce: ${JSON.stringify(nonceData.nonce)},
129
- dk: ${JSON.stringify(nonceData.xorKey)},
130
- isPremium: ${JSON.stringify(isPremium)},
131
- isLite: ${JSON.stringify(isLite)},
132
- uid: ${JSON.stringify(req.uid || 0)}
133
- };
134
- </script>
135
- </head>`);
136
-
137
- res.type('html').send(injectedHtml);
102
+ // Lite users get full PDF like premium (for nonce/server-side PDF data)
103
+ const hasFullAccess = isPremium || isLite;
104
+ const nonceData = nonceStore.generate(req.uid, safeName, hasFullAccess);
105
+
106
+ // Serve the viewer template with comprehensive security headers
107
+ res.set({
108
+ 'X-Frame-Options': 'SAMEORIGIN',
109
+ 'X-Content-Type-Options': 'nosniff',
110
+ 'X-XSS-Protection': '1; mode=block',
111
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, private, max-age=0',
112
+ 'Pragma': 'no-cache',
113
+ 'Expires': '0',
114
+ 'Referrer-Policy': 'no-referrer',
115
+ 'Permissions-Policy': 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
116
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'self'",
117
+ });
118
+
119
+ // Inject the filename, nonce, and key into the cached viewer
120
+ // Key is embedded in HTML - NOT visible in any network API response!
121
+ const injectedHtml = viewerHtmlCache
122
+ .replace('</head>', `
123
+ <style>
124
+ /* Hide upload overlay since PDF will auto-load */
125
+ #uploadOverlay { display: none !important; }
126
+ </style>
127
+ <script>
128
+ window.PDF_SECURE_CONFIG = {
129
+ filename: ${JSON.stringify(safeName)},
130
+ relativePath: ${JSON.stringify(req.app.get('relative_path') || '')},
131
+ csrfToken: ${JSON.stringify(req.csrfToken ? req.csrfToken() : '')},
132
+ nonce: ${JSON.stringify(nonceData.nonce)},
133
+ dk: ${JSON.stringify(nonceData.dk)},
134
+ iv: ${JSON.stringify(nonceData.iv)},
135
+ isPremium: ${JSON.stringify(isPremium)},
136
+ isLite: ${JSON.stringify(isLite)},
137
+ uid: ${JSON.stringify(req.uid)}
138
+ };
139
+ </script>
140
+ </head>`);
141
+
142
+ res.type('html').send(injectedHtml);
143
+ } catch (err) {
144
+ console.error('[PDF-Secure] Viewer route error:', err.message);
145
+ return res.status(500).send('Internal error');
146
+ }
138
147
  });
139
148
  };
140
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-pdf-secure",
3
- "version": "1.2.29",
3
+ "version": "1.2.30",
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": {
Binary file
@@ -151,24 +151,30 @@
151
151
  // Thumbnails will be generated on-demand when sidebar opens
152
152
  }
153
153
 
154
- // Partial XOR decoder - must match backend encoding
155
- function partialXorDecode(encodedData, keyBase64) {
156
- const key = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0));
157
- const data = new Uint8Array(encodedData);
158
- const keyLen = key.length;
159
-
160
- // Decrypt first 10KB fully
161
- const fullDecryptLen = Math.min(10240, data.length);
162
- for (let i = 0; i < fullDecryptLen; i++) {
163
- data[i] = data[i] ^ key[i % keyLen];
164
- }
165
-
166
- // Decrypt every 50th byte after that
167
- for (let i = fullDecryptLen; i < data.length; i += 50) {
168
- data[i] = data[i] ^ key[i % keyLen];
169
- }
170
-
171
- return data.buffer;
154
+ // AES-256-GCM decoder using Web Crypto API
155
+ async function aesGcmDecode(encodedData, keyBase64, ivBase64) {
156
+ const keyBytes = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0));
157
+ const iv = Uint8Array.from(atob(ivBase64), c => c.charCodeAt(0));
158
+ const encData = new Uint8Array(encodedData);
159
+
160
+ // Format: [authTag (16 bytes)] [ciphertext]
161
+ const authTag = encData.slice(0, 16);
162
+ const ciphertext = encData.slice(16);
163
+
164
+ // Web Crypto expects ciphertext + authTag concatenated
165
+ const combined = new Uint8Array(ciphertext.length + authTag.length);
166
+ combined.set(ciphertext);
167
+ combined.set(authTag, ciphertext.length);
168
+
169
+ const cryptoKey = await crypto.subtle.importKey(
170
+ 'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']
171
+ );
172
+
173
+ return crypto.subtle.decrypt(
174
+ { name: 'AES-GCM', iv: iv },
175
+ cryptoKey,
176
+ combined
177
+ );
172
178
  }
173
179
 
174
180
  function showPremiumLockOverlay(totalPages) {
@@ -373,7 +379,8 @@
373
379
  if (!pdfBuffer) {
374
380
  // Nonce and key are embedded in HTML config (not fetched from API)
375
381
  const nonce = config.nonce;
376
- const xorKey = config.dk;
382
+ const decryptKey = config.dk;
383
+ const decryptIv = config.iv;
377
384
 
378
385
  // Fetch encrypted PDF binary
379
386
  const pdfUrl = config.relativePath + '/api/v3/plugins/pdf-secure/pdf-data?nonce=' + encodeURIComponent(nonce);
@@ -386,10 +393,10 @@
386
393
  const encodedBuffer = await pdfRes.arrayBuffer();
387
394
  console.log('[PDF-Secure] Encrypted data received:', encodedBuffer.byteLength, 'bytes');
388
395
 
389
- // Decode XOR encrypted data
390
- if (xorKey) {
391
- console.log('[PDF-Secure] Decoding XOR encrypted data...');
392
- pdfBuffer = partialXorDecode(encodedBuffer, xorKey);
396
+ // Decrypt AES-256-GCM encrypted data
397
+ if (decryptKey && decryptIv) {
398
+ console.log('[PDF-Secure] Decrypting AES-256-GCM data...');
399
+ pdfBuffer = await aesGcmDecode(encodedBuffer, decryptKey, decryptIv);
393
400
  } else {
394
401
  pdfBuffer = encodedBuffer;
395
402
  }
@@ -467,7 +474,7 @@
467
474
  <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-2h2v2zm0-4h-2V7h2v6z"/>
468
475
  </svg>
469
476
  <h2>Hata</h2>
470
- <p>${err.message}</p>
477
+ <p>${err.message.replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c]))}</p>
471
478
  `;
472
479
  }
473
480
  }
@@ -2658,7 +2665,7 @@
2658
2665
  function toggleFullscreen() {
2659
2666
  if (window.self !== window.top) {
2660
2667
  // Inside iframe: ask parent to handle fullscreen
2661
- window.parent.postMessage({ type: 'pdf-secure-fullscreen-toggle' }, '*');
2668
+ window.parent.postMessage({ type: 'pdf-secure-fullscreen-toggle' }, window.location.origin);
2662
2669
  } else {
2663
2670
  // Standalone mode: use native fullscreen
2664
2671
  if (document.fullscreenElement) {
@@ -1749,6 +1749,56 @@
1749
1749
  font-size: 12px;
1750
1750
  }
1751
1751
  }
1752
+
1753
+ /* ── Unicourse Page Watermark ── */
1754
+ .unicourse-page-wm {
1755
+ position: absolute;
1756
+ bottom: calc(10px * var(--wm-scale, 1));
1757
+ right: calc(12px * var(--wm-scale, 1));
1758
+ z-index: 10;
1759
+ display: flex;
1760
+ align-items: center;
1761
+ gap: calc(5px * var(--wm-scale, 1));
1762
+ background: none;
1763
+ border: none;
1764
+ padding: 0;
1765
+ text-decoration: none;
1766
+ opacity: 0.7;
1767
+ transition: opacity 0.2s ease;
1768
+ cursor: pointer;
1769
+ transform-origin: bottom right;
1770
+ }
1771
+
1772
+ .unicourse-page-wm:hover {
1773
+ opacity: 1;
1774
+ }
1775
+
1776
+ .unicourse-page-wm img {
1777
+ height: calc(26px * var(--wm-scale, 1));
1778
+ width: auto;
1779
+ display: block;
1780
+ pointer-events: none;
1781
+ flex-shrink: 0;
1782
+ }
1783
+
1784
+ .unicourse-page-wm-text {
1785
+ display: flex;
1786
+ flex-direction: column;
1787
+ align-items: flex-start;
1788
+ gap: calc(1px * var(--wm-scale, 1));
1789
+ }
1790
+
1791
+ .unicourse-page-wm-text span {
1792
+ font-size: calc(7.5px * var(--wm-scale, 1));
1793
+ color: #888;
1794
+ white-space: nowrap;
1795
+ line-height: 1.2;
1796
+ font-weight: 600;
1797
+ }
1798
+
1799
+ @media print {
1800
+ .unicourse-page-wm { display: none !important; }
1801
+ }
1752
1802
  </style>
1753
1803
  </head>
1754
1804
 
@@ -2279,24 +2329,30 @@
2279
2329
  // Thumbnails will be generated on-demand when sidebar opens
2280
2330
  }
2281
2331
 
2282
- // Partial XOR decoder - must match backend encoding
2283
- function partialXorDecode(encodedData, keyBase64) {
2284
- const key = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0));
2285
- const data = new Uint8Array(encodedData);
2286
- const keyLen = key.length;
2332
+ // AES-256-GCM decoder using Web Crypto API
2333
+ async function aesGcmDecode(encodedData, keyBase64, ivBase64) {
2334
+ const keyBytes = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0));
2335
+ const iv = Uint8Array.from(atob(ivBase64), c => c.charCodeAt(0));
2336
+ const encData = new Uint8Array(encodedData);
2287
2337
 
2288
- // Decrypt first 10KB fully
2289
- const fullDecryptLen = Math.min(10240, data.length);
2290
- for (let i = 0; i < fullDecryptLen; i++) {
2291
- data[i] = data[i] ^ key[i % keyLen];
2292
- }
2338
+ // Format: [authTag (16 bytes)] [ciphertext]
2339
+ const authTag = encData.slice(0, 16);
2340
+ const ciphertext = encData.slice(16);
2293
2341
 
2294
- // Decrypt every 50th byte after that
2295
- for (let i = fullDecryptLen; i < data.length; i += 50) {
2296
- data[i] = data[i] ^ key[i % keyLen];
2297
- }
2342
+ // Web Crypto expects ciphertext + authTag concatenated
2343
+ const combined = new Uint8Array(ciphertext.length + authTag.length);
2344
+ combined.set(ciphertext);
2345
+ combined.set(authTag, ciphertext.length);
2298
2346
 
2299
- return data.buffer;
2347
+ const cryptoKey = await crypto.subtle.importKey(
2348
+ 'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']
2349
+ );
2350
+
2351
+ return crypto.subtle.decrypt(
2352
+ { name: 'AES-GCM', iv: iv },
2353
+ cryptoKey,
2354
+ combined
2355
+ );
2300
2356
  }
2301
2357
 
2302
2358
  function injectPageLock(pageEl) {
@@ -2321,8 +2377,8 @@
2321
2377
  <div class="page-lock-message">Tum sayfalara erisim icin Premium satin alabilir veya materyal yukleyerek erisim kazanabilirsiniz.</div>\
2322
2378
  <div class="page-lock-actions">\
2323
2379
  <a href="' + checkoutUrl + '" target="_blank" class="page-lock-button">\
2324
- <svg viewBox="0 0 24 24" width="18" height="18" fill="#1a1a1a"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zm-8-1h2v-4h4v-2h-4V7h-2v4H8v2h4v4z"/></svg>\
2325
- Premium Satin Al\
2380
+ <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>\
2381
+ Hesabini Yukselt\
2326
2382
  </a>\
2327
2383
  <div class="page-lock-divider">ya da</div>\
2328
2384
  <a href="https://forum.ieu.app/material-info" target="_blank" class="page-lock-button-secondary">\
@@ -2517,7 +2573,8 @@
2517
2573
  if (!pdfBuffer) {
2518
2574
  // Nonce and key are embedded in HTML config (not fetched from API)
2519
2575
  const nonce = config.nonce;
2520
- const xorKey = config.dk;
2576
+ const decryptKey = config.dk;
2577
+ const decryptIv = config.iv;
2521
2578
 
2522
2579
  // Fetch encrypted PDF binary
2523
2580
  const pdfUrl = config.relativePath + '/api/v3/plugins/pdf-secure/pdf-data?nonce=' + encodeURIComponent(nonce);
@@ -2530,10 +2587,10 @@
2530
2587
  const encodedBuffer = await pdfRes.arrayBuffer();
2531
2588
  console.log('[PDF-Secure] Encrypted data received:', encodedBuffer.byteLength, 'bytes');
2532
2589
 
2533
- // Decode XOR encrypted data
2534
- if (xorKey) {
2535
- console.log('[PDF-Secure] Decoding XOR encrypted data...');
2536
- pdfBuffer = partialXorDecode(encodedBuffer, xorKey);
2590
+ // Decrypt AES-256-GCM encrypted data
2591
+ if (decryptKey && decryptIv) {
2592
+ console.log('[PDF-Secure] Decrypting AES-256-GCM data...');
2593
+ pdfBuffer = await aesGcmDecode(encodedBuffer, decryptKey, decryptIv);
2537
2594
  } else {
2538
2595
  pdfBuffer = encodedBuffer;
2539
2596
  }
@@ -2611,7 +2668,7 @@
2611
2668
  <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-2h2v2zm0-4h-2V7h2v6z"/>
2612
2669
  </svg>
2613
2670
  <h2>Hata</h2>
2614
- <p>${err.message}</p>
2671
+ <p>${err.message.replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c]))}</p>
2615
2672
  `;
2616
2673
  }
2617
2674
  }
@@ -2688,9 +2745,45 @@
2688
2745
  currentDrawingPage = null;
2689
2746
  });
2690
2747
 
2748
+ // ── Unicourse page watermark injection ──
2749
+ function injectPageWatermark(pageEl) {
2750
+ if (!pageEl) return;
2751
+ var scale = pdfViewer ? pdfViewer.currentScale : 1;
2752
+ var existing = pageEl.querySelector('.unicourse-page-wm');
2753
+ if (existing) {
2754
+ existing.style.setProperty('--wm-scale', scale);
2755
+ return;
2756
+ }
2757
+ pageEl.style.position = 'relative';
2758
+ var a = document.createElement('a');
2759
+ a.href = 'https://unicourse.co/dersler/izmir-ekonomi-universitesi';
2760
+ a.target = '_blank';
2761
+ a.rel = 'noopener';
2762
+ a.className = 'unicourse-page-wm';
2763
+ a.style.setProperty('--wm-scale', scale);
2764
+ a.innerHTML = '<img src="https://cdn.prod.website-files.com/66c986ba4ac79c4fbbd5f45e/66c9958f0188acfb12a2d2fb_64a464c6b3b7df67c22a255b_logo_full.png" alt="unicourse">' +
2765
+ '<div class="unicourse-page-wm-text">' +
2766
+ '<span>Çıkmış sorular ve</span>' +
2767
+ '<span>ders notları için tıkla</span>' +
2768
+ '</div>';
2769
+ pageEl.appendChild(a);
2770
+ }
2771
+
2772
+ // Update all watermarks when zoom changes
2773
+ function updateAllWatermarkScales() {
2774
+ var scale = pdfViewer ? pdfViewer.currentScale : 1;
2775
+ document.querySelectorAll('.unicourse-page-wm').forEach(function(wm) {
2776
+ wm.style.setProperty('--wm-scale', scale);
2777
+ });
2778
+ }
2779
+
2691
2780
  eventBus.on('pagerendered', (evt) => {
2692
2781
  injectAnnotationLayer(evt.pageNumber);
2693
2782
 
2783
+ // Inject unicourse watermark on every page
2784
+ var wmPageEl = document.querySelector('#viewer .page[data-page-number="' + evt.pageNumber + '"]');
2785
+ if (wmPageEl) injectPageWatermark(wmPageEl);
2786
+
2694
2787
  // Re-inject lock overlay if this is a locked page (PDF.js re-render removes it)
2695
2788
  if (premiumInfo && !premiumInfo.isPremium && evt.pageNumber > 1) {
2696
2789
  var pageEl = document.querySelector('#viewer .page[data-page-number="' + evt.pageNumber + '"]');
@@ -2709,8 +2802,8 @@
2709
2802
  };
2710
2803
 
2711
2804
  // Zoom
2712
- document.getElementById('zoomIn').onclick = () => pdfViewer.currentScale += 0.25;
2713
- document.getElementById('zoomOut').onclick = () => pdfViewer.currentScale -= 0.25;
2805
+ document.getElementById('zoomIn').onclick = () => { pdfViewer.currentScale += 0.25; updateAllWatermarkScales(); };
2806
+ document.getElementById('zoomOut').onclick = () => { pdfViewer.currentScale -= 0.25; updateAllWatermarkScales(); };
2714
2807
 
2715
2808
  // Sidebar toggle (deferred thumbnail generation)
2716
2809
  const sidebarEl = document.getElementById('sidebar');
@@ -4794,18 +4887,21 @@
4794
4887
  e.preventDefault();
4795
4888
  e.stopPropagation();
4796
4889
  pdfViewer.currentScale += 0.25;
4890
+ updateAllWatermarkScales();
4797
4891
  return;
4798
4892
  }
4799
4893
  if ((e.ctrlKey || e.metaKey) && (key === '-' || e.code === 'Minus')) {
4800
4894
  e.preventDefault();
4801
4895
  e.stopPropagation();
4802
4896
  pdfViewer.currentScale -= 0.25;
4897
+ updateAllWatermarkScales();
4803
4898
  return;
4804
4899
  }
4805
4900
  if ((e.ctrlKey || e.metaKey) && (key === '0' || e.code === 'Digit0')) {
4806
4901
  e.preventDefault();
4807
4902
  e.stopPropagation();
4808
4903
  pdfViewer.currentScaleValue = 'page-width';
4904
+ updateAllWatermarkScales();
4809
4905
  return;
4810
4906
  }
4811
4907
 
@@ -4889,8 +4985,8 @@
4889
4985
  case 'highlight': setTool('highlight'); break;
4890
4986
  case 'pen': setTool('pen'); break;
4891
4987
  case 'text': setTool('text'); break;
4892
- case 'zoomIn': pdfViewer.currentScale += 0.25; break;
4893
- case 'zoomOut': pdfViewer.currentScale -= 0.25; break;
4988
+ case 'zoomIn': pdfViewer.currentScale += 0.25; updateAllWatermarkScales(); break;
4989
+ case 'zoomOut': pdfViewer.currentScale -= 0.25; updateAllWatermarkScales(); break;
4894
4990
  case 'sepia': document.getElementById('sepiaBtn').click(); break;
4895
4991
  }
4896
4992
  contextMenu.classList.remove('visible');
@@ -4908,7 +5004,7 @@
4908
5004
  if (inIframe) {
4909
5005
  // In iframe: ask parent to fullscreen the iframe element
4910
5006
  // Parent manages fullscreen → more stable on tablets
4911
- window.parent.postMessage({ type: 'pdf-secure-fullscreen-toggle' }, '*');
5007
+ window.parent.postMessage({ type: 'pdf-secure-fullscreen-toggle' }, window.location.origin);
4912
5008
  } else {
4913
5009
  // Standalone: use local fullscreen
4914
5010
  const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
@@ -4995,6 +5091,7 @@
4995
5091
  } else {
4996
5092
  pdfViewer.currentScale -= 0.1;
4997
5093
  }
5094
+ updateAllWatermarkScales();
4998
5095
  }
4999
5096
  }, { passive: false });
5000
5097
 
@@ -5181,6 +5278,7 @@
5181
5278
  const oldScrollTop = container.scrollTop;
5182
5279
 
5183
5280
  pdfViewer.currentScale = finalScale;
5281
+ updateAllWatermarkScales();
5184
5282
 
5185
5283
  // Adjust scroll after PDF.js re-render to keep view centered
5186
5284
  requestAnimationFrame(() => {
@@ -5459,6 +5557,7 @@
5459
5557
  // End of main IIFE - pdfDoc, pdfViewer not accessible from console
5460
5558
  })();
5461
5559
  </script>
5560
+
5462
5561
  </body>
5463
5562
 
5464
5563
  </html>