@yezack/dsh-remote-mobile 0.0.0-stage → 1.9.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/CHANGELOG.md +215 -0
- package/LICENSE +21 -0
- package/README.md +455 -3
- package/README_EN.md +454 -0
- package/cordis.patch.yml +4 -0
- package/lib/auth/crypto.d.ts +24 -0
- package/lib/auth/crypto.js +158 -0
- package/lib/auth/gate.d.ts +29 -0
- package/lib/auth/gate.js +139 -0
- package/lib/auth/prefix.d.ts +19 -0
- package/lib/auth/prefix.js +41 -0
- package/lib/auth/tailscale.d.ts +36 -0
- package/lib/auth/tailscale.js +128 -0
- package/lib/auth/token.d.ts +194 -0
- package/lib/auth/token.js +1009 -0
- package/lib/bridge/compat.d.ts +145 -0
- package/lib/bridge/compat.js +837 -0
- package/lib/client.d.ts +5 -0
- package/lib/client.js +4650 -0
- package/lib/index.d.ts +34 -0
- package/lib/index.js +236 -0
- package/lib/routes/api.d.ts +16 -0
- package/lib/routes/api.js +455 -0
- package/lib/routes/login-page.d.ts +4 -0
- package/lib/routes/login-page.js +418 -0
- package/lib/styles/style-snippets.d.ts +133 -0
- package/lib/styles/style-snippets.js +1039 -0
- package/package.json +81 -6
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { AUTH_COOKIE_NAME, readGlobalLocale } from '../auth/token.js';
|
|
2
|
+
import { getLocalTailscaleIp, getLocalLanIp, getAllNetworkIps, getClientIp, isTailscaleIp, isLanIp } from '../auth/tailscale.js';
|
|
3
|
+
import { getPublicKeyPem, decryptWithPrivateKey, validateSecretStrength, RSA_KEY_FILE } from '../auth/crypto.js';
|
|
4
|
+
import { getLoginPageHtml } from './login-page.js';
|
|
5
|
+
import { DEFAULT_STYLE_FILE } from '../styles/style-snippets.js';
|
|
6
|
+
import { getPairingBridgeState } from '../bridge/compat.js';
|
|
7
|
+
import { resolveRedirectPath } from '../auth/prefix.js';
|
|
8
|
+
function jsonResponse(res, status, data) {
|
|
9
|
+
res.writeHead(status, {
|
|
10
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
11
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
12
|
+
});
|
|
13
|
+
res.end(JSON.stringify(data));
|
|
14
|
+
}
|
|
15
|
+
function getRealClientIp(req) {
|
|
16
|
+
const real = req.headers['x-dsh-real-ip'];
|
|
17
|
+
if (typeof real === 'string' && real)
|
|
18
|
+
return real;
|
|
19
|
+
return getClientIp(req);
|
|
20
|
+
}
|
|
21
|
+
function parseJsonBody(req) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
let body = '';
|
|
24
|
+
req.on('data', (chunk) => {
|
|
25
|
+
body += chunk;
|
|
26
|
+
if (body.length > 1e5)
|
|
27
|
+
req.destroy();
|
|
28
|
+
});
|
|
29
|
+
req.on('end', () => {
|
|
30
|
+
try {
|
|
31
|
+
resolve(JSON.parse(body || '{}'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
resolve({});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
req.on('error', () => resolve({}));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export function createRoutes(store, styleStore) {
|
|
41
|
+
return [
|
|
42
|
+
// 0. RSA 公钥获取接口 (RSA-OAEP-SHA256)
|
|
43
|
+
{
|
|
44
|
+
method: 'GET',
|
|
45
|
+
path: '/api/remote-mobile/public-key',
|
|
46
|
+
handler: async (_req, res) => {
|
|
47
|
+
jsonResponse(res, 200, {
|
|
48
|
+
success: true,
|
|
49
|
+
publicKey: getPublicKeyPem(),
|
|
50
|
+
algorithm: 'RSA-OAEP-SHA256',
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
// 1. 状态查询接口 (返回 Tailscale IP、局域网 LAN IP 与 IP 防暴破安全统计)
|
|
55
|
+
{
|
|
56
|
+
method: 'GET',
|
|
57
|
+
path: '/api/remote-mobile/status',
|
|
58
|
+
handler: async (_req, res) => {
|
|
59
|
+
const opts = store.getOptions();
|
|
60
|
+
const tsIp = getLocalTailscaleIp();
|
|
61
|
+
const lanIp = getLocalLanIp();
|
|
62
|
+
const ips = getAllNetworkIps();
|
|
63
|
+
const sessionStats = store.getSessionsList();
|
|
64
|
+
const ipSecurityStats = store.getIpSecurityStats();
|
|
65
|
+
const bridgeState = getPairingBridgeState();
|
|
66
|
+
jsonResponse(res, 200, {
|
|
67
|
+
success: true,
|
|
68
|
+
locale: readGlobalLocale(),
|
|
69
|
+
tailscaleIp: tsIp,
|
|
70
|
+
lanIp,
|
|
71
|
+
networkIps: ips,
|
|
72
|
+
allowTailscale: opts.allowTailscale,
|
|
73
|
+
allowLan: opts.allowLan,
|
|
74
|
+
hasSecret: Boolean(opts.secretHash || opts.secret),
|
|
75
|
+
maxVisitsPerMinute: opts.maxVisitsPerMinute,
|
|
76
|
+
maxFailedAttempts: opts.maxFailedAttempts,
|
|
77
|
+
lockDurationMs: opts.lockDurationMs,
|
|
78
|
+
devicesCount: sessionStats.count,
|
|
79
|
+
devices: sessionStats.devices,
|
|
80
|
+
ipSecurityStats,
|
|
81
|
+
persistPath: store.persistPath,
|
|
82
|
+
rsaKeyPath: RSA_KEY_FILE,
|
|
83
|
+
styleSnippetsPath: styleStore ? styleStore.persistPath : DEFAULT_STYLE_FILE,
|
|
84
|
+
publicKey: getPublicKeyPem(),
|
|
85
|
+
// 与其他远程接入类插件共存的裁决结果(设置页据此展示警示横幅与修复信息)
|
|
86
|
+
pairingBridgeMode: bridgeState.mode,
|
|
87
|
+
pairingBridgeConflict: bridgeState.conflictWith || '',
|
|
88
|
+
pairingBridgeConflictId: bridgeState.conflictEntryId || '',
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
// 2. 生成 6 位短期配对码
|
|
93
|
+
{
|
|
94
|
+
method: 'POST',
|
|
95
|
+
path: '/api/remote-mobile/generate-code',
|
|
96
|
+
handler: async (_req, res) => {
|
|
97
|
+
const info = store.generateShortCode();
|
|
98
|
+
jsonResponse(res, 200, {
|
|
99
|
+
success: true,
|
|
100
|
+
code: info.code,
|
|
101
|
+
expiresAt: info.expiresAt,
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
// 3. 手机端验证与换取长效 Cookie (支持 RSA-OAEP 密文解密 + 防暴力破解 429 拦截)
|
|
106
|
+
{
|
|
107
|
+
method: 'POST',
|
|
108
|
+
path: '/api/remote-mobile/verify',
|
|
109
|
+
handler: async (req, res) => {
|
|
110
|
+
const body = await parseJsonBody(req);
|
|
111
|
+
let credential = '';
|
|
112
|
+
if (body.encryptedCredential) {
|
|
113
|
+
const decrypted = decryptWithPrivateKey(body.encryptedCredential);
|
|
114
|
+
credential = (decrypted || body.credential || '').trim();
|
|
115
|
+
}
|
|
116
|
+
else if (body.credential) {
|
|
117
|
+
credential = String(body.credential).trim();
|
|
118
|
+
}
|
|
119
|
+
const ua = req.headers['user-agent'];
|
|
120
|
+
const ip = getRealClientIp(req);
|
|
121
|
+
const result = store.verify(credential, ua, ip);
|
|
122
|
+
if (result.success && result.token) {
|
|
123
|
+
const maxAge = 365 * 24 * 60 * 60;
|
|
124
|
+
const cookieVal = `${AUTH_COOKIE_NAME}=${result.token}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax`;
|
|
125
|
+
res.writeHead(200, {
|
|
126
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
127
|
+
'Set-Cookie': cookieVal,
|
|
128
|
+
});
|
|
129
|
+
res.end(JSON.stringify({ success: true, token: result.token }));
|
|
130
|
+
}
|
|
131
|
+
else if (result.locked) {
|
|
132
|
+
jsonResponse(res, 429, {
|
|
133
|
+
success: false,
|
|
134
|
+
locked: true,
|
|
135
|
+
remainingSeconds: result.remainingSeconds,
|
|
136
|
+
reason: result.reason || '连续尝试错误过多,IP已被安全锁定',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
jsonResponse(res, 401, { success: false, reason: result.reason || '验证失败' });
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
// 3.1 手动解除某个 IP 的防暴破锁定
|
|
145
|
+
{
|
|
146
|
+
method: 'POST',
|
|
147
|
+
path: '/api/remote-mobile/unlock-ip',
|
|
148
|
+
handler: async (req, res) => {
|
|
149
|
+
const body = await parseJsonBody(req);
|
|
150
|
+
const ip = (body.ip || '').trim();
|
|
151
|
+
const ok = store.unlockIp(ip);
|
|
152
|
+
jsonResponse(res, 200, { success: ok, ip });
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
// 3.2 一键清空所有 IP 访问与安全审计日志
|
|
156
|
+
{
|
|
157
|
+
method: 'POST',
|
|
158
|
+
path: '/api/remote-mobile/clear-ip-stats',
|
|
159
|
+
handler: async (_req, res) => {
|
|
160
|
+
store.clearAllIpSecurityStats();
|
|
161
|
+
jsonResponse(res, 200, { success: true });
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
// 4. 切换 Tailscale / 局域网免密直连开关
|
|
165
|
+
{
|
|
166
|
+
method: 'POST',
|
|
167
|
+
path: '/api/remote-mobile/toggle-bypass',
|
|
168
|
+
handler: async (req, res) => {
|
|
169
|
+
const body = await parseJsonBody(req);
|
|
170
|
+
const updates = {};
|
|
171
|
+
if (typeof body.allowTailscale === 'boolean') {
|
|
172
|
+
updates.allowTailscale = body.allowTailscale;
|
|
173
|
+
}
|
|
174
|
+
if (typeof body.allowLan === 'boolean') {
|
|
175
|
+
updates.allowLan = body.allowLan;
|
|
176
|
+
}
|
|
177
|
+
if (typeof body.enabled === 'boolean') {
|
|
178
|
+
updates.allowTailscale = body.enabled;
|
|
179
|
+
}
|
|
180
|
+
store.updateOptions(updates);
|
|
181
|
+
const opts = store.getOptions();
|
|
182
|
+
jsonResponse(res, 200, {
|
|
183
|
+
success: true,
|
|
184
|
+
allowTailscale: opts.allowTailscale,
|
|
185
|
+
allowLan: opts.allowLan,
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
method: 'POST',
|
|
191
|
+
path: '/api/remote-mobile/update-options',
|
|
192
|
+
handler: async (req, res) => {
|
|
193
|
+
const body = await parseJsonBody(req);
|
|
194
|
+
const updates = {};
|
|
195
|
+
if (typeof body.allowTailscale === 'boolean') {
|
|
196
|
+
updates.allowTailscale = body.allowTailscale;
|
|
197
|
+
}
|
|
198
|
+
if (typeof body.allowLan === 'boolean') {
|
|
199
|
+
updates.allowLan = body.allowLan;
|
|
200
|
+
}
|
|
201
|
+
if (typeof body.enabled === 'boolean') {
|
|
202
|
+
updates.allowTailscale = body.enabled;
|
|
203
|
+
}
|
|
204
|
+
if (typeof body.maxVisitsPerMinute === 'number' && body.maxVisitsPerMinute > 0) {
|
|
205
|
+
updates.maxVisitsPerMinute = Math.floor(body.maxVisitsPerMinute);
|
|
206
|
+
}
|
|
207
|
+
if (typeof body.maxFailedAttempts === 'number' && body.maxFailedAttempts > 0) {
|
|
208
|
+
updates.maxFailedAttempts = Math.floor(body.maxFailedAttempts);
|
|
209
|
+
}
|
|
210
|
+
if (typeof body.lockDurationMs === 'number' && body.lockDurationMs > 0) {
|
|
211
|
+
updates.lockDurationMs = Math.floor(body.lockDurationMs);
|
|
212
|
+
}
|
|
213
|
+
store.updateOptions(updates);
|
|
214
|
+
const opts = store.getOptions();
|
|
215
|
+
jsonResponse(res, 200, {
|
|
216
|
+
success: true,
|
|
217
|
+
allowTailscale: opts.allowTailscale,
|
|
218
|
+
allowLan: opts.allowLan,
|
|
219
|
+
maxVisitsPerMinute: opts.maxVisitsPerMinute,
|
|
220
|
+
maxFailedAttempts: opts.maxFailedAttempts,
|
|
221
|
+
lockDurationMs: opts.lockDurationMs,
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
// 5. 设置并持久化长期访问密码 (支持 RSA 密文 + 6位以上字母数字复杂度校验)
|
|
226
|
+
{
|
|
227
|
+
method: 'POST',
|
|
228
|
+
path: '/api/remote-mobile/set-secret',
|
|
229
|
+
handler: async (req, res) => {
|
|
230
|
+
const body = await parseJsonBody(req);
|
|
231
|
+
let secret = '';
|
|
232
|
+
if (body.encryptedSecret) {
|
|
233
|
+
secret = decryptWithPrivateKey(body.encryptedSecret).trim();
|
|
234
|
+
}
|
|
235
|
+
else if (typeof body.secret === 'string') {
|
|
236
|
+
secret = body.secret.trim();
|
|
237
|
+
}
|
|
238
|
+
// 校验密码复杂度
|
|
239
|
+
const check = validateSecretStrength(secret);
|
|
240
|
+
if (!check.valid) {
|
|
241
|
+
return jsonResponse(res, 400, {
|
|
242
|
+
success: false,
|
|
243
|
+
reason: check.reason || '密码不满足强度要求',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
store.updateOptions({ secret });
|
|
247
|
+
jsonResponse(res, 200, { success: true, hasSecret: Boolean(secret) });
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
// 5.1 清除长期访问密码
|
|
251
|
+
{
|
|
252
|
+
method: 'POST',
|
|
253
|
+
path: '/api/remote-mobile/clear-secret',
|
|
254
|
+
handler: async (_req, res) => {
|
|
255
|
+
store.updateOptions({ secret: '', secretHash: '' });
|
|
256
|
+
jsonResponse(res, 200, { success: true, hasSecret: false });
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
// 6. 撤销单个设备授权
|
|
260
|
+
{
|
|
261
|
+
method: 'POST',
|
|
262
|
+
path: '/api/remote-mobile/revoke-device',
|
|
263
|
+
handler: async (req, res) => {
|
|
264
|
+
const body = await parseJsonBody(req);
|
|
265
|
+
const token = body.token || '';
|
|
266
|
+
const ok = store.revokeDevice(token);
|
|
267
|
+
jsonResponse(res, 200, { success: ok });
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
// 7. 一键撤销所有设备
|
|
271
|
+
{
|
|
272
|
+
method: 'POST',
|
|
273
|
+
path: '/api/remote-mobile/revoke-all',
|
|
274
|
+
handler: async (_req, res) => {
|
|
275
|
+
store.revokeAllSessions();
|
|
276
|
+
jsonResponse(res, 200, { success: true });
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
// 8. 独立手机登录引导页面(已授权或 Tailscale/局域网免密设备访问时自动 302 跳转回工作区,支持 IP 访问计数与限频)
|
|
280
|
+
{
|
|
281
|
+
method: 'GET',
|
|
282
|
+
path: '/auth',
|
|
283
|
+
handler: async (req, res) => {
|
|
284
|
+
const clientIp = getRealClientIp(req);
|
|
285
|
+
const opts = store.getOptions();
|
|
286
|
+
const isTs = isTailscaleIp(clientIp);
|
|
287
|
+
const isLan = isLanIp(clientIp);
|
|
288
|
+
const token = store.extractTokenFromRequest(req);
|
|
289
|
+
const ua = req.headers['user-agent'] || '';
|
|
290
|
+
const hasValidToken = token ? store.validateToken(token, clientIp) : false;
|
|
291
|
+
// 如果开启了免密直连(Tailscale 或 局域网)或者已持有有效 Token,直接放行直跳根目录(支持网关前缀)
|
|
292
|
+
if ((opts.allowTailscale && isTs) || (opts.allowLan && isLan) || hasValidToken) {
|
|
293
|
+
const rootLocation = resolveRedirectPath(req.headers, '/');
|
|
294
|
+
res.writeHead(302, { Location: rootLocation });
|
|
295
|
+
return res.end();
|
|
296
|
+
}
|
|
297
|
+
// 记录 IP 访问计数并进行防高频刷量限频与设备型号解析
|
|
298
|
+
const visitCheck = store.recordAuthVisit(clientIp, ua);
|
|
299
|
+
if (!visitCheck.allowed) {
|
|
300
|
+
res.writeHead(429, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
301
|
+
return res.end(`
|
|
302
|
+
<!DOCTYPE html>
|
|
303
|
+
<html>
|
|
304
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>安全限制 - DSH</title>
|
|
305
|
+
<style>body{background:#0f172a;color:#fff;font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;padding:20px;box-sizing:border-box;text-align:center;}
|
|
306
|
+
.card{background:rgba(255,255,255,0.06);border:1px solid rgba(239,68,68,0.4);border-radius:16px;padding:28px 20px;max-width:380px;box-shadow:0 8px 32px rgba(0,0,0,0.4);}
|
|
307
|
+
h2{color:#ef4444;margin:0 0 10px 0;font-size:20px;}p{color:#94a3b8;font-size:14px;line-height:1.6;margin:0;}</style>
|
|
308
|
+
</head>
|
|
309
|
+
<body><div class="card"><h2>⚠️ 访问限制 (429)</h2><p>${visitCheck.reason || '当前 IP 请求过于频繁或已被临时锁定,请稍后刷新重试。'}</p></div></body>
|
|
310
|
+
</html>
|
|
311
|
+
`);
|
|
312
|
+
}
|
|
313
|
+
const acceptLang = req.headers['accept-language'] || '';
|
|
314
|
+
const reqLocale = acceptLang.toLowerCase().startsWith('en') ? 'en' : readGlobalLocale();
|
|
315
|
+
const html = getLoginPageHtml(undefined, reqLocale);
|
|
316
|
+
res.writeHead(200, {
|
|
317
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
318
|
+
'Cache-Control': 'no-cache',
|
|
319
|
+
});
|
|
320
|
+
res.end(html);
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
// 9. SSE 实时事件监听通道 (Server-Sent Events 0延时推送,彻底告别轮询)
|
|
324
|
+
{
|
|
325
|
+
method: 'GET',
|
|
326
|
+
path: '/api/remote-mobile/events',
|
|
327
|
+
handler: async (req, res) => {
|
|
328
|
+
res.writeHead(200, {
|
|
329
|
+
'Content-Type': 'text/event-stream',
|
|
330
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
331
|
+
'Connection': 'keep-alive',
|
|
332
|
+
// 注意:不设置 Access-Control-Allow-Origin。前端 Bundle 与本接口同源加载,
|
|
333
|
+
// EventSource 同源连接无需 CORS 头;开放跨域会允许任意网页读取设备与安全事件流。
|
|
334
|
+
});
|
|
335
|
+
res.write(': connected\n\n');
|
|
336
|
+
const onConnected = (device) => {
|
|
337
|
+
res.write(`data: ${JSON.stringify({ type: 'device-connected', device })}\n\n`);
|
|
338
|
+
};
|
|
339
|
+
const onOnline = (device) => {
|
|
340
|
+
res.write(`data: ${JSON.stringify({ type: 'device-online', device })}\n\n`);
|
|
341
|
+
};
|
|
342
|
+
const onRevoked = (data) => {
|
|
343
|
+
res.write(`data: ${JSON.stringify({ type: 'device-revoked', data })}\n\n`);
|
|
344
|
+
};
|
|
345
|
+
const onSecurityUpdated = (data) => {
|
|
346
|
+
res.write(`data: ${JSON.stringify({ type: 'ip-security-updated', ...data })}\n\n`);
|
|
347
|
+
};
|
|
348
|
+
const onSecurityAlert = (data) => {
|
|
349
|
+
res.write(`data: ${JSON.stringify({ type: 'ip-security-alert', ...data })}\n\n`);
|
|
350
|
+
};
|
|
351
|
+
store.on('device-connected', onConnected);
|
|
352
|
+
store.on('device-online', onOnline);
|
|
353
|
+
store.on('device-revoked', onRevoked);
|
|
354
|
+
store.on('ip-security-updated', onSecurityUpdated);
|
|
355
|
+
store.on('ip-security-alert', onSecurityAlert);
|
|
356
|
+
// 心跳定时器 (每 25 秒维持连接)
|
|
357
|
+
const keepAlive = setInterval(() => {
|
|
358
|
+
res.write(': ping\n\n');
|
|
359
|
+
}, 25000);
|
|
360
|
+
if (typeof keepAlive.unref === 'function') {
|
|
361
|
+
;
|
|
362
|
+
keepAlive.unref();
|
|
363
|
+
}
|
|
364
|
+
let cleaned = false;
|
|
365
|
+
const cleanup = () => {
|
|
366
|
+
if (cleaned)
|
|
367
|
+
return;
|
|
368
|
+
cleaned = true;
|
|
369
|
+
clearInterval(keepAlive);
|
|
370
|
+
store.off('device-connected', onConnected);
|
|
371
|
+
store.off('device-online', onOnline);
|
|
372
|
+
store.off('device-revoked', onRevoked);
|
|
373
|
+
store.off('ip-security-updated', onSecurityUpdated);
|
|
374
|
+
store.off('ip-security-alert', onSecurityAlert);
|
|
375
|
+
};
|
|
376
|
+
req.on('close', cleanup);
|
|
377
|
+
res.on('close', cleanup);
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
// 10. 移动端样式片段(样式小插件)管理 API(合并 GET 与 POST 处理,避免 webserver.register 重复 path 冲突)
|
|
381
|
+
{
|
|
382
|
+
path: '/api/remote-mobile/styles',
|
|
383
|
+
handler: async (req, res) => {
|
|
384
|
+
if (!styleStore) {
|
|
385
|
+
return jsonResponse(res, 404, { success: false, reason: 'style snippets module unavailable' });
|
|
386
|
+
}
|
|
387
|
+
// POST: 新增或修改自定义片段(pcEnabled/mobileEnabled 双端开关,缺省移动端开、PC 关)
|
|
388
|
+
if (req.method === 'POST') {
|
|
389
|
+
const body = await parseJsonBody(req);
|
|
390
|
+
try {
|
|
391
|
+
const snippet = styleStore.upsertCustom({
|
|
392
|
+
id: typeof body.id === 'string' ? body.id : undefined,
|
|
393
|
+
name: typeof body.name === 'string' ? body.name : '',
|
|
394
|
+
description: typeof body.description === 'string' ? body.description : undefined,
|
|
395
|
+
css: typeof body.css === 'string' ? body.css : '',
|
|
396
|
+
pcEnabled: typeof body.pcEnabled === 'boolean' ? body.pcEnabled : false,
|
|
397
|
+
mobileEnabled: typeof body.mobileEnabled === 'boolean' ? body.mobileEnabled : true,
|
|
398
|
+
});
|
|
399
|
+
return jsonResponse(res, 200, { success: true, snippet });
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
return jsonResponse(res, 400, {
|
|
403
|
+
success: false,
|
|
404
|
+
reason: e?.message || 'invalid style snippet payload',
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// 默认 GET: 列出全部样式片段(内置预设名称按请求语言本地化)
|
|
409
|
+
const accept = req.headers['accept-language'] || '';
|
|
410
|
+
const lang = accept.toLowerCase().startsWith('en') ? 'en' : readGlobalLocale();
|
|
411
|
+
jsonResponse(res, 200, {
|
|
412
|
+
success: true,
|
|
413
|
+
snippets: styleStore.list(lang),
|
|
414
|
+
persistPath: styleStore.persistPath,
|
|
415
|
+
});
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
method: 'POST',
|
|
420
|
+
path: '/api/remote-mobile/styles/toggle',
|
|
421
|
+
handler: async (req, res) => {
|
|
422
|
+
if (!styleStore) {
|
|
423
|
+
return jsonResponse(res, 404, { success: false, reason: 'style snippets module unavailable' });
|
|
424
|
+
}
|
|
425
|
+
const body = await parseJsonBody(req);
|
|
426
|
+
const scope = body.scope === 'pc' ? 'pc' : 'mobile';
|
|
427
|
+
const ok = styleStore.setEnabled(String(body.id || ''), scope, Boolean(body.enabled));
|
|
428
|
+
jsonResponse(res, ok ? 200 : 400, { success: ok, reason: ok ? undefined : 'style snippet not found' });
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
method: 'POST',
|
|
433
|
+
path: '/api/remote-mobile/styles/delete',
|
|
434
|
+
handler: async (req, res) => {
|
|
435
|
+
if (!styleStore) {
|
|
436
|
+
return jsonResponse(res, 404, { success: false, reason: 'style snippets module unavailable' });
|
|
437
|
+
}
|
|
438
|
+
const body = await parseJsonBody(req);
|
|
439
|
+
const ok = styleStore.removeCustom(String(body.id || ''));
|
|
440
|
+
jsonResponse(res, ok ? 200 : 400, { success: ok, reason: ok ? undefined : 'custom snippet not found' });
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
method: 'POST',
|
|
445
|
+
path: '/api/remote-mobile/styles/reset',
|
|
446
|
+
handler: async (_req, res) => {
|
|
447
|
+
if (!styleStore) {
|
|
448
|
+
return jsonResponse(res, 404, { success: false, reason: 'style snippets module unavailable' });
|
|
449
|
+
}
|
|
450
|
+
styleStore.resetEnabled();
|
|
451
|
+
jsonResponse(res, 200, { success: true, snippets: styleStore.list() });
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
}
|