dsh-mobile-plugin 0.5.23 → 0.5.26
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 +6 -6
- package/knowledge/apps/alipay.md +30 -0
- package/knowledge/apps/android-settings.md +30 -0
- package/knowledge/apps/bilibili.md +30 -0
- package/knowledge/apps/boss-zhipin.md +32 -0
- package/knowledge/apps/douyin.md +32 -0
- package/knowledge/apps/taobao.md +33 -0
- package/knowledge/apps/wechat.md +33 -0
- package/knowledge/apps/weibo.md +31 -0
- package/knowledge/apps/xiaohongshu.md +29 -0
- package/knowledge/apps/zhihu.md +30 -0
- package/knowledge/sites/bilibili.com.md +27 -0
- package/knowledge/sites/github.com.md +27 -0
- package/knowledge/sites/x.com.md +28 -0
- package/knowledge/sites/xiaohongshu.com.md +27 -0
- package/knowledge/sites/youtube.com.md +28 -0
- package/knowledge/sites/zhihu.com.md +27 -0
- package/lib/canvas-app.html +73 -72
- package/lib/client.js +18 -19
- package/lib/impl.js +11 -2
- package/lib/mobile/cli/acp.js +3 -2
- package/lib/mobile/cli/mobile.js +4 -28
- package/lib/mobile/pairing.js +44 -4
- package/lib/mobile/phone/confirm.js +189 -0
- package/lib/mobile/phone/index.js +21 -1
- package/lib/mobile/phone/irreversible.js +146 -0
- package/lib/mobile/phone/pacing.js +173 -0
- package/lib/mobile/phone/route-hint.js +164 -0
- package/lib/mobile/phone/routes-memory.js +133 -0
- package/lib/mobile/phone/routes.js +84 -0
- package/lib/mobile/phone/service.js +163 -13
- package/lib/mobile/phone/tools.js +1190 -62
- package/lib/mobile/phone-knowledge.js +301 -0
- package/lib/mobile/promo-receiver.cjs +77 -0
- package/lib/mobile/schedule.js +64 -7
- package/lib/mobile/sessions.js +40 -5
- package/lib/mobile/sync-inbox-store.js +181 -0
- package/lib/mobile/sync.js +124 -0
- package/lib/mobile/transfer-tickets.js +107 -0
- package/lib/mobile/wechat.js +26 -1
- package/lib/p2p/rendezvous.cjs +106 -0
- package/lib/p2p/session.cjs +455 -0
- package/lib/p2p/wiring.cjs +93 -0
- package/lib/panel/index.html +15 -14
- package/lib/relay/connection-check.js +55 -0
- package/lib/relay/status.js +4 -2
- package/lib/routes/control-plane.js +28 -13
- package/lib/routes/mobile-api.js +55 -1
- package/lib/shared/e2ee-request.js +27 -7
- package/lib/tailcat/host-process.cjs +37 -0
- package/lib/tailcat/session.cjs +42 -5
- package/lib/tailcat/wiring.cjs +25 -4
- package/lib/update-install.cjs +4 -38
- package/package.json +4 -2
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 同步收件箱:按 `instanceId` 存密文记录的磁盘小仓库(`$DSH_HOME/storages/mobile-plugin/sync-inbox.json`)。
|
|
5
|
+
*
|
|
6
|
+
* 每台电脑给「共享同步组」当一次中转点:别的手机把一条条**已用 SGK 加密**的记录写进来,本机
|
|
7
|
+
* 只存不读、看不懂内容。记录的形状是 `{ cid, blob, createdAt }`:`cid` 是内容标识(去重键),
|
|
8
|
+
* `blob` 是 base64 的密文,`createdAt` 由本仓库在写入时打上。
|
|
9
|
+
*
|
|
10
|
+
* 三条限制决定了它是一个「短时邮箱」而不是数据库:
|
|
11
|
+
* · **TTL 24 小时**:过期的记录在读写时顺手清掉。同步只在手机回前台时跑一次,一天足够;
|
|
12
|
+
* 再久的记录没有意义,留着只会让文件慢慢变大。
|
|
13
|
+
* · **单条 blob 上限 8KB**:记录只是「设备清单的一项 / 一条删除指令」,本该很小;超过这个量
|
|
14
|
+
* 说明写错了东西,整条拒收。
|
|
15
|
+
* · **单次最多 16 条**:一次同步一批设备,超过就该分批。
|
|
16
|
+
*
|
|
17
|
+
* 落盘口径照 `mobile-stats.json` 那一套:整份读、整份原子写(临时文件 + rename)。**读坏文件
|
|
18
|
+
* 时 write 绝不覆盖**——那会把盘上还没发出去的记录静默清空;读失败只有 `read()` 退化成空表。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
const { dshHome } = require('../shared/paths');
|
|
25
|
+
|
|
26
|
+
/** 文件格式版本:将来改结构时据此决定丢弃还是迁移。 */
|
|
27
|
+
const VERSION = 1;
|
|
28
|
+
|
|
29
|
+
/** 记录存活上限:24 小时。 */
|
|
30
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** 单条 blob(base64 密文串)的字节上限。 */
|
|
33
|
+
const MAX_BLOB_BYTES = 8 * 1024;
|
|
34
|
+
|
|
35
|
+
/** 单次写入最多接收的记录条数。 */
|
|
36
|
+
const MAX_RECORDS_PER_WRITE = 16;
|
|
37
|
+
|
|
38
|
+
/** cid 的长度上限:它只是去重键,不拼路径,但也不该是无限长的串。 */
|
|
39
|
+
const MAX_CID_LEN = 200;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 单实例最多留多少条:TTL 已经把它压到一天的量,这一道只是文件体积的兜底(超了丢最老的)。
|
|
43
|
+
* 一条 8KB 封顶时,512 条约 4MB,仍可接受。
|
|
44
|
+
*/
|
|
45
|
+
const MAX_STORED_PER_INSTANCE = 512;
|
|
46
|
+
|
|
47
|
+
/** 收件箱文件:跟其它移动端插件状态放同一个目录(`storages/mobile-plugin/`)。 */
|
|
48
|
+
function defaultFile() {
|
|
49
|
+
return path.join(dshHome(), 'storages', 'mobile-plugin', 'sync-inbox.json');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 建一个收件箱 store。默认读 `defaultFile()` 的真实时钟;测试可注入 `file` / `now` / `ttlMs`。
|
|
54
|
+
*
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {string} [options.file] 落盘文件(测试用)
|
|
57
|
+
* @param {() => number} [options.now] 当前时刻的取值器
|
|
58
|
+
* @param {number} [options.ttlMs] 记录存活时长
|
|
59
|
+
*/
|
|
60
|
+
function createSyncInboxStore(options = {}) {
|
|
61
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
62
|
+
const ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs > 0 ? options.ttlMs : TTL_MS;
|
|
63
|
+
const file = options.file || defaultFile();
|
|
64
|
+
|
|
65
|
+
/** 读盘并解析。文件不存在 → 空库;存在但读不懂 → 抛错(write 会用它,绝不能吞成空库)。 */
|
|
66
|
+
function readRaw() {
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
text = fs.readFileSync(file, 'utf8');
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error && error.code === 'ENOENT') return { version: VERSION, instances: {} };
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(text);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw Object.assign(new Error(`同步收件箱文件已损坏:${error.message}`), { code: 'sync-inbox-corrupt' });
|
|
79
|
+
}
|
|
80
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.instances || typeof parsed.instances !== 'object') {
|
|
81
|
+
throw Object.assign(new Error('同步收件箱文件形状不对'), { code: 'sync-inbox-corrupt' });
|
|
82
|
+
}
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 只留形状对、未过期的记录。 */
|
|
87
|
+
function prune(list) {
|
|
88
|
+
const cutoff = now() - ttlMs;
|
|
89
|
+
return list.filter((record) => (
|
|
90
|
+
record
|
|
91
|
+
&& typeof record === 'object'
|
|
92
|
+
&& typeof record.cid === 'string'
|
|
93
|
+
&& typeof record.blob === 'string'
|
|
94
|
+
&& Number.isFinite(record.createdAt)
|
|
95
|
+
&& record.createdAt > cutoff
|
|
96
|
+
));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 原子写:先临时文件再 rename,写一半断电也不留半份 JSON。 */
|
|
100
|
+
function save(db) {
|
|
101
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
102
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
103
|
+
fs.writeFileSync(tmp, JSON.stringify(db), { mode: 0o600 });
|
|
104
|
+
fs.renameSync(tmp, file);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 读某实例的记录:`since` 之后、未过期的,按 `createdAt` 升序。
|
|
109
|
+
* 读盘失败按空表(首页拉不到不该把同步引擎整个卡死)。
|
|
110
|
+
*
|
|
111
|
+
* @param {string} instanceId
|
|
112
|
+
* @param {number} [since] 毫秒时间戳,只取严格晚于它的记录
|
|
113
|
+
* @returns {Array<{cid: string, blob: string, createdAt: number}>}
|
|
114
|
+
*/
|
|
115
|
+
function read(instanceId, since) {
|
|
116
|
+
let db;
|
|
117
|
+
try {
|
|
118
|
+
db = readRaw();
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
const list = Array.isArray(db.instances[String(instanceId || '')]) ? db.instances[String(instanceId || '')] : [];
|
|
123
|
+
const sinceMs = Number.isFinite(since) && since > 0 ? since : 0;
|
|
124
|
+
return prune(list)
|
|
125
|
+
.filter((record) => record.createdAt > sinceMs)
|
|
126
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
127
|
+
.map((record) => ({ cid: record.cid, blob: record.blob, createdAt: record.createdAt }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 写一批记录:按 cid 去重、裁掉越界 / 过期的,返回被接收的 cid 列表。
|
|
132
|
+
*
|
|
133
|
+
* **读坏文件会抛错、不覆盖**:盘上那份可能还有别的手机没取走的东西,绝不能当成空库重写。
|
|
134
|
+
*
|
|
135
|
+
* @param {string} instanceId
|
|
136
|
+
* @param {Array<{cid: string, blob: string}>} records
|
|
137
|
+
* @returns {string[]} 接收到的 cid(重复的也算「已在库里」)
|
|
138
|
+
*/
|
|
139
|
+
function write(instanceId, records) {
|
|
140
|
+
const db = readRaw();
|
|
141
|
+
const id = String(instanceId || '');
|
|
142
|
+
const byCid = new Map(prune(Array.isArray(db.instances[id]) ? db.instances[id] : []).map((r) => [r.cid, r]));
|
|
143
|
+
const accepted = [];
|
|
144
|
+
const at = now();
|
|
145
|
+
let added = 0;
|
|
146
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
147
|
+
if (!record || typeof record !== 'object') continue;
|
|
148
|
+
const cid = typeof record.cid === 'string' ? record.cid : '';
|
|
149
|
+
const blob = typeof record.blob === 'string' ? record.blob : '';
|
|
150
|
+
if (!cid || cid.length > MAX_CID_LEN || !blob) continue;
|
|
151
|
+
if (Buffer.byteLength(blob, 'utf8') > MAX_BLOB_BYTES) continue;
|
|
152
|
+
if (byCid.has(cid)) {
|
|
153
|
+
accepted.push(cid);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (added >= MAX_RECORDS_PER_WRITE) break;
|
|
157
|
+
byCid.set(cid, { cid, blob, createdAt: at });
|
|
158
|
+
accepted.push(cid);
|
|
159
|
+
added += 1;
|
|
160
|
+
}
|
|
161
|
+
let list = [...byCid.values()].sort((a, b) => a.createdAt - b.createdAt);
|
|
162
|
+
if (list.length > MAX_STORED_PER_INSTANCE) list = list.slice(list.length - MAX_STORED_PER_INSTANCE);
|
|
163
|
+
db.version = VERSION;
|
|
164
|
+
db.instances[id] = list;
|
|
165
|
+
save(db);
|
|
166
|
+
return accepted;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { read, write };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
createSyncInboxStore,
|
|
174
|
+
defaultFile,
|
|
175
|
+
VERSION,
|
|
176
|
+
TTL_MS,
|
|
177
|
+
MAX_BLOB_BYTES,
|
|
178
|
+
MAX_RECORDS_PER_WRITE,
|
|
179
|
+
MAX_CID_LEN,
|
|
180
|
+
MAX_STORED_PER_INSTANCE,
|
|
181
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 设备批量同步的两组接口(`/api/mobile/v1/sync/*`,见 docs/device-batch-sync.md)。
|
|
5
|
+
*
|
|
6
|
+
* · `POST /sync/tickets` —— 已配对的设备替同步组里将来新增的手机申领一次性入场券,连同这台
|
|
7
|
+
* 电脑的概况(名字 / 实例号 / 中转地址 / 直连候选 …)一起回给导出方,拼进二维码。
|
|
8
|
+
* · `POST /sync/inbox` —— 别的手机把 SGK 加密的记录写进本机收件箱。
|
|
9
|
+
* · `GET /sync/inbox` —— 取 `since` 之后、未过期的记录。
|
|
10
|
+
*
|
|
11
|
+
* 宿主只存不读收件箱:它不认识 SGK、解不开 blob。真正的内容加解密、去重与「哪条该做什么」
|
|
12
|
+
* 全在手机上(见契约的「自动同步」节)。这里只做两件事:发券、存取密文。
|
|
13
|
+
*
|
|
14
|
+
* 三个 handler 都要求已配对(路由表里走 `guarded`),否则任何人都能领走配对资格、或往这台
|
|
15
|
+
* 电脑的收件箱塞东西。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
19
|
+
const { MAX_RECORDS_PER_WRITE } = require('./sync-inbox-store.js');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 构造同步那组 handler。依赖由移动端路由表装配时注入。
|
|
23
|
+
*
|
|
24
|
+
* @param {object} deps
|
|
25
|
+
* @param {object} deps.state 宿主状态(instanceId / name / relayUrl / lanEnabled …)
|
|
26
|
+
* @param {object} deps.ticketStore 一次性券表(transfer-tickets.js)
|
|
27
|
+
* @param {object} deps.inboxStore 同步收件箱 store(sync-inbox-store.js)
|
|
28
|
+
* @param {(port: number) => Array<object>} [deps.safeLanCandidates] 直连候选(与 /connection-info 同一份)
|
|
29
|
+
* @param {(port: number) => Array<object>} [deps.safeIpv6Candidates] IPv6 直连候选
|
|
30
|
+
* @param {() => number} [deps.lanPortInUse] 宿主实绑的直连端口
|
|
31
|
+
* @param {() => string} [deps.hostPlatform] 宿主系统标识
|
|
32
|
+
* @param {(message: string) => void} [deps.log]
|
|
33
|
+
*/
|
|
34
|
+
function createSyncHandlers(deps = {}) {
|
|
35
|
+
const state = deps.state || {};
|
|
36
|
+
const ticketStore = deps.ticketStore || null;
|
|
37
|
+
const inboxStore = deps.inboxStore || null;
|
|
38
|
+
const log = typeof deps.log === 'function' ? deps.log : () => {};
|
|
39
|
+
const instanceIdOf = () => String(state.instanceId || '');
|
|
40
|
+
|
|
41
|
+
/** 这台电脑给手机的概况:新手机配对前靠它知道对面是谁、该往哪拨。 */
|
|
42
|
+
function hostSummary() {
|
|
43
|
+
const port = typeof deps.lanPortInUse === 'function' ? deps.lanPortInUse() : state.lanPort;
|
|
44
|
+
// 直连候选与 /connection-info、/pair 那份同口径:关掉局域网直连时两份都为空。
|
|
45
|
+
const lanUrls = state.lanEnabled && typeof deps.safeLanCandidates === 'function'
|
|
46
|
+
? deps.safeLanCandidates(port).map((candidate) => candidate && candidate.url).filter(Boolean)
|
|
47
|
+
: [];
|
|
48
|
+
const ipv6Candidates = state.lanEnabled && typeof deps.safeIpv6Candidates === 'function'
|
|
49
|
+
? deps.safeIpv6Candidates(port).map((candidate) => candidate && candidate.url).filter(Boolean)
|
|
50
|
+
: [];
|
|
51
|
+
const hostPlatform = typeof deps.hostPlatform === 'function' ? deps.hostPlatform() : (state.hostPlatform || process.platform);
|
|
52
|
+
return {
|
|
53
|
+
name: state.name || '',
|
|
54
|
+
instanceId: instanceIdOf(),
|
|
55
|
+
// 券只能走协议 v2 的 sealed 配对,所以这里报协议 2。
|
|
56
|
+
proto: 2,
|
|
57
|
+
relayUrl: state.enabled ? (state.relayUrl || null) : null,
|
|
58
|
+
lanUrls,
|
|
59
|
+
ipv6Candidates,
|
|
60
|
+
hostPlatform,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** POST /sync/tickets:申领一次性入场券(count 默认 1、封顶 8)。 */
|
|
65
|
+
async function handleMintTickets(_ctx, req, res) {
|
|
66
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method not allowed' }); return; }
|
|
67
|
+
if (!ticketStore) { sendJson(res, 503, { ok: false, error: 'ticket store unavailable' }); return; }
|
|
68
|
+
// 空体是合法的(count 缺省 1);读不懂 / 太大也按空对象、让 count 走默认,不因多要一个字段就失败。
|
|
69
|
+
const body = (await readJsonBody(req, 16 * 1024)) || {};
|
|
70
|
+
const tickets = ticketStore.mint(instanceIdOf(), body && body.count, {
|
|
71
|
+
ttlMs: body && body.ttlMs,
|
|
72
|
+
reusable: body && body.reusable,
|
|
73
|
+
});
|
|
74
|
+
sendJson(res, 200, { ok: true, host: hostSummary(), tickets });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** POST /sync/inbox:写入一批密文记录,回被接收的 cid 列表。 */
|
|
78
|
+
async function handleInboxWrite(_ctx, req, res) {
|
|
79
|
+
if (!inboxStore) { sendJson(res, 503, { ok: false, error: 'inbox store unavailable' }); return; }
|
|
80
|
+
const body = await readJsonBody(req);
|
|
81
|
+
if (!body || !Array.isArray(body.records)) {
|
|
82
|
+
sendJson(res, 400, { ok: false, error: 'records 必须是数组' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (body.records.length > MAX_RECORDS_PER_WRITE) {
|
|
86
|
+
sendJson(res, 400, { ok: false, error: `单次最多写 ${MAX_RECORDS_PER_WRITE} 条记录` });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let accepted;
|
|
90
|
+
try {
|
|
91
|
+
accepted = inboxStore.write(instanceIdOf(), body.records);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// 读坏盘时 store 会抛错(绝不覆盖已有记录);这里是那条错误落到请求上的地方。
|
|
94
|
+
log('sync inbox write failed:', error && error.message);
|
|
95
|
+
sendJson(res, 500, { ok: false, error: 'internal error' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
sendJson(res, 200, { ok: true, accepted });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** GET /sync/inbox?since=<ms>:取 `since` 之后、未过期的记录。 */
|
|
102
|
+
async function handleInboxRead(_ctx, req, res) {
|
|
103
|
+
if (!inboxStore) { sendJson(res, 503, { ok: false, error: 'inbox store unavailable' }); return; }
|
|
104
|
+
let since = 0;
|
|
105
|
+
try {
|
|
106
|
+
const query = new URL(req.url || '/', 'http://x').searchParams.get('since');
|
|
107
|
+
if (query !== null) since = Number(query);
|
|
108
|
+
} catch { /* 畸形 URL 当 since=0,返回全部未过期的 */ }
|
|
109
|
+
if (!Number.isFinite(since) || since < 0) since = 0;
|
|
110
|
+
const records = inboxStore.read(instanceIdOf(), since);
|
|
111
|
+
sendJson(res, 200, { ok: true, records });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 同一条路径上按 method 分派写 / 读(宿主 webServer 一个路径只能挂一个 handler)。 */
|
|
115
|
+
async function handleInbox(ctx, req, res) {
|
|
116
|
+
if (req.method === 'POST') return handleInboxWrite(ctx, req, res);
|
|
117
|
+
if (req.method === 'GET') return handleInboxRead(ctx, req, res);
|
|
118
|
+
sendJson(res, 405, { ok: false, error: 'method not allowed' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { handleMintTickets, handleInbox, handleInboxWrite, handleInboxRead };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { createSyncHandlers };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 一次性入场券(transfer ticket)的进程内 TTL 表。
|
|
5
|
+
*
|
|
6
|
+
* 设备批量导入/导出时,手机 A 向某台电脑申领一张入场券,塞进二维码交给手机 B;B 拿这张券
|
|
7
|
+
* 走一次正常配对(`/pair` 带 `ticketId`),配对成功券即作废。券的内容与配对票据同一种原语
|
|
8
|
+
* (32 字节 base64url,用来派生 pairKey),但**不进 state、不落盘**——它只活在这台电脑的内存
|
|
9
|
+
* 里、只活很短一段时间(默认 10 分钟)。
|
|
10
|
+
*
|
|
11
|
+
* 为什么一次性 + 短 TTL:券等价于「可以跟这台电脑配一次对」的资格。二维码要在屏幕上长显,
|
|
12
|
+
* 截图 / 偷看都可能发生;配对成功立刻 `consume`,同一张券就配不了第二台手机;短 TTL 让一张
|
|
13
|
+
* 没被用掉的券自己过期。这是拿「可用窗口」换「被截屏后的暴露面」的取舍:窗口之外,即使券
|
|
14
|
+
* 泄露也换不到任何东西。
|
|
15
|
+
*
|
|
16
|
+
* 进程内表:插件重载即清空——券本来就是为了下一次配对临时生成的,没配上的重扫一张新的即可。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const crypto = require('node:crypto');
|
|
20
|
+
|
|
21
|
+
/** 默认有效期:10 分钟。够用户扫完码、走完配对,又不至于让一张漏出去的券长期可用。 */
|
|
22
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** 单次最多发几张:一张码换一台新手机,一次导出最多扩 8 台,再多该分批或先看是不是刷券。 */
|
|
25
|
+
const MAX_COUNT = 8;
|
|
26
|
+
|
|
27
|
+
/** 单张券有效期上限(24 小时):自动同步收件箱里的券要活到后开 App 的手机来取,封顶一天。 */
|
|
28
|
+
const MAX_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/** 键的分隔符:instanceId 与 ticketId 都来自内部生成,用不可见字符只为拼出唯一键。 */
|
|
31
|
+
const SEP = '\u0000';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 建一张券表。默认用真实时钟与 10 分钟 TTL;测试可注入 `now` / `ttlMs` 观察过期。
|
|
35
|
+
*
|
|
36
|
+
* @param {object} [options]
|
|
37
|
+
* @param {number} [options.ttlMs] 券的有效期(毫秒)
|
|
38
|
+
* @param {() => number} [options.now] 当前时刻的取值器(测试用)
|
|
39
|
+
*/
|
|
40
|
+
function createTransferTickets(options = {}) {
|
|
41
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
42
|
+
const ttlMs = Number.isFinite(options.ttlMs) && options.ttlMs > 0 ? options.ttlMs : DEFAULT_TTL_MS;
|
|
43
|
+
|
|
44
|
+
/** 键 → { ticket, expiresAt }。键把实例号也编进去:两张不同宿主的券不会互相顶掉。 */
|
|
45
|
+
const table = new Map();
|
|
46
|
+
const keyOf = (instanceId, ticketId) => `${String(instanceId || '')}${SEP}${ticketId}`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 发券:返回 [{ ticket, ticketId }],`count` 默认 1、封顶 `MAX_COUNT`。
|
|
50
|
+
*
|
|
51
|
+
* `opts.ttlMs` 覆盖单张券的有效期(封顶 `MAX_TTL_MS`);`opts.reusable` 为真时这张券
|
|
52
|
+
* **可被多台手机各用一次、配对成功后不作废**(到期才失效)。两种券用途不同:
|
|
53
|
+
* · 导出二维码里的是**一次性短券**(默认),防截图;
|
|
54
|
+
* · 自动同步收件箱里的是**可复用长券**(24h 内多台组内手机各领一次),因为它只经 SGK
|
|
55
|
+
* 密文投递、不暴露给外人,短 TTL 反而会让后开 App 的手机永远配不上、多台手机只有
|
|
56
|
+
* 第一台抢得到。
|
|
57
|
+
*/
|
|
58
|
+
function mint(instanceId, count, opts = {}) {
|
|
59
|
+
prune();
|
|
60
|
+
const n = Number.isInteger(count) && count > 0 ? Math.min(count, MAX_COUNT) : 1;
|
|
61
|
+
const wantTtl = Number.isFinite(opts.ttlMs) && opts.ttlMs > 0 ? opts.ttlMs : ttlMs;
|
|
62
|
+
const life = Math.min(wantTtl, MAX_TTL_MS);
|
|
63
|
+
const reusable = opts.reusable === true;
|
|
64
|
+
const issued = [];
|
|
65
|
+
const expiresAt = now() + life;
|
|
66
|
+
for (let i = 0; i < n; i += 1) {
|
|
67
|
+
const ticket = crypto.randomBytes(32).toString('base64url');
|
|
68
|
+
const ticketId = crypto.randomBytes(16).toString('base64url');
|
|
69
|
+
table.set(keyOf(instanceId, ticketId), { ticket, expiresAt, reusable });
|
|
70
|
+
issued.push({ ticket, ticketId });
|
|
71
|
+
}
|
|
72
|
+
return issued;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 取券:不存在或已过期都返回 null(过期的顺手清掉)。 */
|
|
76
|
+
function resolve(instanceId, ticketId) {
|
|
77
|
+
if (typeof ticketId !== 'string' || !ticketId) return null;
|
|
78
|
+
const key = keyOf(instanceId, ticketId);
|
|
79
|
+
const entry = table.get(key);
|
|
80
|
+
if (!entry) return null;
|
|
81
|
+
if (entry.expiresAt <= now()) {
|
|
82
|
+
table.delete(key);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return entry.ticket;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 用掉一张券:一次性券直接删除;可复用券不动它(到期由 prune 清)。 */
|
|
89
|
+
function consume(instanceId, ticketId) {
|
|
90
|
+
if (typeof ticketId !== 'string' || !ticketId) return;
|
|
91
|
+
const key = keyOf(instanceId, ticketId);
|
|
92
|
+
const entry = table.get(key);
|
|
93
|
+
if (entry && entry.reusable) return;
|
|
94
|
+
table.delete(key);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 清掉所有过期券;每次 mint / 可在别处定期调用。 */
|
|
98
|
+
function prune(at = now()) {
|
|
99
|
+
for (const [key, entry] of table) {
|
|
100
|
+
if (entry.expiresAt <= at) table.delete(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { mint, resolve, consume, prune, size: () => table.size };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { createTransferTickets, DEFAULT_TTL_MS, MAX_COUNT };
|
package/lib/mobile/wechat.js
CHANGED
|
@@ -426,6 +426,27 @@ function mediaTypeOf(name, bytes) {
|
|
|
426
426
|
return 'image/png';
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
+
/**
|
|
430
|
+
* HTTP 那条路上,图片只留 `{name, base64}` 两个字段 —— `path`(以及任何路径形式的字段)一律剥掉。
|
|
431
|
+
*
|
|
432
|
+
* 为什么 HTTP 这条路不收 path:这个请求来自手机,手机口中的「本机路径」指的是手机上的文件;
|
|
433
|
+
* 宿主却跑在另一台电脑上,照着这个名字去读,读到的是电脑上同名的任意文件 —— 一次保存就顺走
|
|
434
|
+
* 了宿主机上任何一张图。工具那一侧不经过这里:模型自己有读盘权,给的路径本来就该能读,所以
|
|
435
|
+
* `writeArticle` / `mediaBytesOf` 的 path 能力原样保留。
|
|
436
|
+
*
|
|
437
|
+
* 只认 name 与 base64,别的字段(path、filePath、localPath、src……)自然全丢;`images` 不是
|
|
438
|
+
* 数组、项不是对象都按「没给」处理,不抛。name 合不合规矩交给 `writeArticle` 现有的拒绝逻辑。
|
|
439
|
+
*/
|
|
440
|
+
function httpImagesOnly(images) {
|
|
441
|
+
if (!Array.isArray(images)) return [];
|
|
442
|
+
const out = [];
|
|
443
|
+
for (const item of images) {
|
|
444
|
+
if (!isRecord(item)) continue;
|
|
445
|
+
out.push({ name: item.name, base64: item.base64 });
|
|
446
|
+
}
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
|
|
429
450
|
/**
|
|
430
451
|
* `POST /wechat/article` —— 写一篇(App 侧目前不用,留给「在手机上删 / 改名」这类动作;
|
|
431
452
|
* AI 那条路走的是工具,见 `tools.js`)。
|
|
@@ -433,8 +454,10 @@ function mediaTypeOf(name, bytes) {
|
|
|
433
454
|
async function handleArticleWrite(_ctx, req, res) {
|
|
434
455
|
const body = await readJsonBody(req, 8 * 1024 * 1024);
|
|
435
456
|
if (!isRecord(body)) return fail(res, 400, 'bad-request', '请求体不是对象。');
|
|
457
|
+
// 只让 HTTP 入口不收 path:图片只留 {name, base64}(见 httpImagesOnly)。
|
|
458
|
+
const input = { ...body, images: httpImagesOnly(body.images) };
|
|
436
459
|
try {
|
|
437
|
-
const { article } = await writeArticle(
|
|
460
|
+
const { article } = await writeArticle(input);
|
|
438
461
|
sendJson(res, 200, {
|
|
439
462
|
ok: true,
|
|
440
463
|
id: article.id,
|
|
@@ -479,6 +502,8 @@ module.exports = {
|
|
|
479
502
|
handleArticleDelete,
|
|
480
503
|
// 工具那一侧直接用这几个(不经过 HTTP)
|
|
481
504
|
writeArticle,
|
|
505
|
+
// 纯函数:HTTP 入口剥 path 用,单独导出便于直接测
|
|
506
|
+
httpImagesOnly,
|
|
482
507
|
readArticle,
|
|
483
508
|
listArticles,
|
|
484
509
|
plainTextOf,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 点对点隧道的**会合**:手机把自己的打洞候选报上来,电脑按它把自己那半边拉起来,
|
|
5
|
+
* 再把电脑自己的候选还回去。
|
|
6
|
+
*
|
|
7
|
+
* 为什么需要这一步:打洞的本质是「两端同时往对方的出口地址发包」。手机上知道自己的
|
|
8
|
+
* 出口(它自己问 STUN),电脑也知道自己的;但**彼此不知道对方的**。没有这一次交换,
|
|
9
|
+
* 两边都只能往空气里打 —— 这正是这条通道早先一次也连不上的原因。
|
|
10
|
+
*
|
|
11
|
+
* 为什么走这条路而不是另架一个会合服务:这一发就打在我们**已经有**的那条链路上(局域网 /
|
|
12
|
+
* 中转 / tailcat 隧道),只传几十个字节的地址。它不承载业务数据,也不新增任何部署物 ——
|
|
13
|
+
* 与「用户没有服务器、我们也不提供」是同一条原则。真正的数据面完全在两端之间。
|
|
14
|
+
*
|
|
15
|
+
* 端点本身是幂等的:手机每次心跳都可以带上当前候选再来一次,电脑发现「我已经按这份候选
|
|
16
|
+
* 起来过了」就直接复用,不重新打洞(重打会让已经打通的洞白丢一次)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { sendJson } = require('../http/routing.js');
|
|
20
|
+
const { readJsonBody } = require('../shared/http-util.js');
|
|
21
|
+
|
|
22
|
+
/** 候选白名单:只收 `ip:port` 形状,长度也限住 —— 对端给的任何东西都当不可信数据。 */
|
|
23
|
+
const CANDIDATE = /^[0-9a-fA-F.:[\]]{3,64}$/;
|
|
24
|
+
|
|
25
|
+
/** 手机的公钥:只认 base64(44 字符)或 hex(64 字符)两种,长度不等就不是密钥。 */
|
|
26
|
+
const PUBLIC_KEY = /^[A-Za-z0-9+/=]{43,44}$|^[0-9a-fA-F]{64}$/;
|
|
27
|
+
|
|
28
|
+
function normalizePublicKey(value) {
|
|
29
|
+
if (typeof value !== 'string') return '';
|
|
30
|
+
const text = value.trim();
|
|
31
|
+
return PUBLIC_KEY.test(text) ? text : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeCandidates(list) {
|
|
35
|
+
if (!Array.isArray(list)) return [];
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const item of list) {
|
|
38
|
+
if (typeof item !== 'string') continue;
|
|
39
|
+
const text = item.trim();
|
|
40
|
+
if (!text || text.length > 64 || !CANDIDATE.test(text)) continue;
|
|
41
|
+
if (!out.includes(text)) out.push(text);
|
|
42
|
+
// 上限 16 条:正常也就两三条,多出来的只可能是拼错的输入。
|
|
43
|
+
if (out.length >= 16) break;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} deps
|
|
50
|
+
* @param {object} deps.p2p 装配层交下来的 P2P 门面(`lib/p2p/wiring.cjs`)
|
|
51
|
+
* @param {() => number} [deps.lanPortInUse] 本机插件端口(隧道里那些连接最终转给它)
|
|
52
|
+
*/
|
|
53
|
+
function createP2PRendezvousHandler(deps) {
|
|
54
|
+
return async function handleRendezvous(_ctx, req, res) {
|
|
55
|
+
if (!deps.p2p || typeof deps.p2p.open !== 'function') {
|
|
56
|
+
// 这台电脑没装助手 / 没起这条通道:如实说,手机那边据此把这一条标成不可用,
|
|
57
|
+
// 而不是一直等一个永远不会来的回答。
|
|
58
|
+
sendJson(res, 200, { ok: true, available: false, reason: 'p2p-unavailable' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const body = await readJsonBody(req, 16 * 1024);
|
|
63
|
+
const candidates = normalizeCandidates(body && body.candidates);
|
|
64
|
+
const localCandidates = normalizeCandidates(body && body.localCandidates);
|
|
65
|
+
// 手机自己的公钥:电脑拉起隧道时要拿它对 WireGuard 握手。没有它,电脑配不出对端,
|
|
66
|
+
// 这条通道根本起不来(不是「打洞打不通」,是连设备都配不上)。
|
|
67
|
+
const phonePublicKey = normalizePublicKey(body && body.phonePublicKey);
|
|
68
|
+
// 手机候选为空也允许:电脑照样能「只听不打」地起来、把自己的候选报回去,等手机来打。
|
|
69
|
+
// 硬要它先有候选,等于把「谁先拨」的顺序焊死,第一发永远没有候选。
|
|
70
|
+
|
|
71
|
+
const opened = await deps.p2p.open({
|
|
72
|
+
// **不转 `body.peerKey`**:手机那一格装的是**我这台电脑的公钥**(它拿它确认「对面是那台
|
|
73
|
+
// 电脑」),不是它自己的。会话要的对端是手机那把,只有 `phonePublicKey` 一处。
|
|
74
|
+
// 早先把这一格直接当对端传下去,电脑于是拿自己的公钥配 WireGuard —— 洞能开、`path`
|
|
75
|
+
// 报 `direct`,但握手永远对不上,数据面一个包都过不去。
|
|
76
|
+
phonePublicKey,
|
|
77
|
+
candidates,
|
|
78
|
+
localCandidates,
|
|
79
|
+
targetPort: typeof deps.lanPortInUse === 'function' ? deps.lanPortInUse() : 0,
|
|
80
|
+
});
|
|
81
|
+
if (!opened || opened.ok === false) {
|
|
82
|
+
sendJson(res, 200, {
|
|
83
|
+
ok: true,
|
|
84
|
+
available: false,
|
|
85
|
+
reason: (opened && opened.code) || 'start-failed',
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 回我自己的候选:手机拿到这份,才知道往哪打。
|
|
91
|
+
sendJson(res, 200, {
|
|
92
|
+
ok: true,
|
|
93
|
+
available: true,
|
|
94
|
+
// 隧道里那个固定端口(手机拨它)。
|
|
95
|
+
port: deps.p2p.constants ? deps.p2p.constants.P2P_PORT : undefined,
|
|
96
|
+
// **我这一把公钥**:手机要拿它确认「对面是那台电脑」并配 WireGuard 对端。二维码里
|
|
97
|
+
// 有这一份,但已经配过对、或换过电脑的老会话也得能自己拿到 —— 否则手机只能重新扫码。
|
|
98
|
+
publicKey: typeof opened.publicKey === 'string' ? opened.publicKey : '',
|
|
99
|
+
// 电脑的出口地址(STUN 那份 + 本机私网那份)。**这一份才是打洞的起点**。
|
|
100
|
+
candidates: normalizeCandidates(opened.candidates),
|
|
101
|
+
localCandidates: [],
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { createP2PRendezvousHandler, normalizeCandidates };
|