@yezack/dsh-remote-mobile 1.9.1 → 1.10.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.
@@ -16,403 +16,404 @@ export function getLoginPageHtml(publicKeyPem, lang = 'zh') {
16
16
  success: isEn ? 'Authorization successful! Entering workspace...' : '授权成功!正在进入工作区...',
17
17
  networkErr: isEn ? 'Network connection error, please try again' : '网络连接异常,请检查网络',
18
18
  };
19
- return `<!DOCTYPE html>
20
- <html lang="${isEn ? 'en' : 'zh-CN'}">
21
- <head>
22
- <meta charset="UTF-8">
23
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
24
- <title>${i18n.pageTitle}</title>
25
- <style>
26
- :root {
27
- --bg: #0f1117;
28
- --card-bg: rgba(26, 29, 39, 0.85);
29
- --card-border: rgba(255, 255, 255, 0.08);
30
- --primary: #3b82f6;
31
- --primary-hover: #2563eb;
32
- --text: #f3f4f6;
33
- --text-muted: #9ca3af;
34
- --error: #ef4444;
35
- --success: #10b981;
36
- }
37
- @media (prefers-color-scheme: light) {
38
- :root {
39
- --bg: #f3f4f6;
40
- --card-bg: rgba(255, 255, 255, 0.9);
41
- --card-border: rgba(0, 0, 0, 0.08);
42
- --text: #111827;
43
- --text-muted: #6b7280;
44
- }
45
- }
46
- * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
47
- body {
48
- background: var(--bg);
49
- color: var(--text);
50
- min-height: 100vh;
51
- display: flex;
52
- align-items: center;
53
- justify-content: center;
54
- padding: 20px;
55
- }
56
- .auth-card {
57
- width: 100%;
58
- max-width: 380px;
59
- background: var(--card-bg);
60
- backdrop-filter: blur(16px);
61
- -webkit-backdrop-filter: blur(16px);
62
- border: 1px solid var(--card-border);
63
- border-radius: 20px;
64
- padding: 32px 24px;
65
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
66
- text-align: center;
67
- }
68
- .logo-badge {
69
- display: inline-flex;
70
- align-items: center;
71
- justify-content: center;
72
- width: 56px;
73
- height: 56px;
74
- background: linear-gradient(135deg, #3b82f6, #8b5cf6);
75
- border-radius: 16px;
76
- margin-bottom: 20px;
77
- color: white;
78
- box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3);
79
- }
80
- h1 {
81
- font-size: 20px;
82
- font-weight: 700;
83
- margin-bottom: 8px;
84
- }
85
- p.desc {
86
- font-size: 13px;
87
- color: var(--text-muted);
88
- line-height: 1.5;
89
- margin-bottom: 24px;
90
- }
91
- .input-group {
92
- margin-bottom: 16px;
93
- }
94
- input {
95
- width: 100%;
96
- height: 48px;
97
- background: rgba(128, 128, 128, 0.08);
98
- border: 1px solid var(--card-border);
99
- border-radius: 12px;
100
- padding: 0 16px;
101
- font-size: 16px;
102
- color: var(--text);
103
- outline: none;
104
- text-align: center;
105
- letter-spacing: 2px;
106
- transition: all 0.2s;
107
- }
108
- input:focus {
109
- border-color: var(--primary);
110
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
111
- }
112
- button.submit-btn {
113
- width: 100%;
114
- height: 48px;
115
- background: var(--primary);
116
- color: white;
117
- border: none;
118
- border-radius: 12px;
119
- font-size: 15px;
120
- font-weight: 600;
121
- cursor: pointer;
122
- transition: background 0.2s;
123
- display: flex;
124
- align-items: center;
125
- justify-content: center;
126
- gap: 8px;
127
- }
128
- button.submit-btn:active {
129
- background: var(--primary-hover);
130
- transform: scale(0.99);
131
- }
132
- .status-msg {
133
- margin-top: 16px;
134
- font-size: 13px;
135
- display: none;
136
- }
137
- .status-msg.error {
138
- color: var(--error);
139
- display: block;
140
- }
141
- .status-msg.success {
142
- color: var(--success);
143
- display: block;
144
- }
145
- .info-footer {
146
- margin-top: 24px;
147
- padding-top: 16px;
148
- border-top: 1px solid var(--card-border);
149
- font-size: 12px;
150
- color: var(--text-muted);
151
- line-height: 1.6;
152
- text-align: left;
153
- }
154
- </style>
155
- </head>
156
- <body>
157
- <div class="auth-card">
158
- <div class="logo-badge">
159
- <svg style="width: 28px; height: 28px; fill: currentColor;" viewBox="0 0 24 24">
160
- <path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/>
161
- </svg>
162
- </div>
163
- <h1>${i18n.headerTitle}</h1>
164
- <p class="desc">${i18n.headerDesc}</p>
165
-
166
- <form id="authForm">
167
- <div class="input-group">
168
- <input
169
- type="password"
170
- id="credentialInput"
171
- placeholder="${i18n.placeholder}"
172
- autocomplete="off"
173
- required
174
- />
175
- </div>
176
- <button type="submit" class="submit-btn" id="submitBtn">
177
- ${i18n.submitBtn}
178
- </button>
179
- <div id="statusMsg" class="status-msg"></div>
180
- </form>
181
-
182
- <div class="info-footer">
183
- <div>${i18n.footerSecurity}</div>
184
- </div>
185
- </div>
186
-
187
- <script>
188
- const SERVER_RSA_KEY = ${JSON.stringify(inlinePublicKey)};
189
- const form = document.getElementById('authForm');
190
- const input = document.getElementById('credentialInput');
191
- const statusMsg = document.getElementById('statusMsg');
192
- const submitBtn = document.getElementById('submitBtn');
193
-
194
- // 解析 SPKI 格式公钥并提取 n 和 e(用于纯 JS 垫片计算)
195
- function parseSpkiKey(pemStr) {
196
- const b64 = pemStr.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\\s/g, '');
197
- const binary = window.atob(b64);
198
- const bytes = new Uint8Array(binary.length);
199
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
200
-
201
- let idx = 0;
202
- while (idx < bytes.length && bytes[idx] !== 0x02) idx++;
203
- const lenByte = bytes[idx + 1];
204
- let nLen = 0;
205
- let nStart = 0;
206
- if (lenByte === 0x82) {
207
- nLen = (bytes[idx + 2] << 8) | bytes[idx + 3];
208
- nStart = idx + 4;
209
- } else if (lenByte === 0x81) {
210
- nLen = bytes[idx + 2];
211
- nStart = idx + 3;
212
- } else {
213
- nLen = lenByte;
214
- nStart = idx + 2;
215
- }
216
- let nBytes = bytes.slice(nStart, nStart + nLen);
217
- if (nBytes[0] === 0x00) nBytes = nBytes.slice(1);
218
-
219
- idx = nStart + nLen;
220
- while (idx < bytes.length && bytes[idx] !== 0x02) idx++;
221
- const eLen = bytes[idx + 1];
222
- const eBytes = bytes.slice(idx + 2, idx + 2 + eLen);
223
-
224
- let nHex = '';
225
- for (let b of nBytes) nHex += b.toString(16).padStart(2, '0');
226
- let eHex = '';
227
- for (let b of eBytes) eHex += b.toString(16).padStart(2, '0');
228
-
229
- return { n: BigInt('0x' + nHex), e: BigInt('0x' + eHex), keyLen: nBytes.length };
230
- }
231
-
232
- function modPow(b, exp, mod) {
233
- let res = 1n;
234
- let base = b % mod;
235
- let e = exp;
236
- while (e > 0n) {
237
- if (e & 1n) res = (res * base) % mod;
238
- base = (base * base) % mod;
239
- e >>= 1n;
240
- }
241
- return res;
242
- }
243
-
244
- // 纯 JS RSA 垫片加密:使用 CSPRNG (crypto.getRandomValues) 填充
245
- function rsaEncryptPureJs(plainText, pubPem) {
246
- const { n, e, keyLen } = parseSpkiKey(pubPem);
247
- const msgBytes = new TextEncoder().encode(plainText);
248
- const padLen = keyLen - 3 - msgBytes.length;
249
- if (padLen < 8) throw new Error('Message too long');
250
-
251
- const pad = new Uint8Array(padLen);
252
- if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
253
- window.crypto.getRandomValues(pad);
254
- for (let i = 0; i < padLen; i++) {
255
- if (pad[i] === 0) pad[i] = (Math.floor(Math.random() * 254) + 1);
256
- }
257
- } else {
258
- for (let i = 0; i < padLen; i++) {
259
- pad[i] = Math.floor(Math.random() * 255) + 1;
260
- }
261
- }
262
-
263
- const em = new Uint8Array(keyLen);
264
- em[0] = 0x00;
265
- em[1] = 0x02;
266
- em.set(pad, 2);
267
- em[2 + padLen] = 0x00;
268
- em.set(msgBytes, 3 + padLen);
269
-
270
- let emHex = '';
271
- for (let b of em) emHex += b.toString(16).padStart(2, '0');
272
- const m = BigInt('0x' + emHex);
273
- const c = modPow(m, e, n);
274
- let cHex = c.toString(16);
275
- if (cHex.length % 2 !== 0) cHex = '0' + cHex;
276
- while (cHex.length < keyLen * 2) cHex = '00' + cHex;
277
-
278
- let binary = '';
279
- for (let i = 0; i < cHex.length; i += 2) {
280
- binary += String.fromCharCode(parseInt(cHex.slice(i, i + 2), 16));
281
- }
282
- return window.btoa(binary);
283
- }
284
-
285
- // 尝试使用 Web Crypto API 原生 RSA-OAEP SHA-256 加密(安全上下文可用)
286
- async function trySubtleEncrypt(plainText, pubPem) {
287
- if (!window.crypto || !window.crypto.subtle) return null;
288
- const b64 = pubPem.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\\s/g, '');
289
- const binary = window.atob(b64);
290
- const bytes = new Uint8Array(binary.length);
291
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
292
-
293
- const cryptoKey = await window.crypto.subtle.importKey(
294
- 'spki',
295
- bytes.buffer,
296
- { name: 'RSA-OAEP', hash: 'SHA-256' },
297
- false,
298
- ['encrypt']
299
- );
300
-
301
- const encBuffer = await window.crypto.subtle.encrypt(
302
- { name: 'RSA-OAEP' },
303
- cryptoKey,
304
- new TextEncoder().encode(plainText)
305
- );
306
-
307
- const encBytes = new Uint8Array(encBuffer);
308
- let binaryEnc = '';
309
- for (let i = 0; i < encBytes.length; i++) binaryEnc += String.fromCharCode(encBytes[i]);
310
- return window.btoa(binaryEnc);
311
- }
312
-
313
- async function encryptCredential(plainText) {
314
- try {
315
- const subtleB64 = await trySubtleEncrypt(plainText, SERVER_RSA_KEY);
316
- if (subtleB64) return { encryptedCredential: subtleB64 };
317
- } catch (e) {}
318
-
319
- try {
320
- const pureB64 = rsaEncryptPureJs(plainText, SERVER_RSA_KEY);
321
- return { encryptedCredential: pureB64 };
322
- } catch (err) {
323
- console.error('RSA encrypt error:', err);
324
- return { credential: plainText };
325
- }
326
- }
327
-
328
- // 检测 URL 参数里的 token,仅回填输入框,不自动触发验证
329
- const urlParams = new URLSearchParams(window.location.search);
330
- const queryToken = urlParams.get('token');
331
- if (queryToken) {
332
- input.value = queryToken;
333
- }
334
-
335
- // 自适应网关子路径 / baseURI 与重定向
336
- const verifyApiUrl = (function () {
337
- try {
338
- if (document.baseURI && document.querySelector('base')) {
339
- return new URL('api/remote-mobile/verify', document.baseURI).toString();
340
- }
341
- // ⚠️ 本段内联脚本位于模板字符串内:反斜杠在字符串求值时会被吃掉,
342
- // 所以 /auth 后缀正则必须写成双反斜杠形式,否则会拼出以两条斜杠开头
343
- // 的行注释,使整个 script 抛 SyntaxError、登录按钮彻底失效(下两处同理)。
344
- const pathname = window.location.pathname.replace(/\\/auth\\/?$/, '/');
345
- return new URL('api/remote-mobile/verify', window.location.origin + pathname).toString();
346
- } catch (e) {
347
- return '/api/remote-mobile/verify';
348
- }
349
- })();
350
-
351
- const successRedirectUrl = (function () {
352
- try {
353
- // 预留参数能力:支持通过 ?redirect=/path 携带登录后重定向目标
354
- const redirectParam = urlParams.get('redirect');
355
- if (redirectParam && redirectParam.startsWith('/') && !redirectParam.startsWith('//')) {
356
- const appPrefix = window.location.pathname.replace(/\\/auth\\/?$/, '');
357
- if (appPrefix && redirectParam.startsWith(appPrefix)) {
358
- return window.location.origin + redirectParam;
359
- }
360
- return window.location.origin + appPrefix + redirectParam;
361
- }
362
- if (document.baseURI && document.querySelector('base')) {
363
- return new URL('./', document.baseURI).toString();
364
- }
365
- const appPrefix = window.location.pathname.replace(/\\/auth\\/?$/, '/');
366
- return window.location.origin + appPrefix;
367
- } catch (e) {
368
- return '/';
369
- }
370
- })();
371
-
372
- form.addEventListener('submit', (e) => {
373
- e.preventDefault();
374
- const val = input.value.trim();
375
- if (!val) return;
376
- doVerify(val);
377
- });
378
-
379
- async function doVerify(credential) {
380
- submitBtn.disabled = true;
381
- submitBtn.innerText = ${JSON.stringify(i18n.verifying)};
382
- statusMsg.className = 'status-msg';
383
- statusMsg.style.display = 'none';
384
-
385
- try {
386
- const payload = await encryptCredential(credential);
387
- const res = await fetch(verifyApiUrl, {
388
- method: 'POST',
389
- headers: { 'Content-Type': 'application/json' },
390
- body: JSON.stringify(payload)
391
- });
392
- const data = await res.json();
393
- if (data.success) {
394
- statusMsg.className = 'status-msg success';
395
- statusMsg.innerText = ${JSON.stringify(i18n.success)};
396
- statusMsg.style.display = 'block';
397
- setTimeout(() => {
398
- window.location.href = successRedirectUrl;
399
- }, 600);
400
- } else {
401
- statusMsg.className = 'status-msg error';
402
- statusMsg.innerText = data.reason || ${JSON.stringify(isEn ? 'Authorization failed, please try again' : '授权失败,请重试')};
403
- statusMsg.style.display = 'block';
404
- submitBtn.disabled = false;
405
- submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
406
- }
407
- } catch (err) {
408
- statusMsg.className = 'status-msg error';
409
- statusMsg.innerText = ${JSON.stringify(i18n.networkErr)};
410
- statusMsg.style.display = 'block';
411
- submitBtn.disabled = false;
412
- submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
413
- }
414
- }
415
- </script>
416
- </body>
19
+ return `<!DOCTYPE html>
20
+ <html lang="${isEn ? 'en' : 'zh-CN'}">
21
+ <head>
22
+ <meta charset="UTF-8">
23
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
24
+ <title>${i18n.pageTitle}</title>
25
+ <style>
26
+ :root {
27
+ --bg: #0f1117;
28
+ --card-bg: rgba(26, 29, 39, 0.85);
29
+ --card-border: rgba(255, 255, 255, 0.08);
30
+ --primary: #3b82f6;
31
+ --primary-hover: #2563eb;
32
+ --text: #f3f4f6;
33
+ --text-muted: #9ca3af;
34
+ --error: #ef4444;
35
+ --success: #10b981;
36
+ }
37
+ @media (prefers-color-scheme: light) {
38
+ :root {
39
+ --bg: #f3f4f6;
40
+ --card-bg: rgba(255, 255, 255, 0.9);
41
+ --card-border: rgba(0, 0, 0, 0.08);
42
+ --text: #111827;
43
+ --text-muted: #6b7280;
44
+ }
45
+ }
46
+ * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
47
+ body {
48
+ background: var(--bg);
49
+ color: var(--text);
50
+ min-height: 100vh;
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ padding: 20px;
55
+ }
56
+ .auth-card {
57
+ width: 100%;
58
+ max-width: 380px;
59
+ background: var(--card-bg);
60
+ backdrop-filter: blur(16px);
61
+ -webkit-backdrop-filter: blur(16px);
62
+ border: 1px solid var(--card-border);
63
+ border-radius: 20px;
64
+ padding: 32px 24px;
65
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
66
+ text-align: center;
67
+ }
68
+ .logo-badge {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ width: 56px;
73
+ height: 56px;
74
+ background: linear-gradient(135deg, #3b82f6, #8b5cf6);
75
+ border-radius: 16px;
76
+ margin-bottom: 20px;
77
+ color: white;
78
+ box-shadow: 0 8px 16px rgba(59, 130, 246, 0.3);
79
+ }
80
+ h1 {
81
+ font-size: 20px;
82
+ font-weight: 700;
83
+ margin-bottom: 8px;
84
+ }
85
+ p.desc {
86
+ font-size: 13px;
87
+ color: var(--text-muted);
88
+ line-height: 1.5;
89
+ margin-bottom: 24px;
90
+ }
91
+ .input-group {
92
+ margin-bottom: 16px;
93
+ }
94
+ input {
95
+ width: 100%;
96
+ height: 48px;
97
+ background: rgba(128, 128, 128, 0.08);
98
+ border: 1px solid var(--card-border);
99
+ border-radius: 12px;
100
+ padding: 0 16px;
101
+ font-size: 16px;
102
+ color: var(--text);
103
+ outline: none;
104
+ text-align: center;
105
+ letter-spacing: 2px;
106
+ transition: all 0.2s;
107
+ }
108
+ input:focus {
109
+ border-color: var(--primary);
110
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
111
+ }
112
+ button.submit-btn {
113
+ width: 100%;
114
+ height: 48px;
115
+ background: var(--primary);
116
+ color: white;
117
+ border: none;
118
+ border-radius: 12px;
119
+ font-size: 15px;
120
+ font-weight: 600;
121
+ cursor: pointer;
122
+ transition: background 0.2s;
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: center;
126
+ gap: 8px;
127
+ }
128
+ button.submit-btn:active {
129
+ background: var(--primary-hover);
130
+ transform: scale(0.99);
131
+ }
132
+ .status-msg {
133
+ margin-top: 16px;
134
+ font-size: 13px;
135
+ display: none;
136
+ }
137
+ .status-msg.error {
138
+ color: var(--error);
139
+ display: block;
140
+ }
141
+ .status-msg.success {
142
+ color: var(--success);
143
+ display: block;
144
+ }
145
+ .info-footer {
146
+ margin-top: 24px;
147
+ padding-top: 16px;
148
+ border-top: 1px solid var(--card-border);
149
+ font-size: 12px;
150
+ color: var(--text-muted);
151
+ line-height: 1.6;
152
+ text-align: left;
153
+ }
154
+ </style>
155
+ </head>
156
+ <body>
157
+ <div class="auth-card">
158
+ <div class="logo-badge">
159
+ <svg style="width: 28px; height: 28px; fill: currentColor;" viewBox="0 0 24 24">
160
+ <path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/>
161
+ </svg>
162
+ </div>
163
+ <h1>${i18n.headerTitle}</h1>
164
+ <p class="desc">${i18n.headerDesc}</p>
165
+
166
+ <form id="authForm">
167
+ <div class="input-group">
168
+ <input
169
+ type="password"
170
+ id="credentialInput"
171
+ placeholder="${i18n.placeholder}"
172
+ autocomplete="off"
173
+ required
174
+ />
175
+ </div>
176
+ <button type="submit" class="submit-btn" id="submitBtn">
177
+ ${i18n.submitBtn}
178
+ </button>
179
+ <div id="statusMsg" class="status-msg"></div>
180
+ </form>
181
+
182
+ <div class="info-footer">
183
+ <div>${i18n.footerSecurity}</div>
184
+ </div>
185
+ </div>
186
+
187
+ <script>
188
+ const SERVER_RSA_KEY = ${JSON.stringify(inlinePublicKey)};
189
+ const form = document.getElementById('authForm');
190
+ const input = document.getElementById('credentialInput');
191
+ const statusMsg = document.getElementById('statusMsg');
192
+ const submitBtn = document.getElementById('submitBtn');
193
+
194
+ // 解析 SPKI 格式公钥并提取 n 和 e(用于纯 JS 垫片计算)
195
+ function parseSpkiKey(pemStr) {
196
+ const b64 = pemStr.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\\s/g, '');
197
+ const binary = window.atob(b64);
198
+ const bytes = new Uint8Array(binary.length);
199
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
200
+
201
+ let idx = 0;
202
+ while (idx < bytes.length && bytes[idx] !== 0x02) idx++;
203
+ const lenByte = bytes[idx + 1];
204
+ let nLen = 0;
205
+ let nStart = 0;
206
+ if (lenByte === 0x82) {
207
+ nLen = (bytes[idx + 2] << 8) | bytes[idx + 3];
208
+ nStart = idx + 4;
209
+ } else if (lenByte === 0x81) {
210
+ nLen = bytes[idx + 2];
211
+ nStart = idx + 3;
212
+ } else {
213
+ nLen = lenByte;
214
+ nStart = idx + 2;
215
+ }
216
+ let nBytes = bytes.slice(nStart, nStart + nLen);
217
+ if (nBytes[0] === 0x00) nBytes = nBytes.slice(1);
218
+
219
+ idx = nStart + nLen;
220
+ while (idx < bytes.length && bytes[idx] !== 0x02) idx++;
221
+ const eLen = bytes[idx + 1];
222
+ const eBytes = bytes.slice(idx + 2, idx + 2 + eLen);
223
+
224
+ let nHex = '';
225
+ for (let b of nBytes) nHex += b.toString(16).padStart(2, '0');
226
+ let eHex = '';
227
+ for (let b of eBytes) eHex += b.toString(16).padStart(2, '0');
228
+
229
+ return { n: BigInt('0x' + nHex), e: BigInt('0x' + eHex), keyLen: nBytes.length };
230
+ }
231
+
232
+ function modPow(b, exp, mod) {
233
+ let res = 1n;
234
+ let base = b % mod;
235
+ let e = exp;
236
+ while (e > 0n) {
237
+ if (e & 1n) res = (res * base) % mod;
238
+ base = (base * base) % mod;
239
+ e >>= 1n;
240
+ }
241
+ return res;
242
+ }
243
+
244
+ // 纯 JS RSA 垫片加密:使用 CSPRNG (crypto.getRandomValues) 填充
245
+ function rsaEncryptPureJs(plainText, pubPem) {
246
+ const { n, e, keyLen } = parseSpkiKey(pubPem);
247
+ const msgBytes = new TextEncoder().encode(plainText);
248
+ const padLen = keyLen - 3 - msgBytes.length;
249
+ if (padLen < 8) throw new Error('Message too long');
250
+
251
+ const pad = new Uint8Array(padLen);
252
+ if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
253
+ window.crypto.getRandomValues(pad);
254
+ for (let i = 0; i < padLen; i++) {
255
+ if (pad[i] === 0) pad[i] = (Math.floor(Math.random() * 254) + 1);
256
+ }
257
+ } else {
258
+ for (let i = 0; i < padLen; i++) {
259
+ pad[i] = Math.floor(Math.random() * 255) + 1;
260
+ }
261
+ }
262
+
263
+ const em = new Uint8Array(keyLen);
264
+ em[0] = 0x00;
265
+ em[1] = 0x02;
266
+ em.set(pad, 2);
267
+ em[2 + padLen] = 0x00;
268
+ em.set(msgBytes, 3 + padLen);
269
+
270
+ let emHex = '';
271
+ for (let b of em) emHex += b.toString(16).padStart(2, '0');
272
+ const m = BigInt('0x' + emHex);
273
+ const c = modPow(m, e, n);
274
+ let cHex = c.toString(16);
275
+ if (cHex.length % 2 !== 0) cHex = '0' + cHex;
276
+ while (cHex.length < keyLen * 2) cHex = '00' + cHex;
277
+
278
+ let binary = '';
279
+ for (let i = 0; i < cHex.length; i += 2) {
280
+ binary += String.fromCharCode(parseInt(cHex.slice(i, i + 2), 16));
281
+ }
282
+ return window.btoa(binary);
283
+ }
284
+
285
+ // 尝试使用 Web Crypto API 原生 RSA-OAEP SHA-256 加密(安全上下文可用)
286
+ async function trySubtleEncrypt(plainText, pubPem) {
287
+ if (!window.crypto || !window.crypto.subtle) return null;
288
+ const b64 = pubPem.replace(/-----BEGIN PUBLIC KEY-----/g, '').replace(/-----END PUBLIC KEY-----/g, '').replace(/\\s/g, '');
289
+ const binary = window.atob(b64);
290
+ const bytes = new Uint8Array(binary.length);
291
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
292
+
293
+ const cryptoKey = await window.crypto.subtle.importKey(
294
+ 'spki',
295
+ bytes.buffer,
296
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
297
+ false,
298
+ ['encrypt']
299
+ );
300
+
301
+ const encBuffer = await window.crypto.subtle.encrypt(
302
+ { name: 'RSA-OAEP' },
303
+ cryptoKey,
304
+ new TextEncoder().encode(plainText)
305
+ );
306
+
307
+ const encBytes = new Uint8Array(encBuffer);
308
+ let binaryEnc = '';
309
+ for (let i = 0; i < encBytes.length; i++) binaryEnc += String.fromCharCode(encBytes[i]);
310
+ return window.btoa(binaryEnc);
311
+ }
312
+
313
+ async function encryptCredential(plainText) {
314
+ try {
315
+ const subtleB64 = await trySubtleEncrypt(plainText, SERVER_RSA_KEY);
316
+ if (subtleB64) return { encryptedCredential: subtleB64 };
317
+ } catch (e) {}
318
+
319
+ try {
320
+ const pureB64 = rsaEncryptPureJs(plainText, SERVER_RSA_KEY);
321
+ return { encryptedCredential: pureB64 };
322
+ } catch (err) {
323
+ console.error('RSA encrypt error:', err);
324
+ return { credential: plainText };
325
+ }
326
+ }
327
+
328
+ // 检测 URL 参数里的 token,仅回填输入框,不自动触发验证
329
+ const urlParams = new URLSearchParams(window.location.search);
330
+ const queryToken = urlParams.get('token');
331
+ if (queryToken) {
332
+ input.value = queryToken;
333
+ }
334
+
335
+ // 自适应网关子路径 / baseURI 与重定向
336
+ const verifyApiUrl = (function () {
337
+ try {
338
+ if (document.baseURI && document.querySelector('base')) {
339
+ return new URL('api/remote-mobile/verify', document.baseURI).toString();
340
+ }
341
+ // 用 new RegExp 显式构造正则:字符串里不含反斜杠,杜绝外层模板字符串
342
+ // 把转义吃掉后拼出「两条斜杠开头的行注释」,进而使整段内联脚本
343
+ // SyntaxError(历史上曾因此完全无法登录,见 CHANGELOG 1.9.1 / 1.10.0)。
344
+ // 下两处同理。
345
+ const pathname = window.location.pathname.replace(new RegExp('/auth/?$'), '/');
346
+ return new URL('api/remote-mobile/verify', window.location.origin + pathname).toString();
347
+ } catch (e) {
348
+ return '/api/remote-mobile/verify';
349
+ }
350
+ })();
351
+
352
+ const successRedirectUrl = (function () {
353
+ try {
354
+ // 预留参数能力:支持通过 ?redirect=/path 携带登录后重定向目标
355
+ const redirectParam = urlParams.get('redirect');
356
+ if (redirectParam && redirectParam.startsWith('/') && !redirectParam.startsWith('//')) {
357
+ const appPrefix = window.location.pathname.replace(new RegExp('/auth/?$'), '');
358
+ if (appPrefix && redirectParam.startsWith(appPrefix)) {
359
+ return window.location.origin + redirectParam;
360
+ }
361
+ return window.location.origin + appPrefix + redirectParam;
362
+ }
363
+ if (document.baseURI && document.querySelector('base')) {
364
+ return new URL('./', document.baseURI).toString();
365
+ }
366
+ const appPrefix = window.location.pathname.replace(new RegExp('/auth/?$'), '/');
367
+ return window.location.origin + appPrefix;
368
+ } catch (e) {
369
+ return '/';
370
+ }
371
+ })();
372
+
373
+ form.addEventListener('submit', (e) => {
374
+ e.preventDefault();
375
+ const val = input.value.trim();
376
+ if (!val) return;
377
+ doVerify(val);
378
+ });
379
+
380
+ async function doVerify(credential) {
381
+ submitBtn.disabled = true;
382
+ submitBtn.innerText = ${JSON.stringify(i18n.verifying)};
383
+ statusMsg.className = 'status-msg';
384
+ statusMsg.style.display = 'none';
385
+
386
+ try {
387
+ const payload = await encryptCredential(credential);
388
+ const res = await fetch(verifyApiUrl, {
389
+ method: 'POST',
390
+ headers: { 'Content-Type': 'application/json' },
391
+ body: JSON.stringify(payload)
392
+ });
393
+ const data = await res.json();
394
+ if (data.success) {
395
+ statusMsg.className = 'status-msg success';
396
+ statusMsg.innerText = ${JSON.stringify(i18n.success)};
397
+ statusMsg.style.display = 'block';
398
+ setTimeout(() => {
399
+ window.location.href = successRedirectUrl;
400
+ }, 600);
401
+ } else {
402
+ statusMsg.className = 'status-msg error';
403
+ statusMsg.innerText = data.reason || ${JSON.stringify(isEn ? 'Authorization failed, please try again' : '授权失败,请重试')};
404
+ statusMsg.style.display = 'block';
405
+ submitBtn.disabled = false;
406
+ submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
407
+ }
408
+ } catch (err) {
409
+ statusMsg.className = 'status-msg error';
410
+ statusMsg.innerText = ${JSON.stringify(i18n.networkErr)};
411
+ statusMsg.style.display = 'block';
412
+ submitBtn.disabled = false;
413
+ submitBtn.innerText = ${JSON.stringify(i18n.submitBtn)};
414
+ }
415
+ }
416
+ </script>
417
+ </body>
417
418
  </html>`;
418
419
  }