@lasting-beauty/chat-hub 0.1.0-beta.1

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/package.json ADDED
@@ -0,0 +1,118 @@
1
+ {
2
+ "name": "@lasting-beauty/chat-hub",
3
+ "private": false,
4
+ "version": "0.1.0-beta.1",
5
+ "description": "面向单使用者的飞书与钉钉工作信息聚合工具",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Lasting-Beauty/chat-hub.git"
10
+ },
11
+ "publishConfig": {
12
+ "registry": "https://registry.npmjs.org/",
13
+ "tag": "next",
14
+ "access": "public"
15
+ },
16
+ "allowScripts": {
17
+ "@larksuite/cli@1.0.92": true,
18
+ "better-sqlite3@13.0.3": true,
19
+ "dingtalk-workspace-cli": false,
20
+ "esbuild@0.28.2": true,
21
+ "fsevents@2.3.3": false
22
+ },
23
+ "type": "module",
24
+ "bin": {
25
+ "chat-hub": "bin/chat-hub.mjs"
26
+ },
27
+ "files": [
28
+ "bin/chat-hub.mjs",
29
+ "cli/data-lifecycle.mjs",
30
+ "cli/deploy.mjs",
31
+ "cli/external-verification.mjs",
32
+ "cli/ip-certificate.mjs",
33
+ "cli/platform.mjs",
34
+ "cli/runtime.mjs",
35
+ "cli/server-maintenance.mjs",
36
+ "cli/server-verification.mjs",
37
+ "cli/windows-data-migration.mjs",
38
+ "dist",
39
+ "scripts/install-platform-clis.mjs",
40
+ "skills/chat-hub-setup/scripts/gateway.mjs"
41
+ ],
42
+ "engines": {
43
+ "node": ">=22.22.2 <23 || >=24.15 <25"
44
+ },
45
+ "scripts": {
46
+ "dev": "vite --host 127.0.0.1",
47
+ "dev:live": "vite --mode live --host 127.0.0.1",
48
+ "server:dev": "tsx watch server/index.ts",
49
+ "server:start": "tsx server/index.ts",
50
+ "server:synthetic": "tsx server/synthetic-index.ts",
51
+ "smoke:lark-chat": "tsx server/scripts/smoke-lark-chat.ts",
52
+ "smoke:calendar": "tsx server/scripts/smoke-calendar.ts",
53
+ "smoke:dingtalk-minutes": "tsx server/scripts/smoke-dingtalk-minutes.ts",
54
+ "smoke:table-materials": "tsx server/scripts/smoke-table-materials.ts",
55
+ "sync:lark-chat": "tsx server/scripts/sync-lark-chat.ts",
56
+ "data:contracts": "tsx server/scripts/cli-fixtures.ts",
57
+ "data:generate": "tsx server/scripts/synthetic-data.ts generate",
58
+ "data:validate": "tsx server/scripts/synthetic-data.ts validate",
59
+ "data:seed": "tsx server/scripts/synthetic-data.ts seed",
60
+ "test:stress-summary": "tsx server/scripts/stress-summary.ts",
61
+ "eval:summary": "tsx server/scripts/evaluate-summary.ts",
62
+ "calibrate:analysis-model": "tsx server/scripts/calibrate-analysis-model.ts",
63
+ "build": "npm run typecheck && npm run build:client && npm run build:server",
64
+ "build:client": "vite build",
65
+ "build:server": "esbuild server/index.ts --bundle --platform=node --format=esm --target=node22 --packages=external --outfile=dist/server/index.js",
66
+ "preview": "vite preview --host 127.0.0.1",
67
+ "preview:live": "vite preview --mode live --host 127.0.0.1",
68
+ "typecheck": "tsc --noEmit",
69
+ "lint": "eslint .",
70
+ "format": "prettier --check \"src/**/*.{ts,tsx,css}\" \"server/**/*.ts\" \"shared/**/*.ts\" \"tests/**/*.{ts,json}\" \"bin/**/*.mjs\" \"cli/**/*.mjs\" \"installer/**/*.{mjs,json}\" \"scripts/**/*.mjs\" \"*.{json,js,ts}\"",
71
+ "format:write": "prettier --write \"src/**/*.{ts,tsx,css}\" \"server/**/*.ts\" \"shared/**/*.ts\" \"tests/**/*.{ts,json}\" \"bin/**/*.mjs\" \"cli/**/*.mjs\" \"installer/**/*.{mjs,json}\" \"scripts/**/*.mjs\" \"*.{json,js,ts}\"",
72
+ "test": "vitest run --testTimeout=15000",
73
+ "test:setup": "node --test skills/chat-hub-setup/tests/gateway.test.mjs skills/chat-hub-setup/tests/runtime.test.mjs",
74
+ "test:cli": "node --test cli/cli.test.mjs cli/deploy.test.mjs cli/data-lifecycle.test.mjs cli/external-verification.test.mjs cli/ip-certificate.test.mjs cli/platform.test.mjs cli/server-maintenance.test.mjs cli/server-verification.test.mjs cli/windows-data-migration.test.mjs",
75
+ "test:release-policy": "node --test scripts/release-policy.node-test.mjs",
76
+ "test:installer": "node --test installer/test/setup.test.mjs",
77
+ "verify:package": "npm run build && node scripts/verify-package.mjs",
78
+ "verify:installer-package": "node scripts/verify-installer-package.mjs",
79
+ "package:internal": "npm run build && node scripts/verify-package.mjs --artifact-dir artifacts/cli",
80
+ "verify:release": "node scripts/verify-release.mjs",
81
+ "postinstall": "node scripts/install-platform-clis.mjs",
82
+ "prepack": "npm run build",
83
+ "check": "npm run format && npm run lint && npm run test && npm run build && npm run test:setup && npm run test:cli && npm run test:installer && npm run verify:installer-package && npm run test:release-policy"
84
+ },
85
+ "dependencies": {
86
+ "@larksuite/cli": "1.0.92",
87
+ "antd": "6.6.3",
88
+ "better-sqlite3": "13.0.3",
89
+ "dingtalk-workspace-cli": "1.0.60",
90
+ "fastify": "5.12.3",
91
+ "lucide-react": "1.42.0",
92
+ "react": "19.2.8",
93
+ "react-dom": "19.2.8",
94
+ "zod": "4.5.4"
95
+ },
96
+ "devDependencies": {
97
+ "@eslint/js": "10.0.1",
98
+ "@testing-library/jest-dom": "7.0.1",
99
+ "@testing-library/react": "16.3.3",
100
+ "@testing-library/user-event": "14.6.7",
101
+ "@types/better-sqlite3": "9.6.0",
102
+ "@types/node": "22.20.1",
103
+ "@types/react": "19.2.18",
104
+ "@types/react-dom": "19.2.7",
105
+ "esbuild": "0.28.2",
106
+ "eslint": "10.10.0",
107
+ "eslint-plugin-react-hooks": "7.1.1",
108
+ "eslint-plugin-react-refresh": "0.5.6",
109
+ "globals": "16.4.0",
110
+ "jsdom": "30.0.1",
111
+ "prettier": "3.9.6",
112
+ "tsx": "4.23.13",
113
+ "typescript": "6.0.2",
114
+ "typescript-eslint": "8.69.0",
115
+ "vite": "8.2.2",
116
+ "vitest": "5.0.0"
117
+ }
118
+ }
@@ -0,0 +1,163 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { createRequire } from 'node:module';
4
+ import {
5
+ chmodSync,
6
+ copyFileSync,
7
+ existsSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ readFileSync,
12
+ readdirSync,
13
+ renameSync,
14
+ rmSync,
15
+ } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { dirname, join, resolve, sep } from 'node:path';
18
+
19
+ const expectedDingtalkVersion = '1.0.60';
20
+ const packageRequire = createRequire(import.meta.url);
21
+ const archiveByPlatform = Object.freeze({
22
+ 'darwin-x64': 'dws-darwin-amd64.tar.gz',
23
+ 'darwin-arm64': 'dws-darwin-arm64.tar.gz',
24
+ 'linux-x64': 'dws-linux-amd64.tar.gz',
25
+ 'linux-arm64': 'dws-linux-arm64.tar.gz',
26
+ 'win32-x64': 'dws-windows-amd64.zip',
27
+ 'win32-arm64': 'dws-windows-arm64.zip',
28
+ });
29
+
30
+ installDingtalkBinary();
31
+
32
+ function installDingtalkBinary() {
33
+ const packageJsonPath = packageRequire.resolve('dingtalk-workspace-cli/package.json');
34
+ const packageRoot = dirname(packageJsonPath);
35
+ const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
36
+ if (manifest.version !== expectedDingtalkVersion) {
37
+ throw new Error(`未经验证的钉钉 CLI 版本:${manifest.version}`);
38
+ }
39
+
40
+ const archiveName = archiveByPlatform[`${process.platform}-${process.arch}`];
41
+ if (!archiveName) {
42
+ throw new Error(`钉钉 CLI 不支持当前平台:${process.platform}-${process.arch}`);
43
+ }
44
+ const archivePath = join(packageRoot, 'assets', archiveName);
45
+ const checksumsPath = join(packageRoot, 'assets', 'checksums.txt');
46
+ const expectedHash = expectedArchiveHash(checksumsPath, archiveName);
47
+ const actualHash = sha256(archivePath);
48
+ if (actualHash !== expectedHash) {
49
+ throw new Error(`钉钉 CLI 归档 SHA-256 不匹配:${archiveName}`);
50
+ }
51
+
52
+ const temporaryRoot = mkdtempSync(join(tmpdir(), 'chat-hub-dws-binary-'));
53
+ const extractionRoot = join(temporaryRoot, 'extracted');
54
+ const stagedVendor = join(packageRoot, `.chat-hub-vendor-stage-${process.pid}`);
55
+ const backupVendor = join(packageRoot, `.chat-hub-vendor-backup-${process.pid}`);
56
+ const vendor = join(packageRoot, 'vendor');
57
+ assertChildPath(packageRoot, stagedVendor);
58
+ assertChildPath(packageRoot, backupVendor);
59
+ assertChildPath(packageRoot, vendor);
60
+ let previousMoved = false;
61
+ let published = false;
62
+
63
+ try {
64
+ mkdirSync(extractionRoot, { recursive: true, mode: 0o700 });
65
+ execFileSync('tar', ['-xf', archivePath, '-C', extractionRoot], {
66
+ timeout: 120_000,
67
+ windowsHide: true,
68
+ stdio: 'pipe',
69
+ });
70
+ const extractedBinary = findBinary(extractionRoot);
71
+ if (!extractedBinary) throw new Error(`钉钉 CLI 归档缺少 dws 二进制:${archiveName}`);
72
+
73
+ rmSync(stagedVendor, { recursive: true, force: true });
74
+ rmSync(backupVendor, { recursive: true, force: true });
75
+ // The package is commonly installed by root, while the service runs as the
76
+ // dedicated chat-hub user. The directory therefore needs search permission
77
+ // for that user; the binary itself remains immutable to non-root users.
78
+ mkdirSync(stagedVendor, { mode: 0o755 });
79
+ const targetName = process.platform === 'win32' ? 'dws.exe' : 'dws';
80
+ const stagedBinary = join(stagedVendor, targetName);
81
+ copyFileSync(extractedBinary, stagedBinary);
82
+ if (process.platform !== 'win32') chmodSync(stagedBinary, 0o755);
83
+
84
+ const version = execFileSync(stagedBinary, ['--version'], {
85
+ encoding: 'utf8',
86
+ timeout: 30_000,
87
+ windowsHide: true,
88
+ stdio: 'pipe',
89
+ }).trim();
90
+ if (!/^dws version v1\.0\.60(?:\s|$)/.test(version)) {
91
+ throw new Error(`钉钉 CLI 二进制版本未经验证:${version}`);
92
+ }
93
+
94
+ if (existsSync(vendor)) {
95
+ if (lstatSync(vendor).isSymbolicLink()) {
96
+ throw new Error('拒绝替换符号链接形式的钉钉 CLI vendor 目录');
97
+ }
98
+ renameWithRetry(vendor, backupVendor);
99
+ previousMoved = true;
100
+ }
101
+ try {
102
+ renameWithRetry(stagedVendor, vendor);
103
+ if (process.platform !== 'win32') chmodSync(vendor, 0o755);
104
+ published = true;
105
+ } catch (error) {
106
+ if (previousMoved && !existsSync(vendor)) renameWithRetry(backupVendor, vendor);
107
+ throw error;
108
+ }
109
+ if (previousMoved) rmSync(backupVendor, { recursive: true, force: true });
110
+ console.log(`Chat Hub 已准备 dws ${expectedDingtalkVersion},未安装钉钉 Agent Skills`);
111
+ } finally {
112
+ rmSync(temporaryRoot, { recursive: true, force: true });
113
+ rmSync(stagedVendor, { recursive: true, force: true });
114
+ if (published) rmSync(backupVendor, { recursive: true, force: true });
115
+ }
116
+ }
117
+
118
+ function expectedArchiveHash(checksumsPath, archiveName) {
119
+ const match = readFileSync(checksumsPath, 'utf8')
120
+ .split(/\r?\n/)
121
+ .map((line) => line.trim().match(/^([0-9a-f]{64})\s{2}(.+)$/i))
122
+ .find((entry) => entry?.[2] === archiveName);
123
+ if (!match) throw new Error(`钉钉 CLI 校验清单缺少:${archiveName}`);
124
+ return match[1].toLowerCase();
125
+ }
126
+
127
+ function sha256(path) {
128
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
129
+ }
130
+
131
+ function findBinary(directory) {
132
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
133
+ const path = join(directory, entry.name);
134
+ const stat = lstatSync(path);
135
+ if (stat.isSymbolicLink()) throw new Error('钉钉 CLI 归档包含符号链接,拒绝安装');
136
+ if (stat.isDirectory()) {
137
+ const nested = findBinary(path);
138
+ if (nested) return nested;
139
+ } else if (entry.name === 'dws' || entry.name === 'dws.exe') {
140
+ return path;
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ function assertChildPath(parent, child) {
147
+ const prefix = `${resolve(parent)}${sep}`;
148
+ if (!resolve(child).startsWith(prefix)) throw new Error('钉钉 CLI 安装目标越界');
149
+ }
150
+
151
+ function renameWithRetry(source, destination) {
152
+ const retriable = new Set(['EPERM', 'EACCES', 'EBUSY']);
153
+ for (let attempt = 0; ; attempt += 1) {
154
+ try {
155
+ renameSync(source, destination);
156
+ return;
157
+ } catch (error) {
158
+ if (!retriable.has(error?.code) || attempt >= 7) throw error;
159
+ const delay = Math.min(25 * 2 ** attempt, 400);
160
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,360 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
5
+ import { isIP } from 'node:net';
6
+
7
+ const hopHeaders = new Set([
8
+ 'connection',
9
+ 'keep-alive',
10
+ 'transfer-encoding',
11
+ 'upgrade',
12
+ 'proxy-authorization',
13
+ 'proxy-authenticate',
14
+ 'te',
15
+ 'trailer',
16
+ ]);
17
+ const secureHeaders = {
18
+ 'cache-control': 'no-store',
19
+ 'x-content-type-options': 'nosniff',
20
+ 'referrer-policy': 'no-referrer',
21
+ 'content-security-policy': "frame-ancestors 'none'",
22
+ };
23
+ function json(res, status, value) {
24
+ res.writeHead(status, { ...secureHeaders, 'content-type': 'application/json; charset=utf-8' });
25
+ res.end(JSON.stringify(value));
26
+ }
27
+ function failure(res, status, code, message) {
28
+ json(res, status, { ok: false, error: { code, message, retryable: false } });
29
+ }
30
+ function loopback(address) {
31
+ return ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address);
32
+ }
33
+ function ipv4Number(value) {
34
+ const parts = value.split('.').map(Number);
35
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
36
+ return undefined;
37
+ return parts.reduce((sum, part) => ((sum << 8) | part) >>> 0, 0);
38
+ }
39
+ export function sameSubnet(address, lanIp, netmask) {
40
+ const peer = ipv4Number(address?.replace(/^::ffff:/, '') ?? '');
41
+ const ip = ipv4Number(lanIp);
42
+ const mask = ipv4Number(netmask);
43
+ return (
44
+ peer !== undefined && ip !== undefined && mask !== undefined && (peer & mask) === (ip & mask)
45
+ );
46
+ }
47
+ async function body(req) {
48
+ let result = '';
49
+ for await (const chunk of req) {
50
+ result += chunk;
51
+ if (result.length > 4096) throw new Error('request_too_large');
52
+ }
53
+ return JSON.parse(result);
54
+ }
55
+ function localAction(req, port) {
56
+ return (
57
+ loopback(req.socket.remoteAddress) &&
58
+ req.headers.host === `127.0.0.1:${port}` &&
59
+ req.headers.origin === `http://127.0.0.1:${port}` &&
60
+ req.headers['x-chat-hub-local-action'] === 'phone-pair'
61
+ );
62
+ }
63
+ function proxy(
64
+ req,
65
+ res,
66
+ port,
67
+ secure,
68
+ phoneHttp = false,
69
+ publicProxy = false,
70
+ publicScheme = 'https',
71
+ ) {
72
+ const forwardedFor = publicProxy
73
+ ? String(req.headers['x-forwarded-for'] ?? '')
74
+ .split(',')[0]
75
+ .trim()
76
+ : '';
77
+ const headers = Object.fromEntries(
78
+ Object.entries(req.headers).filter(
79
+ ([key]) =>
80
+ !hopHeaders.has(key) &&
81
+ !key.startsWith('x-forwarded-') &&
82
+ key !== 'forwarded' &&
83
+ key !== 'x-chat-hub-token',
84
+ ),
85
+ );
86
+ if (phoneHttp && headers.cookie) {
87
+ // A prior HTTPS Secure cookie cannot be overwritten from HTTP. Use a separate
88
+ // browser cookie and translate it only on this authenticated phone entrance.
89
+ headers.cookie = headers.cookie
90
+ .split(';')
91
+ .map((part) => part.trim())
92
+ .filter((part) => !part.startsWith('chat_hub_session='))
93
+ .map((part) => part.replace(/^chat_hub_http_session=/, 'chat_hub_session='))
94
+ .join('; ');
95
+ }
96
+ if (publicProxy && isIP(forwardedFor)) headers['x-forwarded-for'] = forwardedFor;
97
+ if (publicProxy) headers['x-forwarded-proto'] = publicScheme;
98
+ // Preserve Host and Origin: a phone must never acquire local-only platform controls.
99
+ const upstream = http.request(
100
+ { host: '127.0.0.1', port, path: req.url, method: req.method, headers, timeout: 15000 },
101
+ (response) => {
102
+ const outgoing = Object.fromEntries(
103
+ Object.entries(response.headers).filter(([key]) => !hopHeaders.has(key)),
104
+ );
105
+ if (phoneHttp && outgoing['set-cookie']) {
106
+ outgoing['set-cookie'] = outgoing['set-cookie'].map((cookie) =>
107
+ cookie.replace(/^chat_hub_session=/, 'chat_hub_http_session='),
108
+ );
109
+ }
110
+ if (secure && outgoing['set-cookie']) {
111
+ outgoing['set-cookie'] = outgoing['set-cookie'].map((cookie) =>
112
+ /;\s*Secure(?:;|$)/i.test(cookie) ? cookie : `${cookie}; Secure`,
113
+ );
114
+ }
115
+ res.writeHead(response.statusCode ?? 502, { ...outgoing, ...secureHeaders });
116
+ response.pipe(res);
117
+ response.on('error', () => res.destroy());
118
+ },
119
+ );
120
+ upstream.on('timeout', () => upstream.destroy());
121
+ upstream.on('error', () => {
122
+ if (!res.headersSent)
123
+ failure(res, 502, 'gateway_unavailable', '本机服务暂不可用,请在电脑检查运行状态');
124
+ else res.destroy();
125
+ });
126
+ req.on('aborted', () => upstream.destroy());
127
+ req.pipe(upstream);
128
+ }
129
+
130
+ export async function startGateway(config) {
131
+ let pairing;
132
+ let failures = 0;
133
+ let failureWindow = Date.now();
134
+ const servers = [];
135
+ const phoneSecure = config.phoneProtocol !== 'http';
136
+ const phonePort = phoneSecure ? config.httpsPort : config.httpPort;
137
+ const phoneOrigin = config.lanIp
138
+ ? `${phoneSecure ? 'https' : 'http'}://${config.lanIp}:${phonePort}`
139
+ : undefined;
140
+ const transportGuide = phoneSecure
141
+ ? `<p>首次先在手机安装并信任本机证书。</p><p>证书下载:${config.lanIp ? `http://${config.lanIp}:${config.httpPort}/certificate` : '未启用'}</p><p>证书指纹(请在手机核对):${config.fingerprint ?? ''}</p>`
142
+ : '<p>无需安装证书。HTTP 不加密访问码、会话与工作内容,仅在可信局域网使用。</p>';
143
+ const nonce = randomBytes(18).toString('base64url');
144
+ const page = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Chat Hub 手机连接</title><style nonce="${nonce}">body{max-width:640px;margin:50px auto;padding:20px;font:17px system-ui;color:#25312e;background:#f7f8fa}button{padding:12px;background:#287968;color:white;border:0;border-radius:8px}pre{font-size:24px;white-space:pre-wrap}</style><h1>连接手机</h1><p>电脑和手机保持同一局域网。打开手机地址,在“本地访问密钥”输入框填写这里生成的短时配对码,即可登录。</p>${transportGuide}<p>手机地址:${phoneOrigin ?? '未启用手机访问'}</p><button id="pair">生成一次性配对码</button><pre id="code"></pre><p>有效期 10 分钟,成功使用后失效;仅在这里显示,不写入日志。平台授权仍在电脑完成。</p><a href="/">返回 Chat Hub</a><script nonce="${nonce}">document.querySelector('#pair').onclick=async()=>{const r=await fetch('/__setup/pair',{method:'POST',headers:{'x-chat-hub-local-action':'phone-pair'}});const v=await r.json();document.querySelector('#code').textContent=r.ok?v.code:v.error.message;};</script></html>`;
145
+ const localHandler = async (req, res) => {
146
+ if (!loopback(req.socket.remoteAddress) || req.headers.host !== `127.0.0.1:${config.httpPort}`)
147
+ return failure(res, 403, 'invalid_host', '请使用本机入口');
148
+ if (req.url === '/__setup/status' && req.method === 'GET')
149
+ return json(res, 200, { ok: true, id: config.id, mode: config.mode, phoneOrigin });
150
+ if (req.url === '/__setup/phone' && req.method === 'GET') {
151
+ res.writeHead(200, {
152
+ ...secureHeaders,
153
+ 'content-type': 'text/html; charset=utf-8',
154
+ 'content-security-policy': `default-src 'none'; connect-src 'self'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; frame-ancestors 'none'`,
155
+ });
156
+ return res.end(page);
157
+ }
158
+ if (req.url === '/__setup/pair') {
159
+ if (req.method !== 'POST' || !localAction(req, config.httpPort))
160
+ return failure(res, 403, 'local_action_required', '请在电脑手机连接页生成配对码');
161
+ if (!config.lanIp) return failure(res, 409, 'lan_disabled', '本次未启用手机入口');
162
+ if (pairing?.pending) return failure(res, 409, 'pair_in_progress', '正在完成配对,请稍后');
163
+ const code = randomBytes(8).toString('hex').toUpperCase();
164
+ pairing = { code, expiresAt: Date.now() + 600000, pending: false };
165
+ return json(res, 200, { code: code.match(/.{4}/g).join('-'), expiresInSeconds: 600 });
166
+ }
167
+ if (req.url.startsWith('/__setup/')) return failure(res, 404, 'not_found', '入口不存在');
168
+ proxy(req, res, config.frontendPort, false);
169
+ };
170
+ const publicHost = config.publicHost ?? config.publicDomain;
171
+ const publicScheme = config.publicScheme ?? 'https';
172
+ const publicSecure = publicScheme === 'https';
173
+ const publicPort = publicSecure ? 443 : 80;
174
+ const publicOrigin = publicHost ? `${publicScheme}://${publicHost}` : undefined;
175
+ const publicHandler = async (req, res) => {
176
+ const allowedHost =
177
+ req.headers.host === publicHost || req.headers.host === `${publicHost}:${publicPort}`;
178
+ if (
179
+ !loopback(req.socket.remoteAddress) ||
180
+ !allowedHost ||
181
+ req.headers['x-forwarded-proto'] !== publicScheme
182
+ ) {
183
+ return failure(
184
+ res,
185
+ 403,
186
+ 'gateway_required',
187
+ `公网入口只接受来自本机 Caddy 的 ${publicScheme.toUpperCase()} 请求`,
188
+ );
189
+ }
190
+ if (req.headers.origin && req.headers.origin !== publicOrigin)
191
+ return failure(res, 403, 'invalid_origin', '请求来源不正确');
192
+ if (!['GET', 'HEAD'].includes(req.method) && req.headers.origin !== publicOrigin)
193
+ return failure(res, 403, 'invalid_origin', '请求来源不正确');
194
+ if (
195
+ req.url.startsWith('/__setup/') ||
196
+ req.url === '/api/session/local' ||
197
+ req.url.startsWith('/api/local-access/')
198
+ ) {
199
+ return failure(res, 403, 'local_action_required', '公网入口不提供本机专用操作');
200
+ }
201
+ proxy(req, res, config.frontendPort, publicSecure, false, true, publicScheme);
202
+ };
203
+ const lanAllowed = (req, port) =>
204
+ req.headers.host === `${config.lanIp}:${port}` &&
205
+ sameSubnet(req.socket.remoteAddress, config.lanIp, config.netmask);
206
+ const phoneHandler = async (req, res) => {
207
+ if (!lanAllowed(req, phonePort)) return failure(res, 403, 'lan_only', '仅限当前局域网访问');
208
+ if (req.headers.origin && req.headers.origin !== phoneOrigin)
209
+ return failure(res, 403, 'invalid_origin', '请使用手机入口');
210
+ if (!['GET', 'HEAD'].includes(req.method) && req.headers.origin !== phoneOrigin)
211
+ return failure(res, 403, 'invalid_origin', '请求来源不正确');
212
+ if (
213
+ req.url.startsWith('/__setup/') ||
214
+ req.url === '/api/session/local' ||
215
+ req.url.startsWith('/api/local-access/') ||
216
+ req.url.includes('/auth-flows/') ||
217
+ (req.url.startsWith('/api/connections') && req.method !== 'GET')
218
+ ) {
219
+ return failure(res, 403, 'local_action_required', '此操作请在电脑本机页面完成');
220
+ }
221
+ if (req.url === '/api/session' && req.method === 'POST') {
222
+ if (Date.now() - failureWindow > 60000) {
223
+ failures = 0;
224
+ failureWindow = Date.now();
225
+ }
226
+ if (failures >= 5) return failure(res, 429, 'rate_limited', '尝试过于频繁,请一分钟后重试');
227
+ let candidate;
228
+ try {
229
+ candidate = (await body(req)).token;
230
+ } catch {
231
+ candidate = undefined;
232
+ }
233
+ const normalized =
234
+ typeof candidate === 'string' ? candidate.replaceAll('-', '').toUpperCase() : '';
235
+ if (
236
+ !pairing ||
237
+ pairing.pending ||
238
+ pairing.expiresAt <= Date.now() ||
239
+ !/^[A-F0-9]{16}$/.test(normalized) ||
240
+ normalized.length !== pairing.code.length ||
241
+ !timingSafeEqual(Buffer.from(normalized), Buffer.from(pairing.code))
242
+ ) {
243
+ failures++;
244
+ return failure(res, 401, 'unauthorized', '配对码无效或已过期,请在电脑重新生成');
245
+ }
246
+ pairing.pending = true;
247
+ try {
248
+ // Exchange a user-confirmed one-time phone code for the app's existing session.
249
+ // The platform/API master secret is never read, copied or returned.
250
+ const response = await fetch(`http://127.0.0.1:${config.apiPort}/api/session/local`, {
251
+ method: 'POST',
252
+ headers: {
253
+ origin: `http://127.0.0.1:${config.httpPort}`,
254
+ 'x-chat-hub-local-action': 'local-session',
255
+ },
256
+ signal: AbortSignal.timeout(5000),
257
+ });
258
+ const cookie = response.headers
259
+ .getSetCookie()
260
+ .find((entry) => entry.startsWith('chat_hub_session='));
261
+ if (!response.ok || !cookie) throw new Error('session_unavailable');
262
+ pairing = undefined;
263
+ res.setHeader(
264
+ 'set-cookie',
265
+ phoneSecure
266
+ ? `${cookie}; Secure`
267
+ : cookie.replace(/^chat_hub_session=/, 'chat_hub_http_session='),
268
+ );
269
+ return json(res, 200, { ok: true, data: { authenticated: true } });
270
+ } catch {
271
+ if (pairing) pairing.pending = false;
272
+ return failure(res, 503, 'session_unavailable', '本机会话暂不可用,请在电脑检查服务后重试');
273
+ }
274
+ }
275
+ proxy(req, res, config.frontendPort, phoneSecure, !phoneSecure);
276
+ };
277
+ const listen = async (server, port, host) => {
278
+ server.requestTimeout = 15000;
279
+ server.headersTimeout = 10000;
280
+ server.maxConnections = 32;
281
+ server.on('clientError', (_error, socket) => socket.destroy());
282
+ servers.push(server);
283
+ await new Promise((resolve, reject) => {
284
+ server.once('error', reject);
285
+ server.listen(port, host, resolve);
286
+ });
287
+ };
288
+ const wrap = (handler) => (req, res) =>
289
+ Promise.resolve(handler(req, res)).catch(() => {
290
+ if (!res.headersSent) failure(res, 500, 'gateway_error', '入口暂不可用');
291
+ else res.destroy();
292
+ });
293
+ try {
294
+ await listen(
295
+ http.createServer(wrap(config.entryMode === 'public' ? publicHandler : localHandler)),
296
+ config.httpPort,
297
+ '127.0.0.1',
298
+ );
299
+ if (config.lanIp && !phoneSecure) {
300
+ await listen(http.createServer(wrap(phoneHandler)), config.httpPort, config.lanIp);
301
+ } else if (config.lanIp) {
302
+ const certificate = readFileSync(config.caCert);
303
+ await listen(
304
+ http.createServer((req, res) => {
305
+ if (
306
+ !lanAllowed(req, config.httpPort) ||
307
+ req.url !== '/certificate' ||
308
+ req.method !== 'GET'
309
+ )
310
+ return failure(res, 403, 'https_required', '请使用 HTTPS 手机入口');
311
+ res.writeHead(200, {
312
+ ...secureHeaders,
313
+ 'content-type': 'application/x-x509-ca-cert',
314
+ 'content-disposition': 'attachment; filename="chat-hub-ca.crt"',
315
+ });
316
+ res.end(certificate);
317
+ }),
318
+ config.httpPort,
319
+ config.lanIp,
320
+ );
321
+ await listen(
322
+ https.createServer(
323
+ {
324
+ key: readFileSync(config.serverKey),
325
+ cert: readFileSync(config.serverCert),
326
+ minVersion: 'TLSv1.2',
327
+ },
328
+ wrap(phoneHandler),
329
+ ),
330
+ config.httpsPort,
331
+ config.lanIp,
332
+ );
333
+ }
334
+ } catch (error) {
335
+ await Promise.all(
336
+ servers
337
+ .filter((server) => server.listening)
338
+ .map(
339
+ (server) =>
340
+ new Promise((resolve) => {
341
+ server.closeAllConnections();
342
+ server.close(resolve);
343
+ }),
344
+ ),
345
+ );
346
+ throw error;
347
+ }
348
+ return {
349
+ close: () =>
350
+ Promise.all(
351
+ servers.map(
352
+ (server) =>
353
+ new Promise((resolve) => {
354
+ server.closeAllConnections();
355
+ server.close(resolve);
356
+ }),
357
+ ),
358
+ ),
359
+ };
360
+ }