dsh-mobile-plugin 0.5.23 → 0.5.24

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.
@@ -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 };