feat(sync): 同步盘 + 关系层 (先建关系, 再做文件同步)
## 关系层 (src/main/relations.ts, relation-service.ts) 原来"发现即信任": UDP 广播包里明文带着 driveToken, 同网段任何进程拿到就能浏览 你的整个网盘, 而且没有"授权"这个概念。现在拆成三层: 发现 (临时) -> 关系 (持久, 用户显式建立) -> 授权 (分级, 双方各自独立) 建立关系的方式跟远程终端的授权闸门**完全一致** —— 对方直接弹窗, 不用邀请码: A 点"发起同步关系" -> WS 帧 relation:request -> B 侧弹窗 (复用 ApprovalDialog, kind='relation') -> 同意 -> relation:accept -> 双方落库并交换 per-peer 令牌。 - 令牌: myToken=我签发给对方的, peerToken=对方签发给我的 - **driveToken 不再进广播包**; file-server 在关系令牌匹配不上时回退到旧的全局 token, 但只允许"只读浏览网盘" —— 不给同步区, 也不给写/删 - 关系存 JSON 文件 (原子写), 不做 schema 迁移 - 授权能力: drive(浏览我的网盘) / sync(与我同步文件), 双方各自独立勾选 - 远程终端/端口转发/USB 仍走原来的 remoteAllowPeers 白名单, 没有并进关系层 - 同一对端 60s 内只弹一次窗 (WS 是"发现即建立"的, 不限流就能被刷屏) ## 同步区: 独立于网盘的第二套空间 用户的同步目录可以是任意文件夹 (~/文档/工作), 不必待在网盘目录里。 /drive/* 网盘 -> 共享目录, 要 drive 授权 /sync/* 同步区 -> 某关系专属的同步目录, 要 sync 授权, 路径严格限制在该目录内 顶层目录可以同时和多个设备同步: 每个关系自动分配一个子目录 (~/备份/MECHREVO/, ~/备份/lenovo/), 物理隔开, 同名文件不会互相覆盖 —— "统一目录多个聊天备份"成立。 ## 文件关系表 (src/main/sync-manifest.ts) —— 同步盘地基 核心是**区分"文件被删了"和"文件本来就不存在"**: - **墓碑**: 删除不抹记录, 只标 state='deleted'。直接抹掉的话, 对方离线回来会把 已删文件当成"新增"重新下载 -> 删除永远删不掉; 对方从备份恢复也会让删除被 静默撤销。有墓碑 + seq 比较就都能挡住。 - **seq = Lamport 时钟**: hash 只能答"一样不一样", seq 才能答"谁更晚" —— 判定 删除方向全靠它。扫描顺序必须是 advanceClock -> reconcileLocal -> mergePeer (先收消息再产生事件), 否则跨机器的 seq 不可比, 删除方向会判反。 - **三方对比**: 基线(上次两边一致的 hash) + 本地 + 远端。缺基线才退回 mtime 兜底。 - 墓碑留 90 天 (比大品牌默认 30 天保守), 且要"对方已越过该 seq"才清理。 - **删除传播**: 对方推关系表过来时, 比对基线确认我没改过才删本地那份; 改过则 报冲突 —— 绝不静默丢数据。 - 冲突: 手动同步弹窗对比让用户选方向; 自动同步跳过并记录, 不替用户决定。 ## 其余 - 自动同步 (src/main/auto-sync.ts): 60s 轮询 + 指数退避, 对方上线/开关打开时 立刻跑一轮。 - 同步面板 (SyncPanel.vue): 关系区 + 变更列表 (含"将删除对方/本机") + 冲突对比 弹窗 + 自动同步开关。 - 关系授权弹窗复用远程终端那套 ApprovalDialog。 - 目录穿越防护: 上传/下载/删除的路径都严格限制在各自根目录内, 且**软链归一后 再比一次** (同步目录是用户任意文件夹, 里面出现软链很常见)。 - 跨关系校验: 令牌与 relationId 绑定, A 关系的对方不能读写 B 关系的同步目录。 - 关系令牌在 IPC 返回给渲染层时剥掉, 界面拿不到凭据。 - 建关系/授权/同步/删除传播都写 audit_log。 ## 测试 - npm run selftest 64 通过 / 0 失败 (R关系 / M墓碑删除传播 / Y同步盘) - npm run selftest:sync 26 条全通过 (双节点端到端: 建立关系->同步->删->传播->防复活) ## 文档 docs/relation-and-sync.md —— 三层模型 / 关系建立 / 墓碑与 seq / 三方对比判定表 / 安全要点 / 已知不一致。
This commit is contained in:
+43
-12
@@ -8,30 +8,49 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { getSettings } from '../settings'
|
||||
|
||||
export type RemoteKind = 'terminal' | 'forward' | 'usb'
|
||||
export type RemoteKind = 'terminal' | 'forward' | 'usb' | 'relation'
|
||||
|
||||
/** 需要走"远程操作白名单"的种类 (relation 走关系层自己的授权, 不在这张表里) */
|
||||
export type RemoteGateKind = Exclude<RemoteKind, 'relation'>
|
||||
|
||||
interface Pending {
|
||||
resolve: (ok: boolean, remember?: boolean) => void
|
||||
resolve: (reply: ApprovalReply) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户对授权请求的回复。
|
||||
* terminal/forward/usb 只用到 ok + remember;
|
||||
* relation (建立同步关系) 还会带上用户勾选的授权范围和他自己的同步目录。
|
||||
*/
|
||||
export interface ApprovalReply {
|
||||
ok: boolean
|
||||
remember?: boolean
|
||||
/** relation: 用户开放给对方的能力 */
|
||||
grants?: string[]
|
||||
/** relation: 用户这边的同步目录 */
|
||||
syncLocalDir?: string
|
||||
/** relation: 允许对方用我网盘里的哪个子目录 */
|
||||
syncRemotePath?: string
|
||||
}
|
||||
|
||||
const pending = new Map<string, Pending>()
|
||||
const approvedAt = new Map<string, number>()
|
||||
const APPROVE_TTL_MS = 24 * 3600_000
|
||||
|
||||
function cacheKey(peerId: string, kind: RemoteKind, nonce?: string) {
|
||||
function cacheKey(peerId: string, kind: RemoteGateKind, nonce?: string) {
|
||||
// nonce 用于 usb.attach 这种需要每次重新确认的场景
|
||||
return nonce ? `${peerId}::${kind}::${nonce}` : `${peerId}::${kind}`
|
||||
}
|
||||
|
||||
export function isAutoAllowed(peerId: string, kind: RemoteKind): boolean {
|
||||
export function isAutoAllowed(peerId: string, kind: RemoteGateKind): boolean {
|
||||
const s = getSettings()
|
||||
if (!s.remoteEnabled) return false
|
||||
const allow = s.remoteAllowPeers?.[peerId]?.[kind]
|
||||
return !!allow
|
||||
}
|
||||
|
||||
export function isRemembered(peerId: string, kind: RemoteKind): boolean {
|
||||
export function isRemembered(peerId: string, kind: RemoteGateKind): boolean {
|
||||
const k = cacheKey(peerId, kind)
|
||||
const ts = approvedAt.get(k)
|
||||
if (!ts) return false
|
||||
@@ -42,11 +61,11 @@ export function isRemembered(peerId: string, kind: RemoteKind): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
export function markApproved(peerId: string, kind: RemoteKind) {
|
||||
export function markApproved(peerId: string, kind: RemoteGateKind) {
|
||||
approvedAt.set(cacheKey(peerId, kind), Date.now())
|
||||
}
|
||||
|
||||
export function resetApproval(peerId: string, kind?: RemoteKind) {
|
||||
export function resetApproval(peerId: string, kind?: RemoteGateKind) {
|
||||
for (const k of approvedAt.keys()) {
|
||||
if (k.startsWith(`${peerId}::`)) {
|
||||
if (!kind || k.startsWith(`${peerId}::${kind}`)) approvedAt.delete(k)
|
||||
@@ -55,7 +74,7 @@ export function resetApproval(peerId: string, kind?: RemoteKind) {
|
||||
}
|
||||
|
||||
// 同步决策: 自动允许 / 缓存允许 -> 直接 true; 否则 false (UI 需要主动 ask)
|
||||
export function shouldAsk(peerId: string, kind: RemoteKind): boolean {
|
||||
export function shouldAsk(peerId: string, kind: RemoteGateKind): boolean {
|
||||
if (isAutoAllowed(peerId, kind)) return false
|
||||
if (isRemembered(peerId, kind)) {
|
||||
markApproved(peerId, kind)
|
||||
@@ -75,12 +94,20 @@ export interface ApprovalRequest {
|
||||
kind: RemoteKind
|
||||
detail?: string
|
||||
ts: number
|
||||
/** relation: 对方想要的能力 */
|
||||
grants?: string[]
|
||||
/** relation: 对方的同步目录 */
|
||||
syncLocalDir?: string
|
||||
/** relation: 对方想用我网盘里的哪个子目录 */
|
||||
syncRemotePath?: string
|
||||
/** relation: 关系 id */
|
||||
relationId?: string
|
||||
}
|
||||
|
||||
const waitingQueue: ApprovalRequest[] = []
|
||||
const waitersByReq = new Map<string, Pending>()
|
||||
|
||||
export function enqueueApproval(req: ApprovalRequest): Promise<{ ok: boolean; remember?: boolean }> {
|
||||
export function enqueueApproval(req: ApprovalRequest): Promise<ApprovalReply> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(req.requestId)
|
||||
@@ -90,7 +117,7 @@ export function enqueueApproval(req: ApprovalRequest): Promise<{ ok: boolean; re
|
||||
if (i >= 0) waitingQueue.splice(i, 1)
|
||||
resolve({ ok: false })
|
||||
}, ASK_TIMEOUT_MS)
|
||||
pending.set(req.requestId, { resolve: (ok, remember) => resolve({ ok, remember }), timer })
|
||||
pending.set(req.requestId, { resolve, timer })
|
||||
waitersByReq.set(req.requestId, pending.get(req.requestId)!)
|
||||
waitingQueue.push(req)
|
||||
})
|
||||
@@ -100,7 +127,11 @@ export function listPendingApprovals(): ApprovalRequest[] {
|
||||
return [...waitingQueue]
|
||||
}
|
||||
|
||||
export function replyApproval(requestId: string, ok: boolean, remember?: boolean): boolean {
|
||||
export function replyApproval(
|
||||
requestId: string,
|
||||
ok: boolean,
|
||||
extra?: { remember?: boolean; grants?: string[]; syncLocalDir?: string; syncRemotePath?: string },
|
||||
): boolean {
|
||||
const p = pending.get(requestId)
|
||||
if (!p) return false
|
||||
clearTimeout(p.timer)
|
||||
@@ -108,7 +139,7 @@ export function replyApproval(requestId: string, ok: boolean, remember?: boolean
|
||||
waitersByReq.delete(requestId)
|
||||
const i = waitingQueue.findIndex(x => x.requestId === requestId)
|
||||
if (i >= 0) waitingQueue.splice(i, 1)
|
||||
p.resolve(ok, remember)
|
||||
p.resolve({ ok, ...(extra || {}) })
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user