@aalis/plugin-webui-server 0.14.0 → 0.15.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 +5 -1
- package/dist/admin-channel.d.ts +10 -0
- package/dist/admin-channel.d.ts.map +1 -0
- package/dist/admin-channel.js +57 -0
- package/dist/admin-channel.js.map +1 -0
- package/dist/attention.d.ts +36 -0
- package/dist/attention.d.ts.map +1 -0
- package/dist/attention.js +73 -0
- package/dist/attention.js.map +1 -0
- package/dist/auth.d.ts +22 -3
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +192 -68
- package/dist/auth.js.map +1 -1
- package/dist/client-discovery.d.ts +7 -3
- package/dist/client-discovery.d.ts.map +1 -1
- package/dist/client-discovery.js +9 -3
- package/dist/client-discovery.js.map +1 -1
- package/dist/client-switch-page.d.ts +2 -0
- package/dist/client-switch-page.d.ts.map +1 -1
- package/dist/client-switch-page.js +37 -36
- package/dist/client-switch-page.js.map +1 -1
- package/dist/gate.d.ts +3 -3
- package/dist/gate.js +3 -3
- package/dist/index.d.ts +21 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +577 -1018
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +18 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +198 -0
- package/dist/layout.js.map +1 -0
- package/dist/logs.d.ts +10 -0
- package/dist/logs.d.ts.map +1 -0
- package/dist/logs.js +50 -0
- package/dist/logs.js.map +1 -0
- package/dist/page-check.d.ts +56 -0
- package/dist/page-check.d.ts.map +1 -0
- package/dist/page-check.js +369 -0
- package/dist/page-check.js.map +1 -0
- package/dist/page-files.d.ts +13 -0
- package/dist/page-files.d.ts.map +1 -0
- package/dist/page-files.js +39 -0
- package/dist/page-files.js.map +1 -0
- package/dist/pages.d.ts +60 -0
- package/dist/pages.d.ts.map +1 -0
- package/dist/pages.js +470 -0
- package/dist/pages.js.map +1 -0
- package/dist/route.d.ts +11 -0
- package/dist/route.d.ts.map +1 -0
- package/dist/route.js +13 -0
- package/dist/route.js.map +1 -0
- package/dist/routes/files.d.ts +8 -9
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +32 -119
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/frames.d.ts +4 -0
- package/dist/routes/frames.d.ts.map +1 -0
- package/dist/routes/frames.js +103 -0
- package/dist/routes/frames.js.map +1 -0
- package/dist/routes/marketplace.d.ts +46 -129
- package/dist/routes/marketplace.d.ts.map +1 -1
- package/dist/routes/marketplace.js +138 -86
- package/dist/routes/marketplace.js.map +1 -1
- package/dist/routes/media.d.ts +7 -0
- package/dist/routes/media.d.ts.map +1 -0
- package/dist/routes/media.js +63 -0
- package/dist/routes/media.js.map +1 -0
- package/dist/routes/pages.d.ts +20 -0
- package/dist/routes/pages.d.ts.map +1 -0
- package/dist/routes/pages.js +86 -0
- package/dist/routes/pages.js.map +1 -0
- package/dist/routes/pipe-to-response.d.ts +9 -0
- package/dist/routes/pipe-to-response.d.ts.map +1 -0
- package/dist/routes/pipe-to-response.js +16 -0
- package/dist/routes/pipe-to-response.js.map +1 -0
- package/dist/routes/plugins.d.ts +22 -24
- package/dist/routes/plugins.d.ts.map +1 -1
- package/dist/routes/plugins.js +384 -361
- package/dist/routes/plugins.js.map +1 -1
- package/dist/routes/proxy.d.ts.map +1 -1
- package/dist/routes/proxy.js +2 -1
- package/dist/routes/proxy.js.map +1 -1
- package/dist/session-transport.d.ts +6 -0
- package/dist/session-transport.d.ts.map +1 -0
- package/dist/session-transport.js +32 -0
- package/dist/session-transport.js.map +1 -0
- package/package.json +34 -31
- package/dist/protocol.d.ts +0 -113
- package/dist/protocol.d.ts.map +0 -1
- package/dist/protocol.js +0 -59
- package/dist/protocol.js.map +0 -1
- package/dist/routes/uploaded-files.d.ts +0 -20
- package/dist/routes/uploaded-files.d.ts.map +0 -1
- package/dist/routes/uploaded-files.js +0 -161
- package/dist/routes/uploaded-files.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @aalis/plugin-webui-server
|
|
2
2
|
|
|
3
|
-
Web
|
|
3
|
+
Web 管理界面后端:会话连接、管理面推送、插件、服务与市场
|
|
4
4
|
## 角色
|
|
5
5
|
|
|
6
6
|
运行时插件
|
|
@@ -11,6 +11,10 @@ Web 管理界面后端:对话/插件/服务/市场/文件
|
|
|
11
11
|
pnpm add @aalis/plugin-webui-server
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
## 宿主路径
|
|
15
|
+
|
|
16
|
+
以 `optional(hostPaths)` 声明 `host-paths`(`@aalis/api-host-paths`),从安装集扫描本机包与前端。宿主不提供时发现不到前端,整个 WebUI 前端不可用,只剩 HTTP 接口,市场与系统组件接口返回 503。Node 宿主 `@aalis/runtime` 总会提供它。
|
|
17
|
+
|
|
14
18
|
## 文档
|
|
15
19
|
|
|
16
20
|
详见 [docs/plugins/plugin-webui-server.md](../../docs/plugins/plugin-webui-server.md)。
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LogFilter, WebuiAdminEvent } from '@aalis/api-webui';
|
|
2
|
+
import type { LogEntry, Logger } from '@aalis/core';
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
export declare const ADMIN_PATH = "/admin";
|
|
5
|
+
/** match 按筛选给出判定日志的函数 */
|
|
6
|
+
export declare function createAdminChannel(logger: Logger, match: (filter: LogFilter) => (entry: LogEntry) => boolean): {
|
|
7
|
+
accept: (ws: WebSocket) => void;
|
|
8
|
+
send: (event: WebuiAdminEvent) => void;
|
|
9
|
+
};
|
|
10
|
+
//# sourceMappingURL=admin-channel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-channel.d.ts","sourceRoot":"","sources":["../src/admin-channel.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AAG/B,eAAO,MAAM,UAAU,WAAW,CAAC;AAYnC,yBAAyB;AACzB,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,CAAC,KAAK,EAAE,QAAQ,KAAK,OAAO;iBAKvF,SAAS,KAAG,IAAI;kBAgBf,eAAe,KAAG,IAAI;EAY5C"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// admin-channel.ts — /admin:管理面事件推送
|
|
3
|
+
//
|
|
4
|
+
// 会话之外的推送(日志、插件与页面登记表的变化、重启、换前端)走这条端点,与会话协议的 /session 分开。
|
|
5
|
+
// 事件类型见 @aalis/api-webui 的 WebuiAdminEvent:日志只推给订阅了、符合它订阅时的筛选的连接,其余事件推给全部连接。
|
|
6
|
+
// 前端发来的只有日志的订阅(可带筛选,再订阅一次换掉筛选)与退订;认不出的消息(含写法不对的筛选)记一行告警后忽略,
|
|
7
|
+
// 连接与之前的订阅照常,较新的前端连上较旧的服务端也不会被断开。
|
|
8
|
+
// ============================================================
|
|
9
|
+
import { WebSocket } from 'ws';
|
|
10
|
+
import { parseLogFilter } from './logs.js';
|
|
11
|
+
export const ADMIN_PATH = '/admin';
|
|
12
|
+
/** 帧按 JSON 读出的对象;不是 JSON 对象时为 undefined */
|
|
13
|
+
function messageOf(text) {
|
|
14
|
+
try {
|
|
15
|
+
const value = JSON.parse(text);
|
|
16
|
+
return typeof value === 'object' && value !== null ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** match 按筛选给出判定日志的函数 */
|
|
23
|
+
export function createAdminChannel(logger, match) {
|
|
24
|
+
const clients = new Set();
|
|
25
|
+
const logSubscribers = new Map();
|
|
26
|
+
/** 接一条已认证的 /admin 连接 */
|
|
27
|
+
function accept(ws) {
|
|
28
|
+
clients.add(ws);
|
|
29
|
+
ws.on('message', data => {
|
|
30
|
+
const message = messageOf(String(data));
|
|
31
|
+
const filter = message?.type === 'log.subscribe' ? parseLogFilter(message.filter) : undefined;
|
|
32
|
+
if (filter)
|
|
33
|
+
logSubscribers.set(ws, match(filter));
|
|
34
|
+
else if (message?.type === 'log.unsubscribe')
|
|
35
|
+
logSubscribers.delete(ws);
|
|
36
|
+
else
|
|
37
|
+
logger.warn('WebUI /admin 收到认不出的消息,已忽略');
|
|
38
|
+
});
|
|
39
|
+
ws.on('close', () => {
|
|
40
|
+
clients.delete(ws);
|
|
41
|
+
logSubscribers.delete(ws);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** 推一个事件:日志推给订阅了、符合筛选的连接,其余推给全部连接 */
|
|
45
|
+
function send(event) {
|
|
46
|
+
const json = JSON.stringify(event);
|
|
47
|
+
const targets = event.type === 'log'
|
|
48
|
+
? [...logSubscribers].flatMap(([ws, accepts]) => (accepts(event.entry) ? [ws] : []))
|
|
49
|
+
: clients;
|
|
50
|
+
for (const ws of targets) {
|
|
51
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
52
|
+
ws.send(json);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { accept, send };
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=admin-channel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin-channel.js","sourceRoot":"","sources":["../src/admin-channel.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAC/D,oCAAoC;AACpC,EAAE;AACF,0DAA0D;AAC1D,6EAA6E;AAC7E,4DAA4D;AAC5D,kCAAkC;AAClC,+DAA+D;AAI/D,OAAO,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AAC/B,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAE3C,MAAM,CAAC,MAAM,UAAU,GAAG,QAAQ,CAAC;AAEnC,2CAA2C;AAC3C,SAAS,SAAS,CAAC,IAAY;IAC7B,IAAI,CAAC;QACH,MAAM,KAAK,GAAY,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACxC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,CAAC,CAAC,CAAE,KAAiC,CAAC,CAAC,CAAC,SAAS,CAAC;IACtG,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,yBAAyB;AACzB,MAAM,UAAU,kBAAkB,CAAC,MAAc,EAAE,KAA0D;IAC3G,MAAM,OAAO,GAAG,IAAI,GAAG,EAAa,CAAC;IACrC,MAAM,cAAc,GAAG,IAAI,GAAG,EAA2C,CAAC;IAE1E,wBAAwB;IACxB,SAAS,MAAM,CAAC,EAAa;QAC3B,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChB,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,IAAI,CAAC,EAAE;YACtB,MAAM,OAAO,GAAG,SAAS,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;YACxC,MAAM,MAAM,GAAG,OAAO,EAAE,IAAI,KAAK,eAAe,CAAC,CAAC,CAAC,cAAc,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;YAC9F,IAAI,MAAM;gBAAE,cAAc,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC;iBAC7C,IAAI,OAAO,EAAE,IAAI,KAAK,iBAAiB;gBAAE,cAAc,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;;gBACnE,MAAM,CAAC,IAAI,CAAC,2BAA2B,CAAC,CAAC;QAChD,CAAC,CAAC,CAAC;QACH,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YACnB,cAAc,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAC5B,CAAC,CAAC,CAAC;IACL,CAAC;IAED,qCAAqC;IACrC,SAAS,IAAI,CAAC,KAAsB;QAClC,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;QACnC,MAAM,OAAO,GACX,KAAK,CAAC,IAAI,KAAK,KAAK;YAClB,CAAC,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YACpF,CAAC,CAAC,OAAO,CAAC;QACd,KAAK,MAAM,EAAE,IAAI,OAAO,EAAE,CAAC;YACzB,IAAI,EAAE,CAAC,UAAU,KAAK,SAAS,CAAC,IAAI;gBAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtD,CAAC;IACH,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;AAC1B,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Approval } from '@aalis/api-approval';
|
|
2
|
+
import type { HostConfig } from '@aalis/api-host-config';
|
|
3
|
+
import type { SessionManagerService } from '@aalis/api-session-manager';
|
|
4
|
+
import type { AttentionItem, Notice, PluginBlockers, WebuiAdminEvent } from '@aalis/api-webui';
|
|
5
|
+
import type { PluginManagerService, PluginStatusEntry, ServiceSelection, Services } from '@aalis/core';
|
|
6
|
+
/** 算插件没起来的原因要读的:插件状态与定义、当前有提供者的服务名、配置文档(宿主不提供时读运行态配置) */
|
|
7
|
+
interface BlockerSources {
|
|
8
|
+
plugins: Pick<PluginManagerService, 'getStatus' | 'getPlugin'>;
|
|
9
|
+
services: Pick<Services, 'names'>;
|
|
10
|
+
doc?: Pick<HostConfig, 'getPluginConfig'>;
|
|
11
|
+
}
|
|
12
|
+
/** 插件状态快照,每个实例带上没起来的原因 */
|
|
13
|
+
export declare function blockedStatus({ plugins, services, doc }: BlockerSources): Array<PluginStatusEntry & PluginBlockers>;
|
|
14
|
+
interface AttentionDeps {
|
|
15
|
+
/** 推出未结的审批;审批服务不在时没有 */
|
|
16
|
+
approvals(): readonly Approval[];
|
|
17
|
+
/** 会话管理:审批所在会话的标题 */
|
|
18
|
+
sessions: Pick<ServiceSelection<Pick<SessionManagerService, 'getSession'>>, 'present' | 'use'>;
|
|
19
|
+
/** 插件状态与没起来的原因;插件管理服务不在时为空 */
|
|
20
|
+
plugins(): ReadonlyArray<PluginStatusEntry & PluginBlockers>;
|
|
21
|
+
/** 登记着的提示,带所属实例 */
|
|
22
|
+
notices(): ReadonlyArray<Notice & {
|
|
23
|
+
instance: string;
|
|
24
|
+
}>;
|
|
25
|
+
send(event: WebuiAdminEvent): void;
|
|
26
|
+
}
|
|
27
|
+
export declare function createAttention(deps: AttentionDeps): {
|
|
28
|
+
/** 审批在前,其后是插件故障与提示,各按先后 */
|
|
29
|
+
items: () => AttentionItem[];
|
|
30
|
+
/** 审批推出或结算了 */
|
|
31
|
+
changed: () => void;
|
|
32
|
+
/** 插件登记表重算过:故障(连同链与根)变了才推 */
|
|
33
|
+
pluginsChanged(): void;
|
|
34
|
+
};
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=attention.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attention.d.ts","sourceRoot":"","sources":["../src/attention.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACxE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC/F,OAAO,KAAK,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAGvG,yDAAyD;AACzD,UAAU,cAAc;IACtB,OAAO,EAAE,IAAI,CAAC,oBAAoB,EAAE,WAAW,GAAG,WAAW,CAAC,CAAC;IAC/D,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAClC,GAAG,CAAC,EAAE,IAAI,CAAC,UAAU,EAAE,iBAAiB,CAAC,CAAC;CAC3C;AAED,0BAA0B;AAC1B,wBAAgB,aAAa,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,GAAG,EAAE,EAAE,cAAc,GAAG,KAAK,CAAC,iBAAiB,GAAG,cAAc,CAAC,CAgBnH;AAED,UAAU,aAAa;IACrB,wBAAwB;IACxB,SAAS,IAAI,SAAS,QAAQ,EAAE,CAAC;IACjC,qBAAqB;IACrB,QAAQ,EAAE,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,qBAAqB,EAAE,YAAY,CAAC,CAAC,EAAE,SAAS,GAAG,KAAK,CAAC,CAAC;IAC/F,8BAA8B;IAC9B,OAAO,IAAI,aAAa,CAAC,iBAAiB,GAAG,cAAc,CAAC,CAAC;IAC7D,mBAAmB;IACnB,OAAO,IAAI,aAAa,CAAC,MAAM,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,IAAI,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;CACpC;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,aAAa;IA2B/C,2BAA2B;iBAChB,aAAa,EAAE;IAQ1B,eAAe;;IAEf,6BAA6B;sBACX,IAAI;EAOzB"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// attention.ts — 插件没起来的原因与「需要处理」
|
|
3
|
+
//
|
|
4
|
+
// 插件没起来的原因(缺、在等、待配置)在这里算一份,插件列表与「需要处理」共用:缺与在等按 api-doctor 的共享判据沿依赖链
|
|
5
|
+
// 追到根,与 runtime 的启动告警、诊断同一说法;失败的另按配置 schema 校验它的配置,列出必填却没填的项。
|
|
6
|
+
//
|
|
7
|
+
// 「需要处理」:推出未结的审批、插件故障(失败与缺服务;只在等别的插件启动的不算)与插件的提示汇总成一个列表,每项计 1,
|
|
8
|
+
// 经 GET /api/attention 给出。变了就经 /admin 推 attention.changed:审批推出或结算是变化,提示的登记与撤回由页面登记表推
|
|
9
|
+
// (pages.ts);插件登记表每次重算之后比一次故障(连同链与根),变了才推(重算很频繁,多数与故障无关)。
|
|
10
|
+
// ============================================================
|
|
11
|
+
import { unmetDependencies } from '@aalis/api-doctor';
|
|
12
|
+
import { validateConfig } from '@aalis/schema-config';
|
|
13
|
+
/** 插件状态快照,每个实例带上没起来的原因 */
|
|
14
|
+
export function blockedStatus({ plugins, services, doc }) {
|
|
15
|
+
const status = plugins.getStatus();
|
|
16
|
+
const unmet = unmetDependencies(status, services.names());
|
|
17
|
+
/** 必填却没填的配置项:`''` 算没填,密钥引用算填了(validateConfig 的 missing) */
|
|
18
|
+
const unconfigured = (instanceId) => {
|
|
19
|
+
const entry = plugins.getPlugin(instanceId);
|
|
20
|
+
const config = doc ? doc.getPluginConfig(instanceId) : (entry?.config ?? {});
|
|
21
|
+
return validateConfig(entry?.definition.configSchema, config)
|
|
22
|
+
.filter(issue => issue.kind === 'missing')
|
|
23
|
+
.map(issue => issue.path);
|
|
24
|
+
};
|
|
25
|
+
return status.map(entry => ({
|
|
26
|
+
...entry,
|
|
27
|
+
...(unmet.get(entry.instanceId) ?? { lacking: [], waiting: [] }),
|
|
28
|
+
unconfigured: entry.state === 'error' ? unconfigured(entry.instanceId) : [],
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
export function createAttention(deps) {
|
|
32
|
+
const faults = () => deps.plugins().flatMap(({ instanceId, state, error, lacking, waiting, unconfigured }) => state === 'error' || (state === 'pending' && lacking.length > 0)
|
|
33
|
+
? [
|
|
34
|
+
{
|
|
35
|
+
kind: 'plugin',
|
|
36
|
+
instance: instanceId,
|
|
37
|
+
state,
|
|
38
|
+
...(error !== undefined && { error }),
|
|
39
|
+
lacking,
|
|
40
|
+
waiting,
|
|
41
|
+
unconfigured,
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
: []);
|
|
45
|
+
const titleOf = (sessionId) => {
|
|
46
|
+
const info = deps.sessions.present ? deps.sessions.use.getSession(sessionId) : undefined;
|
|
47
|
+
return info && (info.title || info.name);
|
|
48
|
+
};
|
|
49
|
+
let reported = JSON.stringify(faults());
|
|
50
|
+
const changed = () => deps.send({ type: 'attention.changed' });
|
|
51
|
+
return {
|
|
52
|
+
/** 审批在前,其后是插件故障与提示,各按先后 */
|
|
53
|
+
items: () => [
|
|
54
|
+
...deps.approvals().map(({ id, sessionId, tool }) => {
|
|
55
|
+
const session = titleOf(sessionId);
|
|
56
|
+
return { kind: 'approval', id, sessionId, ...(session !== undefined && { session }), tool };
|
|
57
|
+
}),
|
|
58
|
+
...faults(),
|
|
59
|
+
...deps.notices().map(notice => ({ kind: 'notice', ...notice })),
|
|
60
|
+
],
|
|
61
|
+
/** 审批推出或结算了 */
|
|
62
|
+
changed,
|
|
63
|
+
/** 插件登记表重算过:故障(连同链与根)变了才推 */
|
|
64
|
+
pluginsChanged() {
|
|
65
|
+
const now = JSON.stringify(faults());
|
|
66
|
+
if (now === reported)
|
|
67
|
+
return;
|
|
68
|
+
reported = now;
|
|
69
|
+
changed();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=attention.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attention.js","sourceRoot":"","sources":["../src/attention.ts"],"names":[],"mappings":"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"}
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http';
|
|
2
2
|
import type { UserIdentity } from '@aalis/api-authority';
|
|
3
3
|
import type { ProcessService } from '@aalis/api-process';
|
|
4
|
-
import type
|
|
4
|
+
import type express from 'express';
|
|
5
|
+
/** 失败计数表最多记这么多个地址:满了再记新地址时丢掉最早的那条,内存有界,也不因此变成全局锁定 */
|
|
6
|
+
export declare const LOGIN_FAILURE_ADDRESSES_MAX = 10000;
|
|
7
|
+
/**
|
|
8
|
+
* 请求的来源能不能收,WebSocket 升级与改状态的请求(非 GET)共用。浏览器发起的握手与 POST 一律带 Origin,只收与本服务同源的:
|
|
9
|
+
* Origin 的主机与端口等于请求的 Host。同一主机别的端口上的网页与本服务同站,SameSite cookie 会随它的请求发出,
|
|
10
|
+
* 靠这一关拦下。没有 Origin 的是浏览器之外的客户端(脚本、命令行),不会被别的网页借用户的浏览器发起,交给 token。
|
|
11
|
+
* Host 按 Origin 的协议解析后再比,两边的默认端口都去掉(代理转来的 Host 可能写成 `x:443`)。
|
|
12
|
+
* 经反向代理访问时,代理要把原始的 Host 转给本服务。
|
|
13
|
+
*/
|
|
14
|
+
export declare function originAllowed(req: Pick<IncomingMessage, 'headers'>): boolean;
|
|
5
15
|
export interface AuthSystem {
|
|
6
|
-
/**
|
|
7
|
-
|
|
16
|
+
/**
|
|
17
|
+
* 挂上认证,要在 JSON 解析之后、其余路由之前:改状态的请求先核来源,再是登录页的脚本、`?token=` 一键登录、登录、
|
|
18
|
+
* 登录状态与注销,其余未登录的请求一律拦下(API 回 401,GET 页面回登录页)
|
|
19
|
+
*/
|
|
20
|
+
install(expressApp: express.Express): void;
|
|
8
21
|
/** 校验 WebSocket 升级请求是否已认证(cookie 读取) */
|
|
9
22
|
verifyWsClient(req: IncomingMessage): boolean;
|
|
10
23
|
/** 解析调用者身份:单 token 模式 → `webui:console`(owner 级,单人语义);未认证 → undefined。 */
|
|
@@ -16,6 +29,12 @@ export interface AuthSystem {
|
|
|
16
29
|
* - token 由调用方经 getToken 提供,每次校验现取:persist 模式下 storage 晚上线时,调用方会把
|
|
17
30
|
* 启动时的临时 token 换成读回的持久化 token(见 index.ts 的 token 解析)
|
|
18
31
|
* - HttpOnly + SameSite=Strict cookie;`?token=` 一键登录;同源 POST /api/auth/login 提交
|
|
32
|
+
* - 改状态的请求(非 GET、HEAD、OPTIONS)按 {@link originAllowed} 校验来源,与 WebSocket 升级同一个判定:SameSite 按站判、
|
|
33
|
+
* 不分端口,同一主机别的端口上的网页发一个简单 POST 就会带上 cookie
|
|
34
|
+
* - `?token=` 与登录接口按来源地址限速:失败计数见 LOGIN_MAX_FAILURES、LOGIN_FAILURE_WINDOW_MS,成功清零。
|
|
35
|
+
* 别的网页借 owner 的浏览器发起的登录(跨源的 POST、别的站点打开的 `?token=`)不比对也不计数,免得别人替 owner 把
|
|
36
|
+
* 地址锁住。带 cookie 的请求不计数:各种模式的 token 都够长(随机生成的 24 字节,固定的至少 32 个字符),
|
|
37
|
+
* 按 cookie 逐个猜猜不中;对 cookie 也锁定的话,共用一个来源地址(同一个反向代理)的已登录页面会被别人一起锁住
|
|
19
38
|
* - 身份恒为 `webui:console`(owner 级,单人模式语义)
|
|
20
39
|
*/
|
|
21
40
|
export declare function createAuthSystem(getToken: () => string): AuthSystem;
|
package/dist/auth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEzD,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AASnC,qDAAqD;AACrD,eAAO,MAAM,2BAA2B,QAAS,CAAC;AAclD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAC,GAAG,OAAO,CAO5E;AAmGD,MAAM,WAAW,UAAU;IACzB;;;OAGG;IACH,OAAO,CAAC,UAAU,EAAE,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAC3C,wCAAwC;IACxC,cAAc,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC;IAC9C,0EAA0E;IAC1E,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,SAAS,CAAC,GAAG,YAAY,GAAG,SAAS,CAAC;CAC3E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,MAAM,GAAG,UAAU,CA4InE;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,GAAG,IAAI,CAwBnE"}
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { BUILTIN_THEMES, DEFAULT_THEMES } from '@aalis/api-webui';
|
|
3
|
+
import { route } from './route.js';
|
|
1
4
|
const TOKEN_COOKIE = 'aalis_webui_token';
|
|
5
|
+
/** 同一来源地址在一个窗口内登录失败满这么多次,到窗口结束前它的登录一律回 429(正确的 token 也不收) */
|
|
6
|
+
const LOGIN_MAX_FAILURES = 10;
|
|
7
|
+
/** 登录失败的计数窗口,从这个地址的第一次失败算起;窗口结束自动解除,计数归零 */
|
|
8
|
+
const LOGIN_FAILURE_WINDOW_MS = 10 * 60 * 1000;
|
|
9
|
+
/** 失败计数表最多记这么多个地址:满了再记新地址时丢掉最早的那条,内存有界,也不因此变成全局锁定 */
|
|
10
|
+
export const LOGIN_FAILURE_ADDRESSES_MAX = 10_000;
|
|
11
|
+
/**
|
|
12
|
+
* token 的定时安全比较:两边先取 SHA-256 摘要再逐字节比,比较耗时与 token 的内容和长度都无关,
|
|
13
|
+
* 逐字节试探与按长度试探都得不到信号。
|
|
14
|
+
*/
|
|
15
|
+
function tokenEquals(given, expected) {
|
|
16
|
+
const digest = (s) => createHash('sha256').update(s).digest();
|
|
17
|
+
return timingSafeEqual(digest(given), digest(expected));
|
|
18
|
+
}
|
|
19
|
+
/** 不改状态的请求方法:不校验来源 */
|
|
20
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
21
|
+
/**
|
|
22
|
+
* 请求的来源能不能收,WebSocket 升级与改状态的请求(非 GET)共用。浏览器发起的握手与 POST 一律带 Origin,只收与本服务同源的:
|
|
23
|
+
* Origin 的主机与端口等于请求的 Host。同一主机别的端口上的网页与本服务同站,SameSite cookie 会随它的请求发出,
|
|
24
|
+
* 靠这一关拦下。没有 Origin 的是浏览器之外的客户端(脚本、命令行),不会被别的网页借用户的浏览器发起,交给 token。
|
|
25
|
+
* Host 按 Origin 的协议解析后再比,两边的默认端口都去掉(代理转来的 Host 可能写成 `x:443`)。
|
|
26
|
+
* 经反向代理访问时,代理要把原始的 Host 转给本服务。
|
|
27
|
+
*/
|
|
28
|
+
export function originAllowed(req) {
|
|
29
|
+
const { origin, host } = req.headers;
|
|
30
|
+
if (origin === undefined)
|
|
31
|
+
return true;
|
|
32
|
+
if (host === undefined || !URL.canParse(origin))
|
|
33
|
+
return false;
|
|
34
|
+
const { protocol, host: originHost } = new URL(origin);
|
|
35
|
+
const target = `${protocol}//${host}`;
|
|
36
|
+
return URL.canParse(target) && new URL(target).host === originHost;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 这次导航是不是别的网页发起的:Sec-Fetch-Site 为 cross-site 或 same-site。粘贴链接、书签与启动时自动打开的
|
|
40
|
+
* 一键登录链接是 none,本服务自己页面里的跳转是 same-origin;不带这个头的(浏览器之外的客户端、旧浏览器)不算
|
|
41
|
+
*/
|
|
42
|
+
function navigatedFromOtherSite(req) {
|
|
43
|
+
const site = req.headers['sec-fetch-site'];
|
|
44
|
+
return site === 'cross-site' || site === 'same-site';
|
|
45
|
+
}
|
|
2
46
|
function parseCookieValue(header, name) {
|
|
3
47
|
if (!header)
|
|
4
48
|
return undefined;
|
|
@@ -24,45 +68,58 @@ function buildSetCookie(name, value, maxAgeSec) {
|
|
|
24
68
|
function buildClearCookie(name) {
|
|
25
69
|
return `${name}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
|
|
26
70
|
}
|
|
27
|
-
/**
|
|
71
|
+
/** 登录页的脚本:文档级 CSP 只许同源脚本,不内联,由这个地址给出(不需登录) */
|
|
72
|
+
const LOGIN_SCRIPT_PATH = '/api/auth/login.js';
|
|
73
|
+
/** 提交 POST /api/auth/login,成功后刷新到 / */
|
|
74
|
+
const LOGIN_SCRIPT = `const f=document.getElementById('f'),t=document.getElementById('t'),b=document.getElementById('b'),e=document.getElementById('e');
|
|
75
|
+
f.addEventListener('submit',async ev=>{
|
|
76
|
+
ev.preventDefault();e.textContent='';b.disabled=true;
|
|
77
|
+
try{
|
|
78
|
+
const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:t.value.trim()})});
|
|
79
|
+
if(r.ok){location.replace('/');}else{const j=await r.json().catch(()=>({}));e.textContent=j.error||'token 不正确';b.disabled=false;t.select();}
|
|
80
|
+
}catch(err){e.textContent='网络错误';b.disabled=false;}
|
|
81
|
+
});
|
|
82
|
+
`;
|
|
83
|
+
/** 一个内置主题的颜色令牌,写成 CSS 自定义属性的声明 */
|
|
84
|
+
const themeTokens = (id) => Object.entries(BUILTIN_THEMES[id].colors)
|
|
85
|
+
.map(([name, value]) => `${name}:${value}`)
|
|
86
|
+
.join(';');
|
|
87
|
+
/**
|
|
88
|
+
* 登录页的样式:颜色取默认主题的令牌,深浅跟随系统(界面在登录之后才读得到本浏览器选的主题)。与界面同一套写法:
|
|
89
|
+
* 不用卡片、不加阴影,圆角用小档
|
|
90
|
+
*/
|
|
91
|
+
const LOGIN_STYLE = `:root{color-scheme:light dark;${themeTokens(DEFAULT_THEMES.dark)}}
|
|
92
|
+
@media (prefers-color-scheme: light){:root{${themeTokens(DEFAULT_THEMES.light)}}}
|
|
93
|
+
*{box-sizing:border-box}
|
|
94
|
+
body{margin:0;min-height:100vh;min-height:100dvh;display:flex;align-items:center;justify-content:center;padding:1rem;font-family:system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;font-size:0.875rem;line-height:1.375rem;background:var(--color-bg);color:var(--color-text)}
|
|
95
|
+
form{width:min(22.5rem,100%);display:flex;flex-direction:column;gap:0.75rem}
|
|
96
|
+
h1{margin:0;font-size:1rem;line-height:1.5rem;font-weight:600}
|
|
97
|
+
p{margin:0;color:var(--color-text-2)}
|
|
98
|
+
p.note{color:var(--color-warning-text)}
|
|
99
|
+
input{width:100%;height:2.25rem;padding:0 0.75rem;font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:1rem;color:var(--color-text);background:transparent;border:1px solid var(--color-border-control);border-radius:4px}
|
|
100
|
+
input:focus-visible,button:focus-visible{outline:2px solid var(--color-focus);outline-offset:2px}
|
|
101
|
+
button{height:2.25rem;font:inherit;font-weight:500;color:var(--color-on-accent);background:var(--color-accent);border:0;border-radius:4px;cursor:pointer}
|
|
102
|
+
button:hover{background:var(--color-accent-hover)}
|
|
103
|
+
button:disabled{opacity:0.6;cursor:wait}
|
|
104
|
+
.err{min-height:1.375rem;color:var(--color-danger-text)}`;
|
|
105
|
+
/** 登录页:只引用同源的登录脚本,不引入别的资源 */
|
|
28
106
|
function loginPageHtml(reason) {
|
|
29
107
|
const note = reason ? `<p class="note">${escapeHtml(reason)}</p>` : '';
|
|
30
108
|
return `<!DOCTYPE html><html lang="zh-CN"><head>
|
|
31
109
|
<meta charset="utf-8"><title>Aalis WebUI 登录</title>
|
|
32
110
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
33
111
|
<style>
|
|
34
|
-
|
|
35
|
-
*{box-sizing:border-box}
|
|
36
|
-
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#0b0d10;color:#e6e8eb}
|
|
37
|
-
.card{width:min(420px,90vw);padding:32px;background:#16191d;border:1px solid #262b32;border-radius:12px;box-shadow:0 12px 36px rgba(0,0,0,.4)}
|
|
38
|
-
h1{margin:0 0 8px;font-size:22px;font-weight:600}
|
|
39
|
-
p{margin:0 0 16px;color:#8b95a1;font-size:14px;line-height:1.5}
|
|
40
|
-
p.note{color:#ffb300}
|
|
41
|
-
input{width:100%;padding:10px 12px;font-size:14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#0b0d10;color:#e6e8eb;border:1px solid #2a3038;border-radius:6px;outline:none}
|
|
42
|
-
input:focus{border-color:#5b8def}
|
|
43
|
-
button{width:100%;margin-top:12px;padding:10px;font-size:14px;font-weight:500;background:#5b8def;color:#fff;border:0;border-radius:6px;cursor:pointer}
|
|
44
|
-
button:hover{background:#4a7de0}
|
|
45
|
-
button:disabled{opacity:.5;cursor:wait}
|
|
46
|
-
.err{margin-top:10px;color:#ff6b6b;font-size:13px;min-height:18px}
|
|
112
|
+
${LOGIN_STYLE}
|
|
47
113
|
</style></head>
|
|
48
|
-
<body><form
|
|
114
|
+
<body><form id="f">
|
|
49
115
|
<h1>Aalis WebUI</h1>
|
|
50
|
-
<p
|
|
116
|
+
<p>访问 token 在家目录的 state/webui/access.txt 里,启动日志「访问凭据已写入」那行给出它的完整路径;tokenMode 为 fixed 时它是配置项 fixedToken,启动后值存在家目录的 secrets.yaml 里(配置文件里是引用)。</p>
|
|
51
117
|
${note}
|
|
52
118
|
<input id="t" type="password" autocomplete="off" placeholder="访问 token" autofocus required>
|
|
53
119
|
<button type="submit" id="b">登录</button>
|
|
54
120
|
<div class="err" id="e"></div>
|
|
55
121
|
</form>
|
|
56
|
-
<script
|
|
57
|
-
const f=document.getElementById('f'),t=document.getElementById('t'),b=document.getElementById('b'),e=document.getElementById('e');
|
|
58
|
-
f.addEventListener('submit',async ev=>{
|
|
59
|
-
ev.preventDefault();e.textContent='';b.disabled=true;
|
|
60
|
-
try{
|
|
61
|
-
const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:t.value.trim()})});
|
|
62
|
-
if(r.ok){location.replace('/');}else{const j=await r.json().catch(()=>({}));e.textContent=j.error||'token 不正确';b.disabled=false;t.select();}
|
|
63
|
-
}catch(err){e.textContent='网络错误';b.disabled=false;}
|
|
64
|
-
});
|
|
65
|
-
</script></body></html>`;
|
|
122
|
+
<script src="${LOGIN_SCRIPT_PATH}"></script></body></html>`;
|
|
66
123
|
}
|
|
67
124
|
function escapeHtml(s) {
|
|
68
125
|
return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
@@ -73,77 +130,144 @@ function escapeHtml(s) {
|
|
|
73
130
|
* - token 由调用方经 getToken 提供,每次校验现取:persist 模式下 storage 晚上线时,调用方会把
|
|
74
131
|
* 启动时的临时 token 换成读回的持久化 token(见 index.ts 的 token 解析)
|
|
75
132
|
* - HttpOnly + SameSite=Strict cookie;`?token=` 一键登录;同源 POST /api/auth/login 提交
|
|
133
|
+
* - 改状态的请求(非 GET、HEAD、OPTIONS)按 {@link originAllowed} 校验来源,与 WebSocket 升级同一个判定:SameSite 按站判、
|
|
134
|
+
* 不分端口,同一主机别的端口上的网页发一个简单 POST 就会带上 cookie
|
|
135
|
+
* - `?token=` 与登录接口按来源地址限速:失败计数见 LOGIN_MAX_FAILURES、LOGIN_FAILURE_WINDOW_MS,成功清零。
|
|
136
|
+
* 别的网页借 owner 的浏览器发起的登录(跨源的 POST、别的站点打开的 `?token=`)不比对也不计数,免得别人替 owner 把
|
|
137
|
+
* 地址锁住。带 cookie 的请求不计数:各种模式的 token 都够长(随机生成的 24 字节,固定的至少 32 个字符),
|
|
138
|
+
* 按 cookie 逐个猜猜不中;对 cookie 也锁定的话,共用一个来源地址(同一个反向代理)的已登录页面会被别人一起锁住
|
|
76
139
|
* - 身份恒为 `webui:console`(owner 级,单人模式语义)
|
|
77
140
|
*/
|
|
78
141
|
export function createAuthSystem(getToken) {
|
|
79
142
|
const cookieMaxAge = 30 * 24 * 3600; // 30d;进程重启 token 轮换后无效
|
|
143
|
+
// 登录失败按来源地址计数。本机回环也计数:经同机反向代理来的请求全是回环地址,豁免回环等于不限速;
|
|
144
|
+
// owner 自己输错满额也只等到窗口结束,已登录的页面不受影响。
|
|
145
|
+
// 时间取单调时钟,键的插入顺序就是窗口结束的先后,过期的从表头剪掉;表满时也从表头丢,最多 LOGIN_FAILURE_ADDRESSES_MAX 条。
|
|
146
|
+
const failures = new Map();
|
|
147
|
+
/** 这个地址还要等多少毫秒才能再登录,不受限时为 0;先剪掉窗口已过的计数 */
|
|
148
|
+
function lockedFor(address) {
|
|
149
|
+
const now = performance.now();
|
|
150
|
+
for (const [key, entry] of failures) {
|
|
151
|
+
if (entry.resetAt > now)
|
|
152
|
+
break;
|
|
153
|
+
failures.delete(key);
|
|
154
|
+
}
|
|
155
|
+
const entry = failures.get(address);
|
|
156
|
+
return entry && entry.count >= LOGIN_MAX_FAILURES ? entry.resetAt - now : 0;
|
|
157
|
+
}
|
|
158
|
+
/** 记一次失败;须紧跟在 lockedFor 之后调用,表里留着的计数都还在窗口内 */
|
|
159
|
+
function recordFailure(address) {
|
|
160
|
+
const entry = failures.get(address);
|
|
161
|
+
if (entry) {
|
|
162
|
+
entry.count++;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (failures.size >= LOGIN_FAILURE_ADDRESSES_MAX)
|
|
166
|
+
failures.delete(failures.keys().next().value);
|
|
167
|
+
failures.set(address, { count: 1, resetAt: performance.now() + LOGIN_FAILURE_WINDOW_MS });
|
|
168
|
+
}
|
|
169
|
+
/** 给被限速的登录写好 429 与 Retry-After,返回给人看的说明 */
|
|
170
|
+
function rejectLocked(res, waitMs) {
|
|
171
|
+
res.status(429);
|
|
172
|
+
res.setHeader('Retry-After', String(Math.ceil(waitMs / 1000)));
|
|
173
|
+
return `登录失败次数过多,请 ${Math.ceil(waitMs / 60_000)} 分钟后再试。`;
|
|
174
|
+
}
|
|
80
175
|
function tokenAuthed(req) {
|
|
81
176
|
const cookieToken = parseCookieValue(req.headers.cookie, TOKEN_COOKIE);
|
|
82
|
-
return !!cookieToken && cookieToken
|
|
177
|
+
return !!cookieToken && tokenEquals(cookieToken, getToken());
|
|
83
178
|
}
|
|
84
179
|
function identify(req) {
|
|
85
180
|
return tokenAuthed(req) ? { platform: 'webui', userId: 'console' } : undefined;
|
|
86
181
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
182
|
+
const addressOf = (req) => req.socket.remoteAddress ?? '';
|
|
183
|
+
function install(expressApp) {
|
|
184
|
+
// 改状态的请求先看来源:别的网页借 owner 的浏览器发来的(带着 cookie)一律拒,登录与注销也一样
|
|
185
|
+
expressApp.use((req, res, next) => {
|
|
186
|
+
if (SAFE_METHODS.has(req.method) || originAllowed(req))
|
|
187
|
+
return next();
|
|
188
|
+
res
|
|
189
|
+
.status(403)
|
|
190
|
+
.json({ error: '请求来源与本服务不同源(经反向代理访问时,代理要转发原始的 Host)' });
|
|
191
|
+
});
|
|
192
|
+
// 登录页的脚本不需登录
|
|
193
|
+
route(expressApp, 'GET /api/auth/login.js', (_req, res) => {
|
|
194
|
+
res.type('application/javascript').send(LOGIN_SCRIPT);
|
|
195
|
+
});
|
|
196
|
+
// ?token= 命中 → 设置 cookie → 302 到同一路径,只去掉 token,其余查询串(如日志的筛选)照留。别的站点打开的不认 token,
|
|
197
|
+
// 按没带 token 往下走
|
|
198
|
+
expressApp.use((req, res, next) => {
|
|
199
|
+
const qToken = typeof req.query?.token === 'string' ? req.query.token : undefined;
|
|
200
|
+
if (req.method !== 'GET' || !qToken || navigatedFromOtherSite(req))
|
|
201
|
+
return next();
|
|
202
|
+
const address = addressOf(req);
|
|
203
|
+
const waitMs = lockedFor(address);
|
|
204
|
+
if (waitMs > 0) {
|
|
205
|
+
const note = rejectLocked(res, waitMs);
|
|
206
|
+
res.type('html').send(loginPageHtml(note));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
92
209
|
const token = getToken();
|
|
93
|
-
if (qToken
|
|
210
|
+
if (tokenEquals(qToken, token)) {
|
|
211
|
+
failures.delete(address);
|
|
94
212
|
res.setHeader('Set-Cookie', buildSetCookie(TOKEN_COOKIE, token, cookieMaxAge));
|
|
95
|
-
|
|
213
|
+
const rest = new URLSearchParams(req.originalUrl.slice(req.originalUrl.indexOf('?') + 1));
|
|
214
|
+
rest.delete('token');
|
|
215
|
+
const search = rest.toString();
|
|
216
|
+
res.redirect(302, search ? `${req.path}?${search}` : req.path);
|
|
96
217
|
return;
|
|
97
218
|
}
|
|
219
|
+
recordFailure(address);
|
|
98
220
|
res.status(401).type('html').send(loginPageHtml('URL 中的 token 不正确,请手动输入。'));
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
221
|
+
});
|
|
222
|
+
// 登录:{ token }。跨源的已在上面拒掉,不计数
|
|
223
|
+
route(expressApp, 'POST /api/auth/login', (req, res) => {
|
|
224
|
+
const address = addressOf(req);
|
|
225
|
+
const waitMs = lockedFor(address);
|
|
226
|
+
if (waitMs > 0) {
|
|
227
|
+
res.json({ error: rejectLocked(res, waitMs) });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
103
230
|
const body = req.body ?? {};
|
|
104
231
|
const token = getToken();
|
|
105
|
-
if (typeof body.token === 'string' && body.token
|
|
232
|
+
if (typeof body.token === 'string' && tokenEquals(body.token, token)) {
|
|
233
|
+
failures.delete(address);
|
|
106
234
|
res.setHeader('Set-Cookie', buildSetCookie(TOKEN_COOKIE, token, cookieMaxAge));
|
|
107
235
|
res.json({ ok: true, identity: { platform: 'webui', userId: 'console' } });
|
|
108
236
|
}
|
|
109
237
|
else {
|
|
110
|
-
|
|
238
|
+
recordFailure(address);
|
|
239
|
+
res.status(401).json({ error: 'token 不正确' });
|
|
111
240
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (url === '/api/auth/status' && req.method === 'GET') {
|
|
241
|
+
});
|
|
242
|
+
// 登录状态(含当前身份,供前端顶栏展示)
|
|
243
|
+
route(expressApp, 'GET /api/auth/status', (req, res) => {
|
|
116
244
|
const identity = identify(req);
|
|
117
245
|
res.json({ authed: !!identity, identity });
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (url === '/api/auth/logout' && req.method === 'POST') {
|
|
246
|
+
});
|
|
247
|
+
// 注销:清 token cookie
|
|
248
|
+
route(expressApp, 'POST /api/auth/logout', (_req, res) => {
|
|
122
249
|
res.setHeader('Set-Cookie', buildClearCookie(TOKEN_COOKIE));
|
|
123
250
|
res.json({ ok: true });
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
res.status(401).end();
|
|
142
|
-
};
|
|
251
|
+
});
|
|
252
|
+
// 已登录的放行;没登录的 API 一律 401,GET 回内联登录页(覆盖 SPA shell,不暴露任何静态资源)
|
|
253
|
+
expressApp.use((req, res, next) => {
|
|
254
|
+
if (tokenAuthed(req))
|
|
255
|
+
return next();
|
|
256
|
+
if (req.path.startsWith('/api/')) {
|
|
257
|
+
res.status(401).json({ error: 'unauthenticated' });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (req.method === 'GET') {
|
|
261
|
+
res.status(401).type('html').send(loginPageHtml());
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
res.status(401).end();
|
|
265
|
+
});
|
|
266
|
+
}
|
|
143
267
|
function verifyWsClient(req) {
|
|
144
268
|
return tokenAuthed(req);
|
|
145
269
|
}
|
|
146
|
-
return {
|
|
270
|
+
return { install, verifyWsClient, identify };
|
|
147
271
|
}
|
|
148
272
|
/**
|
|
149
273
|
* 跨平台打开默认浏览器到指定 URL(无外部依赖)
|