@xcanwin/manyoyo 8.3.2 → 8.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/README.md +1 -1
- package/bin/manyoyo.js +109 -48
- package/docker/manyoyo.Dockerfile +41 -8
- package/docker/res/agent/update-agents.sh +1 -1
- package/docker/res/playwright/browser.json +9 -12
- package/docker/res/playwright/chrome-policy.json +3 -0
- package/docker/res/playwright/env +3 -0
- package/docker/res/playwright/playwright-cli.sh +5 -0
- package/docker/res/playwright/stealth.init.js +11 -9
- package/lib/container-exec.js +132 -0
- package/lib/container-init.sh +59 -0
- package/lib/container-manage-options.js +35 -0
- package/lib/container-network.js +526 -0
- package/lib/container-run.js +24 -3
- package/lib/container-state.js +488 -0
- package/lib/egress-proxy.js +298 -0
- package/lib/env-text.js +62 -0
- package/lib/network-endpoints.js +99 -0
- package/lib/network-policy.js +265 -0
- package/lib/plugin/fingerprint.js +65 -50
- package/lib/plugin/playwright-extensions.js +0 -9
- package/lib/plugin/playwright-server.js +22 -2
- package/lib/plugin/playwright.js +55 -16
- package/lib/port-forward.js +135 -0
- package/lib/web/AGENTS.md +4 -0
- package/lib/web/container-manage.js +450 -0
- package/lib/web/index.html +80 -76
- package/lib/web/server.js +303 -76
- package/manyoyo.example.json +20 -2
- package/package.json +13 -8
package/lib/plugin/playwright.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Playwright 插件:容器内的浏览器怎么来,只有 4 种模式、同一时间只有一个。
|
|
4
|
-
// default 容器内 Xvfb
|
|
5
|
-
// headed
|
|
4
|
+
// default 容器内 Xvfb 里的有头 Google Chrome(无需宿主机进程)
|
|
5
|
+
// headed 宿主机有窗口的浏览器(优先用本机 Google Chrome)
|
|
6
6
|
// chrome 用户正在使用的 Chrome(经本机中继)
|
|
7
7
|
// vnc 独立容器里的浏览器,用 noVNC 网页观看
|
|
8
8
|
// 宿主机目录 current/ 以目录方式只读挂进容器的 /run/manyoyo-playwright/,里面的 config.json 一律原子替换,
|
|
@@ -106,6 +106,19 @@ function defaultListenHost(platform = process.platform) {
|
|
|
106
106
|
return platform === 'darwin' ? '127.0.0.1' : '0.0.0.0';
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Google Chrome 在各平台的标准安装位置(patchright 的 channel: 'chrome' 也在这些位置查找)
|
|
110
|
+
function googleChromeCandidates(platform = process.platform, env = process.env) {
|
|
111
|
+
if (platform === 'darwin') {
|
|
112
|
+
return ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
|
|
113
|
+
}
|
|
114
|
+
if (platform === 'win32') {
|
|
115
|
+
return ['PROGRAMFILES', 'PROGRAMFILES(X86)', 'LOCALAPPDATA']
|
|
116
|
+
.filter(name => env[name])
|
|
117
|
+
.map(name => path.join(env[name], 'Google', 'Chrome', 'Application', 'chrome.exe'));
|
|
118
|
+
}
|
|
119
|
+
return ['/opt/google/chrome/chrome'];
|
|
120
|
+
}
|
|
121
|
+
|
|
109
122
|
// headed 的浏览器服务必须监听 0.0.0.0:playwright 只绑 loopback 时会按 Host 头做 DNS 重绑定防护,
|
|
110
123
|
// 容器用 host.containers.internal 访问会被 403(macOS 上实测);监听 0.0.0.0 则不检查 Host,靠 token 保护
|
|
111
124
|
function headedListenHost() {
|
|
@@ -171,7 +184,7 @@ class PlaywrightPlugin {
|
|
|
171
184
|
locale: String(merged.locale || '').trim() || host.locale,
|
|
172
185
|
timezoneId: String(merged.timezoneId || '').trim() || host.timezoneId,
|
|
173
186
|
navigatorPlatform: String(merged.navigatorPlatform || '').trim(),
|
|
174
|
-
disableWebRTC: merged.disableWebRTC === true || String(merged.disableWebRTC).toLowerCase() === 'true',
|
|
187
|
+
disableWebRTC: merged.disableWebRTC === undefined ? fingerprint.DEFAULT_PROFILE.disableWebRTC : (merged.disableWebRTC === true || String(merged.disableWebRTC).toLowerCase() === 'true'),
|
|
175
188
|
devtoolsActivePortPath: String(merged.devtoolsActivePortPath || '').trim(),
|
|
176
189
|
extensionProdversion: String(merged.extensionProdversion || '').trim() || '132.0.0.0'
|
|
177
190
|
};
|
|
@@ -276,8 +289,10 @@ class PlaywrightPlugin {
|
|
|
276
289
|
this.ensureDirs();
|
|
277
290
|
const config = `${JSON.stringify(this.containerConfigFor(state, runtimeCommand), null, 4)}\n`;
|
|
278
291
|
const initScript = fingerprint.buildInitScript(this.profile());
|
|
292
|
+
const envFile = fingerprint.buildContainerEnvFile(this.profile());
|
|
279
293
|
const configPath = path.join(this.currentDir, 'config.json');
|
|
280
294
|
const initPath = path.join(this.currentDir, 'stealth.init.js');
|
|
295
|
+
const envPath = path.join(this.currentDir, 'env');
|
|
281
296
|
const same = (file, content) => {
|
|
282
297
|
try {
|
|
283
298
|
return fs.readFileSync(file, 'utf8') === content;
|
|
@@ -288,6 +303,9 @@ class PlaywrightPlugin {
|
|
|
288
303
|
if (!same(initPath, initScript)) {
|
|
289
304
|
writeConfigFileSecure(initPath, initScript);
|
|
290
305
|
}
|
|
306
|
+
if (!same(envPath, envFile)) {
|
|
307
|
+
writeConfigFileSecure(envPath, envFile);
|
|
308
|
+
}
|
|
291
309
|
if (!same(configPath, config)) {
|
|
292
310
|
writeConfigFileSecure(configPath, config);
|
|
293
311
|
}
|
|
@@ -395,27 +413,42 @@ class PlaywrightPlugin {
|
|
|
395
413
|
};
|
|
396
414
|
}
|
|
397
415
|
|
|
416
|
+
// 浏览器服务与探测用 patchright-core(去掉 CDP Runtime 痕迹的开源补丁版),
|
|
417
|
+
// 版本与镜像内 @playwright/cli 的 playwright-core 同 minor,否则容器内 client 连宿主机服务会被 428 拒绝
|
|
398
418
|
corePath() {
|
|
399
|
-
|
|
400
|
-
return path.dirname(require.resolve('playwright-core/package.json', { paths: [path.dirname(cliPackage)] }));
|
|
419
|
+
return path.dirname(require.resolve('patchright-core/package.json'));
|
|
401
420
|
}
|
|
402
421
|
|
|
403
422
|
coreCli() {
|
|
404
423
|
return path.join(this.corePath(), 'cli.js');
|
|
405
424
|
}
|
|
406
425
|
|
|
407
|
-
// 浏览器与探测都用 @playwright/cli 自带的 playwright-core,保证与容器内版本一致
|
|
408
426
|
installCommand(args) {
|
|
409
427
|
return `node ${shellValue(this.coreCli())} ${args}`;
|
|
410
428
|
}
|
|
411
429
|
|
|
412
|
-
|
|
430
|
+
// 宿主机是否装了 Google Chrome;不会替用户安装(需要 sudo)
|
|
431
|
+
hasGoogleChrome() {
|
|
432
|
+
return googleChromeCandidates().some(file => fs.existsSync(file));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
installChromium() {
|
|
413
436
|
const result = spawnSync(process.execPath, [this.coreCli(), 'install', '--no-shell', 'chromium'], { stdio: 'inherit' });
|
|
414
437
|
if (result.status !== 0) {
|
|
415
438
|
throw new Error(`浏览器安装失败,请手动执行: ${this.installCommand('install --no-shell chromium')}`);
|
|
416
439
|
}
|
|
417
440
|
}
|
|
418
441
|
|
|
442
|
+
// 返回是否使用 Google Chrome;没有时退回 patchright 的 Chromium 并提示
|
|
443
|
+
ensureBrowserInstalled() {
|
|
444
|
+
if (this.hasGoogleChrome()) {
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
this.out('[up] 未检测到 Google Chrome,使用 Chromium,部分检测站点会识别为 Chromium。安装 Google Chrome 后重新 up 即可。');
|
|
448
|
+
this.installChromium();
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
419
452
|
// ---------- 探测 ----------
|
|
420
453
|
|
|
421
454
|
probe(kind, endpoint, timeout = PROBE_TIMEOUT_MS) {
|
|
@@ -454,10 +487,10 @@ class PlaywrightPlugin {
|
|
|
454
487
|
|
|
455
488
|
// ---------- 进程管理 ----------
|
|
456
489
|
|
|
457
|
-
spawnDetached(command, args, logPath) {
|
|
490
|
+
spawnDetached(command, args, logPath, env = process.env) {
|
|
458
491
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
459
492
|
const logFd = fs.openSync(logPath, 'a', 0o600);
|
|
460
|
-
const child = spawn(command, args, { detached: true, stdio: ['ignore', logFd, logFd] });
|
|
493
|
+
const child = spawn(command, args, { detached: true, stdio: ['ignore', logFd, logFd], env });
|
|
461
494
|
fs.closeSync(logFd);
|
|
462
495
|
child.unref();
|
|
463
496
|
return child;
|
|
@@ -538,8 +571,9 @@ class PlaywrightPlugin {
|
|
|
538
571
|
if (!(await this.portPreflight('headed', port))) {
|
|
539
572
|
return 1;
|
|
540
573
|
}
|
|
574
|
+
let useChrome;
|
|
541
575
|
try {
|
|
542
|
-
this.ensureBrowserInstalled();
|
|
576
|
+
useChrome = this.ensureBrowserInstalled();
|
|
543
577
|
} catch (error) {
|
|
544
578
|
return this.startFailure('headed', error.message);
|
|
545
579
|
}
|
|
@@ -553,21 +587,24 @@ class PlaywrightPlugin {
|
|
|
553
587
|
port,
|
|
554
588
|
wsPath: `/${token}`,
|
|
555
589
|
chromiumSandbox,
|
|
556
|
-
|
|
590
|
+
useChrome,
|
|
591
|
+
extensionPaths,
|
|
557
592
|
...this.profile()
|
|
558
593
|
}), null, 4)}\n`);
|
|
559
594
|
fs.rmSync(this.logPath(), { force: true });
|
|
595
|
+
// 语言与时区放进浏览器进程的原生环境(合并而不是替换 process.env,代理等变量要保留)
|
|
560
596
|
child = this.spawnDetached(process.execPath,
|
|
561
|
-
[path.join(__dirname, 'playwright-server.js'), this.corePath(), serverConfigPath], this.logPath()
|
|
597
|
+
[path.join(__dirname, 'playwright-server.js'), this.corePath(), serverConfigPath], this.logPath(),
|
|
598
|
+
{ ...process.env, ...fingerprint.buildProcessEnv(this.profile()) });
|
|
562
599
|
if (await this.waitForPort(port, child)) {
|
|
563
600
|
break;
|
|
564
601
|
}
|
|
565
602
|
await this.killGroup(child.pid, { verify: false });
|
|
566
603
|
if (!chromiumSandbox || !tailText(this.logPath(), 200).includes('No usable sandbox')) {
|
|
567
604
|
return this.startFailure('headed', '浏览器服务没有启动成功。',
|
|
568
|
-
`${tailText(this.logPath(), 20)}
|
|
605
|
+
`${tailText(this.logPath(), 20)}${useChrome ? '' : `\n缺系统依赖时执行: sudo ${this.installCommand('install-deps chromium')}`}`);
|
|
569
606
|
}
|
|
570
|
-
this.out('[up]
|
|
607
|
+
this.out('[up] 系统不允许非特权用户命名空间,浏览器沙箱不可用,改为无沙箱启动。');
|
|
571
608
|
}
|
|
572
609
|
return { mode: 'headed', port, pid: child.pid, listenHost: headedListenHost(), extensionPaths };
|
|
573
610
|
}
|
|
@@ -669,12 +706,13 @@ class PlaywrightPlugin {
|
|
|
669
706
|
wsPath: `/${token}`,
|
|
670
707
|
window: 'virtual',
|
|
671
708
|
chromiumSandbox: false,
|
|
672
|
-
|
|
709
|
+
extensionPaths: mounts.containerPaths,
|
|
673
710
|
...this.profile()
|
|
674
711
|
}), null, 4)}\n`);
|
|
675
712
|
const envFile = path.join(mountDir, 'env');
|
|
676
713
|
const noProxy = appendNoProxy(process.env.NO_PROXY ?? process.env.no_proxy);
|
|
677
|
-
|
|
714
|
+
const nativeEnv = Object.entries(fingerprint.buildProcessEnv(this.profile())).map(([key, value]) => `${key}=${value}\n`).join('');
|
|
715
|
+
writeConfigFileSecure(envFile, `VNC_PASSWORD=${password}\nNO_PROXY=${noProxy}\nno_proxy=${noProxy}\n${nativeEnv}`);
|
|
678
716
|
this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
|
|
679
717
|
const listenHost = defaultListenHost();
|
|
680
718
|
const args = [
|
|
@@ -917,5 +955,6 @@ module.exports = {
|
|
|
917
955
|
appendNoProxy,
|
|
918
956
|
connectHostFor,
|
|
919
957
|
defaultListenHost,
|
|
958
|
+
googleChromeCandidates,
|
|
920
959
|
headedListenHost
|
|
921
960
|
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const net = require('net');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { buildExecArgs } = require('./container-exec');
|
|
7
|
+
|
|
8
|
+
// 运行中暴露容器端口:serve 在宿主机监听 bind:hostPort,每个连接起一个 `exec -i <容器> socat STDIO TCP:127.0.0.1:<端口>` 双向 pipe。
|
|
9
|
+
// 不需要重启容器、不依赖容器的网络模式(slirp4netns / bridge / pasta 都一样)。
|
|
10
|
+
const MAX_CONNECTIONS_PER_FORWARD = 64;
|
|
11
|
+
|
|
12
|
+
// 容器里优先 socat,没有就用 node 兜底;端口是校验过的整数
|
|
13
|
+
function containerCommand(port) {
|
|
14
|
+
const p = Number(port);
|
|
15
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error(`端口非法: ${port}`);
|
|
16
|
+
const nodeScript = `const s=require('net').connect(${p},'127.0.0.1');process.stdin.pipe(s);s.pipe(process.stdout);s.on('error',()=>process.exit(1));s.on('close',()=>process.exit(0))`;
|
|
17
|
+
return ['/bin/sh', '-c', `if command -v socat >/dev/null 2>&1; then exec socat STDIO TCP:127.0.0.1:${p}; else exec node -e "${nodeScript}"; fi`];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function keyOf(id, bind, hostPort) {
|
|
21
|
+
return `${id}|${bind}:${hostPort}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createPortForwarder(options) {
|
|
25
|
+
const command = options.command;
|
|
26
|
+
const env = options.env ? { ...process.env, ...options.env } : process.env;
|
|
27
|
+
const homeDir = options.homeDir || os.homedir();
|
|
28
|
+
const resolveName = options.resolveName;
|
|
29
|
+
const warn = options.warn || (() => {});
|
|
30
|
+
const forwards = new Map(); // key → { id, bind, hostPort, port, server, sockets:Set, active }
|
|
31
|
+
|
|
32
|
+
function handleConnection(forward, socket) {
|
|
33
|
+
if (forward.active >= MAX_CONNECTIONS_PER_FORWARD) {
|
|
34
|
+
socket.destroy();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const name = resolveName(forward.id);
|
|
38
|
+
if (!name) {
|
|
39
|
+
// 容器(状态目录)已被删:这个转发没有意义了,释放宿主机端口
|
|
40
|
+
closeForward(keyOf(forward.id, forward.bind, forward.hostPort));
|
|
41
|
+
socket.destroy();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
forward.active += 1;
|
|
45
|
+
forward.sockets.add(socket);
|
|
46
|
+
const built = buildExecArgs({ homeDir, dockerExecArgs: () => '' }, name, {
|
|
47
|
+
interactive: true,
|
|
48
|
+
withEnv: false,
|
|
49
|
+
command: containerCommand(forward.port)
|
|
50
|
+
});
|
|
51
|
+
const child = spawn(command, built.args, { env, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
52
|
+
const finish = () => {
|
|
53
|
+
if (forward.sockets.delete(socket)) forward.active -= 1;
|
|
54
|
+
built.cleanup();
|
|
55
|
+
socket.destroy();
|
|
56
|
+
if (!child.killed) child.kill('SIGTERM');
|
|
57
|
+
};
|
|
58
|
+
socket.on('error', finish);
|
|
59
|
+
socket.on('close', finish);
|
|
60
|
+
child.on('error', finish);
|
|
61
|
+
child.on('close', finish);
|
|
62
|
+
child.stdin.on('error', () => {});
|
|
63
|
+
child.stdout.on('error', () => {});
|
|
64
|
+
socket.pipe(child.stdin);
|
|
65
|
+
child.stdout.pipe(socket);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function open({ id, bind, hostPort, port }) {
|
|
69
|
+
const key = keyOf(id, bind, hostPort);
|
|
70
|
+
if (forwards.has(key)) return Promise.resolve();
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const forward = { id, bind, hostPort, port, sockets: new Set(), active: 0, server: null };
|
|
73
|
+
const server = net.createServer(socket => handleConnection(forward, socket));
|
|
74
|
+
forward.server = server;
|
|
75
|
+
server.once('error', error => {
|
|
76
|
+
reject(error.code === 'EADDRINUSE'
|
|
77
|
+
? new Error(`宿主机端口 ${bind}:${hostPort} 已被占用`)
|
|
78
|
+
: error);
|
|
79
|
+
});
|
|
80
|
+
server.listen(hostPort, bind, () => {
|
|
81
|
+
server.removeAllListeners('error');
|
|
82
|
+
server.on('error', error => warn(`端口转发 ${bind}:${hostPort} 出错: ${error.message}`));
|
|
83
|
+
forwards.set(key, forward);
|
|
84
|
+
resolve();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function closeForward(key) {
|
|
90
|
+
const forward = forwards.get(key);
|
|
91
|
+
if (!forward) return;
|
|
92
|
+
forwards.delete(key);
|
|
93
|
+
forward.server.close();
|
|
94
|
+
forward.sockets.forEach(socket => socket.destroy());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function close(id, bind, hostPort) {
|
|
98
|
+
closeForward(keyOf(id, bind, hostPort));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function closeFor(id) {
|
|
102
|
+
[...forwards.keys()].filter(key => key.startsWith(`${id}|`)).forEach(closeForward);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function closeAll() {
|
|
106
|
+
[...forwards.keys()].forEach(closeForward);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function list(id) {
|
|
110
|
+
return [...forwards.values()].filter(f => f.id === id).map(f => ({ bind: f.bind, hostPort: f.hostPort, port: f.port }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 让转发列表与策略一致:多的关、少的开;单条失败只警告
|
|
114
|
+
async function sync(id, expose) {
|
|
115
|
+
const want = new Map(expose.map(e => [keyOf(id, e.bind, e.hostPort), e]));
|
|
116
|
+
[...forwards.keys()].filter(key => key.startsWith(`${id}|`) && !want.has(key)).forEach(closeForward);
|
|
117
|
+
const failures = [];
|
|
118
|
+
for (const [key, entry] of want) {
|
|
119
|
+
const existing = forwards.get(key);
|
|
120
|
+
if (existing && existing.port === entry.port) continue;
|
|
121
|
+
if (existing) closeForward(key);
|
|
122
|
+
try {
|
|
123
|
+
await open({ id, ...entry });
|
|
124
|
+
} catch (e) {
|
|
125
|
+
failures.push(e.message);
|
|
126
|
+
warn(e.message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return failures;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { open, close, closeFor, closeAll, list, sync };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { createPortForwarder, containerCommand, MAX_CONNECTIONS_PER_FORWARD };
|
package/lib/web/AGENTS.md
CHANGED
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
- 空闲保活是 `/agent/stream` 那条心跳规则的同类问题,**两个入口都要守**:终端侧服务端每 `WEB_TERMINAL_PING_INTERVAL_MS`(30s)发 WebSocket ping 帧,连续 `WEB_TERMINAL_MAX_MISSED_PONGS`(3 次,约 90s)无 pong 才判死;前端另发应用层 `ping`(浏览器 JS 发不出 ping 帧,上行需要自己造流量)。判死阈值不要收紧到一个周期:手机切后台会让连接短暂挂起,误杀等于用户的 shell 没了。升级后的 socket 还要 `setTimeout(0)` + `setKeepAlive`,解除 HTTP 侧空闲超时。
|
|
22
22
|
- 新增接口/页面必须走全局认证网关,禁止在业务路由里零散补认证;匿名白名单见根 `AGENTS.md` 的安全约束。
|
|
23
23
|
|
|
24
|
+
## 容器管理接口
|
|
25
|
+
|
|
26
|
+
`lib/web/container-manage.js`(路由工厂,由 `handleWebApi` 追加进路由表):`/api/containers/:name/{env,autostart,autostart/run,autostart/log,network,expose}` 与 `/api/containers/orphans`。旧容器(无 `manyoyo.id`)GET 返回 `{legacy:true}`、写返回 409。`PUT env` 用 `If-Match`(etag)防止覆盖容器内的修改;放开类改动(`open`、非 loopback 暴露)服务端也要求 `confirmRisk:true`。serve 运行时就绪后 `startContainerServices` 订阅 `<runtime> events`(start)补下发规则、拉起 `autostartOnServe` 容器、恢复端口暴露;过滤代理 `ensureWebEgressProxy` 懒启动。所有 exec 经 `spawnContainerExec`(env 临时文件在子进程 close 时删)。
|
|
27
|
+
|
|
24
28
|
## lib/web/index.html
|
|
25
29
|
|
|
26
30
|
前端构建产物(`npm run build:web`,已 `.gitignore`),由 `loadIndexHtml()` 在每次请求时读取,`/` 与登录页 `/auth/login` 共用。服务端不托管其它前端静态资源,别往 `lib/web/` 里放散装 js/css。
|