dsh-crossterm 0.8.1 → 0.8.3
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/CHANGELOG.md +37 -0
- package/README.md +12 -14
- package/client/client.js +5 -1
- package/lib/bus.js +1 -1
- package/lib/cloud/home.js +1 -0
- package/lib/cloud/relay.js +54 -7
- package/lib/cloud/session-backup.js +64 -9
- package/lib/index.js +20 -4
- package/lib/web.js +15 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# dsh-crossterm 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.8.3 — 2026-10-19
|
|
4
|
+
|
|
5
|
+
### 已归档会话只备份一次,卡片把备份拆成「已归档 / 活跃中」
|
|
6
|
+
|
|
7
|
+
- **归档清单**:DSH 把已归档会话记在 `<DSH_HOME>/storages/workspace.json` 的 `global.archivedSessionIds`(本机 24 个)。
|
|
8
|
+
新增 `readArchivedIds(dshHome)` 读它,读不到就当没有归档(不让备份停摆)。
|
|
9
|
+
- **归档会话备份一次就别再备**:`backupSessions()` 把会话分成「已归档 / 活跃中」两类,
|
|
10
|
+
归档会话第一次传完在 `backup-state.json` 里记 `archived: true`,之后每轮直接跳过、连大小都不比;
|
|
11
|
+
万一归档文件事后被写大(理论上不会),仍然补传一次再回到跳过。
|
|
12
|
+
- **计数进状态**:每轮写 `state.lastRun = {at, archivedTotal, activeTotal, archivedDone, activeDone}`;
|
|
13
|
+
`backupSummary()` 带出 `archived / active / archivedBackedUp / activeBackedUp`。
|
|
14
|
+
- **卡片**:「会话备份」行显示 `已归档 24/24 · 活跃中 9/9 · 43.1 MB · 上次 2 分钟前`。
|
|
15
|
+
- **修**:`loadState()` 原来会把不认识的字段丢掉,导致 `lastRun` 存不进总结;改成保留未知字段。
|
|
16
|
+
- 探针 `F:\dsh\001X\crossterm-work\probe-archived-backup.mjs` **16 PASS**(首轮分类、次轮全跳过、归档变大补一次、清单缺失回落活跃);
|
|
17
|
+
`probe-cloud-home` 42、`probe-security-080` 21、`probe-relay-082` 7 回归通过。
|
|
18
|
+
- **要不要跟着动手:不用**(纯备份策略与显示)。
|
|
19
|
+
|
|
20
|
+
## 0.8.2 — 2026-10-19
|
|
21
|
+
|
|
22
|
+
### 复测反馈:F7 只修一半、A6、F14、F16
|
|
23
|
+
|
|
24
|
+
- **F7/F8 只修一半**:跳过判断用文件名(`state.processed[name]`)、投递记录写信封 id(`state.processed[mailId]`),
|
|
25
|
+
同一个 id 换两个文件名会被投两次;归档失败时下一轮会重复投递。
|
|
26
|
+
改为 `state.seen[name] = mailId` 记录「文件名 → 信封 id」,跳过判断统一看这张表 + 已投递表;
|
|
27
|
+
发现同 id 的第二个文件时直接把它归档到 `done/`,不再投第二次。
|
|
28
|
+
- **A6(安全)DNS rebinding**:`GET /crossterm/api/token` 是先取令牌再用的引导接口,同源 GET 不带 `Origin`,
|
|
29
|
+
rebound 页面的 `Host` 是攻击者域名却能拿到令牌,令牌防线被绕过。
|
|
30
|
+
现在所有 `/crossterm/api/*`(含 token 路由)都要求 `Host` 是 `127.0.0.1` / `localhost` / `[::1]`,否则 403。
|
|
31
|
+
- **F14 收件人解析**:`to` 写设备名(`yoce-c-4fb38e`)或写错名字时,原实现一律「留件」,信在对方 inbox 里每 20 秒重试。
|
|
32
|
+
现在按 会话名/短号 → `session-<uuid>` → 最近活跃会话 依次回退,并把回退原因写进正文;
|
|
33
|
+
只有本机一个会话都没有时才留件。
|
|
34
|
+
- **F16 死信**:失败原来每轮都重下重投。现在按 20s / 1m / 5m / 15m / 1h 退避,连续 8 次移进
|
|
35
|
+
`<设备目录>/dead/` 并记 `parked`,不再无限重试;签名无效的信件直接退避 1 小时。
|
|
36
|
+
|
|
37
|
+
- 探针:`probe-relay-082.mjs` 7 项全 PASS(同 id 两文件名只投一次、重复副本归档、失败退避、退避期内不重投、8 次进 dead)。
|
|
38
|
+
- 要不要跟着动手:**要** —— 收信方升级到 0.8.2 才能吃到「设备名/写错名字」的回退与死信退避。
|
|
39
|
+
|
|
3
40
|
## 0.8.1 — 2026-10-19
|
|
4
41
|
|
|
5
42
|
### 从旧版升到 0.8.0 时,签名密钥可能一直生成不出来
|
package/README.md
CHANGED
|
@@ -28,25 +28,23 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
|
|
|
28
28
|
> `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
|
|
29
29
|
> 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
|
|
30
30
|
|
|
31
|
-
**本版更新(0.8.
|
|
31
|
+
**本版更新(0.8.3)**
|
|
32
32
|
|
|
33
|
-
-
|
|
34
|
-
|
|
33
|
+
- **已归档会话不再反复备份**:备份前先读 DSH 的归档清单(`storages/workspace.json`),
|
|
34
|
+
归档会话第一次传完就标记,之后每轮直接跳过(本机 24 个已归档、9 个活跃中)。
|
|
35
|
+
- **卡片细分**:「会话备份」行改成 `已归档 24/24 · 活跃中 9/9 · 43.1 MB · 上次 …`,
|
|
36
|
+
一眼看出哪类备份完了。
|
|
37
|
+
- **活跃会话照旧增量**:从 `_session.json` 的水位往后传,不会重传整份。
|
|
35
38
|
|
|
36
|
-
**上一版(0.8.
|
|
39
|
+
**上一版(0.8.2)**
|
|
37
40
|
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
带外站 `Origin` 的请求 403 —— 防网页 CSRF。卡片自动取令牌,不用手动配。
|
|
41
|
-
- **云端信封带 HMAC-SHA256 签名**:密钥 `relaySecret` 自动生成在 `_account.json`,收信侧验签,签名缺失或不符就留件不收。
|
|
42
|
-
- **入站限额**:信封 >512 KB 跳过、附件最多 20 个、单附件 >64 MB 拒收;信封自带的云端路径不再采信。
|
|
43
|
-
- **投递顺序修正**:按信封 id 去重、先归档 `done/` 成功再记已处理。
|
|
44
|
-
- **要跟着动手**:账号生成 `relaySecret` 后,<0.8.0 设备发的未签名信封会被拒收;请把所有设备升到 ≥0.8.0。
|
|
41
|
+
- 死信按 20s / 1m / 5m / 15m / 1h 退避,8 次后进 `dead/`;收件人写设备名会回退「最近活跃会话」;
|
|
42
|
+
同一个信封不会投两次;`/crossterm/api/*` 要求本机回环 `Host`。
|
|
45
43
|
|
|
46
|
-
**更早(0.
|
|
44
|
+
**更早(0.8.1 / 0.8.0)**
|
|
47
45
|
|
|
48
|
-
-
|
|
49
|
-
-
|
|
46
|
+
- 0.8.1:旧布局缓存缺签名密钥时会重算并补写 `relaySecret`。
|
|
47
|
+
- 0.8.0 安全加固:附件名净化 + 越界校验、写接口要 `x-crossterm-token`、信封 HMAC 签名、入站限额、按 id 去重。
|
|
50
48
|
|
|
51
49
|
## 一、安装与卸载
|
|
52
50
|
|
package/client/client.js
CHANGED
|
@@ -197,7 +197,11 @@ window.__ModuleLoader__.load({
|
|
|
197
197
|
function backupText(backup) {
|
|
198
198
|
if (!backup || !backup.files) return "还没有备份(会在开云同步后自动开始)";
|
|
199
199
|
const when = backup.lastAt ? agoText(backup.lastAt) : "未知";
|
|
200
|
-
|
|
200
|
+
const parts = [];
|
|
201
|
+
if (typeof backup.archived === "number") parts.push(`已归档 ${Number(backup.archivedBackedUp || 0)}/${backup.archived}`);
|
|
202
|
+
if (typeof backup.active === "number") parts.push(`活跃中 ${Number(backup.activeBackedUp || 0)}/${backup.active}`);
|
|
203
|
+
const head = parts.length ? parts.join(" · ") : `${backup.files} 个会话`;
|
|
204
|
+
return `${head} · ${sizeText(backup.bytes)} · 上次 ${when}`;
|
|
201
205
|
}
|
|
202
206
|
|
|
203
207
|
function Card() {
|
package/lib/bus.js
CHANGED
package/lib/cloud/home.js
CHANGED
package/lib/cloud/relay.js
CHANGED
|
@@ -13,9 +13,9 @@ const nowIso = () => new Date().toISOString()
|
|
|
13
13
|
function loadState(file) {
|
|
14
14
|
try {
|
|
15
15
|
const j = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
16
|
-
return { at: j.at || 0, processed: j.processed || {}, failed: j.failed || {}, sent: j.sent || [] }
|
|
16
|
+
return { at: j.at || 0, processed: j.processed || {}, seen: j.seen || {}, failed: j.failed || {}, sent: j.sent || [] }
|
|
17
17
|
} catch {
|
|
18
|
-
return { at: 0, processed: {}, failed: {}, sent: [] }
|
|
18
|
+
return { at: 0, processed: {}, seen: {}, failed: {}, sent: [] }
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -169,7 +169,14 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
|
|
|
169
169
|
log(stats.lastError)
|
|
170
170
|
continue
|
|
171
171
|
}
|
|
172
|
-
|
|
172
|
+
// 0.8.2 F7:跳过判断统一用「文件名 → 信封 id」表(seen),投递记录统一用信封 id。
|
|
173
|
+
if (state.seen[name] || state.processed[name]) continue
|
|
174
|
+
const prevFail = state.failed[name]
|
|
175
|
+
if (prevFail && Number(prevFail.nextAt || 0) > Date.now()) {
|
|
176
|
+
// 0.8.2 F16:退避期内不再下载/重投
|
|
177
|
+
stats.pending += 1
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
173
180
|
const localJson = path.join(incomingDir, name)
|
|
174
181
|
let envelope
|
|
175
182
|
try {
|
|
@@ -182,6 +189,21 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
|
|
|
182
189
|
continue
|
|
183
190
|
}
|
|
184
191
|
const mailId = envelope.id || path.basename(name, '.json')
|
|
192
|
+
if (state.processed[mailId]) {
|
|
193
|
+
// 0.8.2 F7:同一个信封换了文件名又出现 —— 已经投过,把这个副本归档掉,不再投第二次
|
|
194
|
+
try {
|
|
195
|
+
await pan.mkdir(layout.paths.doneDir)
|
|
196
|
+
await pan.put(localJson, `${layout.paths.doneDir}/${name}`)
|
|
197
|
+
await pan.rm(`${layout.paths.inboxDir}/${name}`)
|
|
198
|
+
state.seen[name] = mailId
|
|
199
|
+
delete state.failed[name]
|
|
200
|
+
saveState(stateFile, { ...state, at: Date.now() })
|
|
201
|
+
log(`中转站:${name} 与已投递的 ${mailId} 同一个信封,已归档不重投`)
|
|
202
|
+
} catch (e) {
|
|
203
|
+
log(`中转站:${name} 重复信封归档失败:${(e && e.message) || String(e)}`)
|
|
204
|
+
}
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
185
207
|
// 0.8.0 F3:有账号密钥就要求签名对得上,否则拒收(防伪造发件人 / 提示注入)
|
|
186
208
|
const secret = await secretNow()
|
|
187
209
|
if (secret) {
|
|
@@ -189,7 +211,9 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
|
|
|
189
211
|
stats.failed += 1
|
|
190
212
|
stats.lastError = `信封签名无效,已拒收:${name}`
|
|
191
213
|
log(stats.lastError)
|
|
192
|
-
|
|
214
|
+
const prevSig = state.failed[name] || {}
|
|
215
|
+
const sigTries = Number(prevSig.tries || 0) + 1
|
|
216
|
+
state.failed[name] = { at: nowIso(), mailId, reason: '签名无效', tries: sigTries, nextAt: Date.now() + 3600000 }
|
|
193
217
|
saveState(stateFile, { ...state, at: Date.now() })
|
|
194
218
|
continue
|
|
195
219
|
}
|
|
@@ -223,12 +247,35 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
|
|
|
223
247
|
log(`归档 ${name} 失败:${(e && e.message) || String(e)}`)
|
|
224
248
|
}
|
|
225
249
|
state.processed[mailId] = { at: nowIso(), name, mailId, archived, sessionId: result.sessionId || '', to: String(envelope.to) }
|
|
250
|
+
state.seen[name] = mailId
|
|
251
|
+
delete state.failed[name]
|
|
226
252
|
log(`中转站投递 ${mailId} → ${result.sessionId || String(envelope.to)}`)
|
|
227
253
|
} else {
|
|
228
254
|
stats.pending += 1
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
255
|
+
// 0.8.2 F16:失败按 20s/1m/5m/15m/1h 退避,连续 8 次移进 dead/,不再每 20 秒重下重投
|
|
256
|
+
const prev = state.failed[name] || {}
|
|
257
|
+
const tries = Number(prev.tries || 0) + 1
|
|
258
|
+
const delays = [20000, 60000, 300000, 900000, 3600000]
|
|
259
|
+
state.failed[name] = {
|
|
260
|
+
at: nowIso(),
|
|
261
|
+
mailId,
|
|
262
|
+
reason: (result && result.reason) || '投递失败',
|
|
263
|
+
tries,
|
|
264
|
+
nextAt: Date.now() + delays[Math.min(tries - 1, delays.length - 1)],
|
|
265
|
+
}
|
|
266
|
+
log(`中转站留件 ${name}(第 ${tries} 次):${(result && result.reason) || '投递失败'}`)
|
|
267
|
+
if (tries >= 8) {
|
|
268
|
+
try {
|
|
269
|
+
const deadDir = `${layout.paths.deviceRoot}/dead`
|
|
270
|
+
await pan.mkdir(deadDir)
|
|
271
|
+
await pan.put(localJson, `${deadDir}/${name}`)
|
|
272
|
+
await pan.rm(`${layout.paths.inboxDir}/${name}`)
|
|
273
|
+
state.failed[name].parked = true
|
|
274
|
+
log(`中转站:${name} 连续失败 ${tries} 次,已移入 dead/`)
|
|
275
|
+
} catch (e) {
|
|
276
|
+
log(`中转站:${name} 移入 dead 失败:${(e && e.message) || String(e)}`)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
232
279
|
}
|
|
233
280
|
saveState(stateFile, { ...state, at: Date.now() })
|
|
234
281
|
}
|
|
@@ -12,7 +12,8 @@ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
|
|
|
12
12
|
function loadState(file) {
|
|
13
13
|
try {
|
|
14
14
|
const j = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
15
|
-
|
|
15
|
+
// 保留未知字段(lastRun 等),否则总结里的归档/活跃计数会被丢掉
|
|
16
|
+
return { ...j, at: j.at || 0, sessions: j.sessions || {}, attachments: j.attachments || {} }
|
|
16
17
|
} catch {
|
|
17
18
|
return { at: 0, sessions: {}, attachments: {} }
|
|
18
19
|
}
|
|
@@ -25,8 +26,22 @@ function saveState(file, state) {
|
|
|
25
26
|
} catch { /* ignore */ }
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 已归档会话 id:DSH 存在 <DSH_HOME>/storages/workspace.json 的 global.archivedSessionIds。
|
|
31
|
+
* 读不到就当没有归档(不让备份因为读不到清单而停摆)。
|
|
32
|
+
*/
|
|
33
|
+
export function readArchivedIds(dshHome) {
|
|
34
|
+
try {
|
|
35
|
+
const j = JSON.parse(fs.readFileSync(path.join(dshHome, 'storages', 'workspace.json'), 'utf8'))
|
|
36
|
+
const ids = (j && j.global && j.global.archivedSessionIds) || []
|
|
37
|
+
return new Set(ids.filter((x) => typeof x === 'string' && x))
|
|
38
|
+
} catch {
|
|
39
|
+
return new Set()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/** 列出 <sessionsRoot>/<workspaceKey>/<sessionId>/session.v4.jsonl.zstd */
|
|
29
|
-
export function listTranscripts(sessionsRoot) {
|
|
44
|
+
export function listTranscripts(sessionsRoot, archivedSet) {
|
|
30
45
|
const out = []
|
|
31
46
|
let wsKeys = []
|
|
32
47
|
try { wsKeys = fs.readdirSync(sessionsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name) } catch { return out }
|
|
@@ -43,7 +58,7 @@ export function listTranscripts(sessionsRoot) {
|
|
|
43
58
|
const full = path.join(dir, file)
|
|
44
59
|
let st = null
|
|
45
60
|
try { st = fs.statSync(full) } catch { continue }
|
|
46
|
-
out.push({ sessionId: id, workspaceKey: ws, dir, file: full, size: st.size, mtimeMs: st.mtimeMs })
|
|
61
|
+
out.push({ sessionId: id, workspaceKey: ws, dir, file: full, size: st.size, mtimeMs: st.mtimeMs, archived: Boolean(archivedSet && archivedSet.has(id)) })
|
|
47
62
|
}
|
|
48
63
|
}
|
|
49
64
|
return out
|
|
@@ -76,19 +91,38 @@ function pickName(names, sessionId) {
|
|
|
76
91
|
* @param {Function} [opts.log]
|
|
77
92
|
* @param {number} [opts.maxSessions] 单轮最多处理多少个会话
|
|
78
93
|
* @param {object} [opts.names] sessionId → { name, cwd }
|
|
94
|
+
* @param {string} [opts.dshHome] 用来读 storages/workspace.json 里的已归档会话清单
|
|
79
95
|
*/
|
|
80
|
-
export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateFile, log = () => {}, maxSessions = 40, names = {} }) {
|
|
96
|
+
export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateFile, log = () => {}, maxSessions = 40, names = {}, dshHome = '' }) {
|
|
81
97
|
const state = loadState(stateFile)
|
|
82
98
|
const remoteRoot = layout.paths.sessionDir
|
|
83
|
-
const
|
|
84
|
-
const
|
|
99
|
+
const archivedSet = dshHome ? readArchivedIds(dshHome) : new Set()
|
|
100
|
+
const all = listTranscripts(sessionsRoot, archivedSet)
|
|
101
|
+
const archivedAll = all.filter((x) => x.archived)
|
|
102
|
+
const activeAll = all.filter((x) => !x.archived)
|
|
103
|
+
const result = {
|
|
104
|
+
ok: true, total: all.length, uploaded: 0, skipped: 0, bytes: 0, sessions: [], error: '',
|
|
105
|
+
archived: { total: archivedAll.length, uploaded: 0, skipped: 0, done: archivedAll.filter((x) => state.sessions[x.sessionId] && state.sessions[x.sessionId].archived).length },
|
|
106
|
+
active: { total: activeAll.length, uploaded: 0, skipped: 0 },
|
|
107
|
+
}
|
|
85
108
|
fs.mkdirSync(tmpDir, { recursive: true })
|
|
86
109
|
let handled = 0
|
|
87
110
|
|
|
88
111
|
for (const item of all) {
|
|
89
112
|
if (handled >= maxSessions) break
|
|
90
113
|
const prev = state.sessions[item.sessionId] || { bytes: 0, parts: 0 }
|
|
91
|
-
|
|
114
|
+
// 已归档:备份过就不再备份(归档会话不会再写,重复上传纯属浪费云盘空间)
|
|
115
|
+
if (item.archived && prev.archived && prev.bytes === item.size) {
|
|
116
|
+
result.skipped += 1
|
|
117
|
+
result.archived.skipped += 1
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (prev.bytes === item.size) {
|
|
121
|
+
result.skipped += 1
|
|
122
|
+
if (item.archived) result.archived.skipped += 1
|
|
123
|
+
else result.active.skipped += 1
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
92
126
|
handled += 1
|
|
93
127
|
try {
|
|
94
128
|
const remoteDir = `${remoteRoot}/${item.sessionId}`
|
|
@@ -121,10 +155,15 @@ export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateF
|
|
|
121
155
|
note: 'bytes = 已备份到的字节位置;下次只传 bytes 之后的新内容',
|
|
122
156
|
}
|
|
123
157
|
await pan.putBuffer(Buffer.from(JSON.stringify(marker, null, 2), 'utf8'), `${remoteDir}/_session.json`)
|
|
124
|
-
state.sessions[item.sessionId] = {
|
|
158
|
+
state.sessions[item.sessionId] = {
|
|
159
|
+
bytes: item.size, parts: chunks.length, chunks, name: marker.name, cwd: marker.cwd,
|
|
160
|
+
updatedAt: marker.updatedAt, fs_id: up.fs_id, archived: Boolean(item.archived),
|
|
161
|
+
}
|
|
125
162
|
result.uploaded += 1
|
|
126
163
|
result.bytes += buf.length
|
|
127
|
-
|
|
164
|
+
if (item.archived) result.archived.uploaded += 1
|
|
165
|
+
else result.active.uploaded += 1
|
|
166
|
+
result.sessions.push({ sessionId: item.sessionId, name: marker.name, bytes: buf.length, from, part: name, archived: Boolean(item.archived) })
|
|
128
167
|
saveState(stateFile, { ...state, at: Date.now() })
|
|
129
168
|
} catch (e) {
|
|
130
169
|
result.ok = false
|
|
@@ -133,6 +172,14 @@ export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateF
|
|
|
133
172
|
break
|
|
134
173
|
}
|
|
135
174
|
}
|
|
175
|
+
const archivedDone = archivedAll.filter((x) => state.sessions[x.sessionId] && state.sessions[x.sessionId].archived).length
|
|
176
|
+
state.lastRun = {
|
|
177
|
+
at: Date.now(),
|
|
178
|
+
archivedTotal: archivedAll.length,
|
|
179
|
+
activeTotal: activeAll.length,
|
|
180
|
+
archivedDone,
|
|
181
|
+
activeDone: Math.max(0, Object.keys(state.sessions).length - archivedDone),
|
|
182
|
+
}
|
|
136
183
|
saveState(stateFile, { ...state, at: Date.now() })
|
|
137
184
|
return result
|
|
138
185
|
}
|
|
@@ -186,12 +233,20 @@ export async function backupAttachments({ pan, layout, objectsRoot, stateFile, l
|
|
|
186
233
|
export function backupSummary(stateFile) {
|
|
187
234
|
const state = loadState(stateFile)
|
|
188
235
|
const sessions = Object.values(state.sessions)
|
|
236
|
+
const run = state.lastRun || {}
|
|
237
|
+
const archivedBackedUp = Number(run.archivedDone || 0)
|
|
238
|
+
const activeBackedUp = Number(run.activeDone || 0)
|
|
189
239
|
return {
|
|
190
240
|
at: state.at || 0,
|
|
191
241
|
lastAt: state.at || 0,
|
|
192
242
|
sessions: sessions.length,
|
|
193
243
|
files: sessions.length,
|
|
194
244
|
lastCount: sessions.length,
|
|
245
|
+
archived: Number(run.archivedTotal || 0),
|
|
246
|
+
active: Number(run.activeTotal || 0),
|
|
247
|
+
archivedBackedUp,
|
|
248
|
+
activeBackedUp,
|
|
249
|
+
archivedUntouched: sessions.filter((s) => s.archived).length,
|
|
195
250
|
bytes: sessions.reduce((a, s) => a + Number(s.bytes || 0), 0),
|
|
196
251
|
attachments: Object.keys(state.attachments).length,
|
|
197
252
|
attachmentBytes: Object.values(state.attachments).reduce((a, s) => a + Number(s.size || 0), 0),
|
package/lib/index.js
CHANGED
|
@@ -830,17 +830,33 @@ class CrossTerm {
|
|
|
830
830
|
|
|
831
831
|
/** 中转站投递:把云端收件箱里的一封信投进本机对应会话(按收件人会话名) */
|
|
832
832
|
async relayDeliver({ to, envelope = {}, files = [] }) {
|
|
833
|
-
|
|
833
|
+
const rawTargets = (Array.isArray(to) ? to : String(to || '').split(/[,,;;\s]+/))
|
|
834
834
|
.map((s) => String(s || '').trim())
|
|
835
835
|
.filter(Boolean)
|
|
836
|
+
// 0.8.2 F14:收件人可能是会话名、短号、session-<uuid>,也可能是「设备名」(别的设备只写设备不写会话)。
|
|
837
|
+
// 能解析的照常投;一个都解析不出来就退回「最近活跃会话」,别让信在 inbox 里每 20 秒重试到天荒地老。
|
|
838
|
+
const resolvable = (raw) => {
|
|
839
|
+
// 必须真的在本机会话表里 —— resolveSessionId 找不到时会「原样返回」,只看返回值会把设备名当会话
|
|
840
|
+
const list = this.sessions()
|
|
841
|
+
const hit = list.find((s) => s.id === raw)
|
|
842
|
+
if (hit) return true
|
|
843
|
+
const byName = this.resolveByName(raw)
|
|
844
|
+
return Boolean(byName && list.some((s) => s.id === byName))
|
|
845
|
+
}
|
|
846
|
+
const unknown = rawTargets.filter((t) => !resolvable(t))
|
|
847
|
+
let targets = rawTargets.filter(resolvable)
|
|
836
848
|
let fallbackNote = ''
|
|
837
849
|
if (!targets.length) {
|
|
838
|
-
// 信封里没写收件人(例如别的设备直接 term_send 到本机、没指会话)→ 投给最近活跃会话
|
|
839
850
|
const list = this.sessions()
|
|
840
851
|
const latest = list.find((s) => s && s.active) || list[0]
|
|
841
|
-
if (!latest) return { ok: false, reason: '
|
|
852
|
+
if (!latest) return { ok: false, reason: `收件人「${rawTargets.join('、') || '空'}」在本机找不到会话,本机也没有可投递的会话` }
|
|
842
853
|
targets = [latest.name || latest.short]
|
|
843
|
-
|
|
854
|
+
const what = rawTargets.length ? `收件人「${rawTargets.join('、')}」不是本机会话名` : '信封没写收件人'
|
|
855
|
+
const selfNames = [this.nodeId, this.name, os.hostname()].map((v) => String(v || '').trim().toLowerCase())
|
|
856
|
+
const deviceHint = rawTargets.some((t) => selfNames.includes(String(t).trim().toLowerCase())) ? '(写的是本机设备名)' : ''
|
|
857
|
+
fallbackNote = `(${what}${deviceHint},按「最近活跃会话」投给「${targets[0]}」)`
|
|
858
|
+
} else if (unknown.length) {
|
|
859
|
+
fallbackNote = `(收件人「${unknown.join('、')}」没匹配到会话,已投给能匹配的 ${targets.length} 个)`
|
|
844
860
|
}
|
|
845
861
|
const controller = this.sessionController()
|
|
846
862
|
if (!controller || typeof controller.prompt !== 'function') {
|
package/lib/web.js
CHANGED
|
@@ -62,7 +62,19 @@ function ensureHttpToken(cfg, log = () => {}) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** 跨站 Origin 一律拒;令牌对得上才放行(同源页面可先 GET /crossterm/api/token 取令牌) */
|
|
65
|
+
const LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
|
|
66
|
+
|
|
67
|
+
/** A6:Host 必须是本机回环地址,挡住 DNS rebinding(rebound 页面的 Host 会是攻击者域名) */
|
|
68
|
+
function isLoopbackHost(host) {
|
|
69
|
+
return LOOPBACK_HOST.test(String(host || '').trim())
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
function checkHttp(req, res, cfg) {
|
|
73
|
+
if (!isLoopbackHost(req.headers && req.headers.host)) {
|
|
74
|
+
// 0.8.2 A6:只接受本机 Host
|
|
75
|
+
json(res, 403, { ok: false, error: '只接受本机访问(Host 必须是 127.0.0.1 / localhost)' })
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
66
78
|
const origin = String((req.headers && req.headers.origin) || '')
|
|
67
79
|
if (origin && !LOOPBACK_ORIGIN.test(origin)) {
|
|
68
80
|
json(res, 403, { ok: false, message: '拒绝跨站请求' })
|
|
@@ -510,8 +522,9 @@ export function registerWeb(ctx, term, log) {
|
|
|
510
522
|
path: '/crossterm/api/token',
|
|
511
523
|
handler: (req, res) => {
|
|
512
524
|
const origin = String((req.headers && req.headers.origin) || '')
|
|
513
|
-
|
|
514
|
-
|
|
525
|
+
// 0.8.2 A6:同源 GET 不带 Origin,DNS rebinding 时 Host 会是攻击者域名 —— 必须挡 Host
|
|
526
|
+
if (!isLoopbackHost(req.headers && req.headers.host) || (origin && !LOOPBACK_ORIGIN.test(origin))) {
|
|
527
|
+
json(res, 403, { ok: false, message: '只接受本机同源请求' })
|
|
515
528
|
return
|
|
516
529
|
}
|
|
517
530
|
json(res, 200, { ok: true, token: ensureHttpToken(term.cfg, log) })
|