Skip to content

Commit c641a48

Browse files
committed
chat(p1): sidecar GC级联清理完整化,预留加密算法迁移协议
1 parent 34a4706 commit c641a48

6 files changed

Lines changed: 116 additions & 4 deletions

File tree

src/public/locales/zh-CN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,7 @@
712712
"msgPrefixSticker": "贴纸",
713713
"membersEmpty": "(暂无成员)",
714714
"channelEncryptionNone": "访问控制私密(推荐)",
715+
"unknownEncryptionScheme": "该频道使用了不支持的加密方案,请升级客户端",
715716
"channelEncryptionMailbox": "高隐私模式(端到端加密)",
716717
"channelEncryptionEnableWarning": "开启高隐私模式后,消息内容将端到端加密,超级节点无法读取内容。历史消息不受影响,成员列表对节点仍可见。是否继续?",
717718
"channelEncryptionDisableWarning": "关闭高隐私模式后,新消息将不再加密,超级节点可读取频道内容。是否继续?",

src/public/parts/shells/chat/src/chat/dag.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,25 @@ export async function appendEncryptedMailboxBatch(username, chatId, body) {
686686
})
687687
}
688688

689+
/**
690+
* 记录私密频道加密方案迁移(硬切换预留,供客户端按版本协商)。
691+
* @param {string} username 用户名
692+
* @param {string} chatId 群组 ID
693+
* @param {{ channelId: string, newScheme: string, newVersion?: number, sender?: string }} body 迁移参数
694+
* @returns {Promise<object>} `appendEvent` 返回的签名事件对象
695+
*/
696+
export async function appendChannelCryptoMigrate(username, chatId, body) {
697+
const { channelId, newScheme, newVersion, sender = 'local' } = body
698+
if (!channelId || !newScheme) throw new Error('channelId and newScheme required')
699+
return appendEvent(username, chatId, {
700+
type: 'channel_crypto_migrate',
701+
channelId,
702+
sender,
703+
timestamp: Date.now(),
704+
content: { channelId, newScheme, newVersion: newVersion ?? 1 },
705+
})
706+
}
707+
689708
/**
690709
* 记录群主或其代理节点的活跃心跳。
691710
* @param {string} username 用户名
@@ -949,6 +968,8 @@ export async function pruneChannelMessagesJsonl(username, chatId, channelId, kee
949968
const kept = n ? lines.slice(-n) : []
950969
await mkdir(dirname(path), { recursive: true })
951970
await writeFile(path, kept.map(l => JSON.stringify(l)).join('\n') + (kept.length ? '\n' : ''), 'utf8')
971+
const keepIds = new Set(kept.map(l => String(l.content?.chatLogEntryId || l.eventId || '')).filter(Boolean))
972+
await pruneSidecarsNotInSet(username, chatId, channelId, keepIds)
952973
}
953974

954975
/**

src/public/parts/shells/chat/src/chat/session.mjs

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import { Buffer } from 'node:buffer'
88
import fs from 'node:fs'
9+
import { readdir, stat } from 'node:fs/promises'
10+
import { join } from 'node:path'
911
import { inspect } from 'node:util'
1012

1113
import { loadJsonFile, saveJsonFile } from '../../../../../../scripts/json_loader.mjs'
@@ -21,10 +23,10 @@ import { unlockAchievement } from '../../../achievements/src/api.mjs'
2123
import { addfile, getfile } from '../files.mjs'
2224
import { generateDiff, createBufferedSyncPreviewUpdater } from '../stream.mjs'
2325

24-
import { deleteLogContextSidecar, hydrateLogContextFromSidecar, persistLogContextSidecar } from './context_sidecar.mjs'
26+
import { deleteLogContextSidecar, hydrateLogContextFromSidecar, persistLogContextSidecar, pruneSidecarsNotInSet } from './context_sidecar.mjs'
2527
import { appendEvent, deleteChatData, ensureChat, getDefaultChannelId, getState, isValidChannelId, listChannelMessages } from './dag.mjs'
2628
import { syncChatLogEntryToDag, mirrorDeleteToDag, mirrorEditToDag } from './dagSync.mjs'
27-
import { chatJsonPath, chatsRoot } from './paths.mjs'
29+
import { chatDir, chatJsonPath, chatsRoot } from './paths.mjs'
2830
import { createCharRpcDispatcher } from './rpcDispatcher.mjs'
2931
import { broadcastEvent as broadcastGroupEvent, bufferStreamChunk, finishStreamBuffer } from './websocket.mjs'
3032

@@ -617,6 +619,44 @@ async function updateChatSummary(groupId, chatMetadata) {
617619
saveShellData(username, 'chat', 'chat_summaries_cache')
618620
}
619621

622+
/**
623+
* 按当前 chatLog 对齐各频道 context sidecar:删除已不存在条目对应的缓存文件。
624+
* @param {string} username 所有者
625+
* @param {string} groupId 群 ID
626+
* @param {chatLogEntry_t[]} chatLog 当前内存中的日志条目
627+
* @returns {Promise<void>}
628+
*/
629+
async function reconcileContextSidecarsWithChatLog(username, groupId, chatLog) {
630+
if (!username || !groupId || !Array.isArray(chatLog)) return
631+
const byCh = new Map()
632+
for (const entry of chatLog) {
633+
if (!entry?.id) continue
634+
const ch = isValidChannelId(entry.extension?.groupChannelId)
635+
? entry.extension.groupChannelId
636+
: 'default'
637+
if (!byCh.has(ch)) byCh.set(ch, new Set())
638+
byCh.get(ch).add(entry.id)
639+
}
640+
const ctxRoot = join(chatDir(username, groupId), 'context_cache')
641+
let names = []
642+
try {
643+
names = await readdir(ctxRoot)
644+
}
645+
catch {
646+
return
647+
}
648+
const channels = new Set(byCh.keys())
649+
for (const name of names)
650+
try {
651+
if ((await stat(join(ctxRoot, name))).isDirectory())
652+
channels.add(name)
653+
}
654+
catch { /* ignore */ }
655+
656+
for (const ch of channels)
657+
await pruneSidecarsNotInSet(username, groupId, ch, byCh.get(ch) ?? new Set())
658+
}
659+
620660
/**
621661
* 从 DAG default 频道行构造 chatLog 条目。
622662
* @param {object} line DAG 消息事件行
@@ -699,6 +739,8 @@ async function hydrateChatLogFromDag(username, groupId, chatMetadata) {
699739
chatMetadata.timeLineIndex = 0
700740
if (chatMetadata.chatLog.length)
701741
chatMetadata.LastTimeSlice = chatMetadata.chatLog[chatMetadata.chatLog.length - 1].timeSlice
742+
743+
await reconcileContextSidecarsWithChatLog(username, groupId, chatMetadata.chatLog)
702744
}
703745

704746
/**
@@ -1451,7 +1493,7 @@ async function executeGeneration(groupId, request, stream, placeholderEntry, cha
14511493
}
14521494
catch { /* ignore */ }
14531495
const idx = chatMetadata.chatLog.findIndex(e => e.id === entryId)
1454-
if (idx !== -1) await deleteMessage(groupId, idx)
1496+
if (idx !== -1) await deleteMessage(groupId, null, idx)
14551497
return
14561498
}
14571499

@@ -1900,6 +1942,18 @@ export async function deleteChat(chatids, username) {
19001942
try {
19011943
const jsonPath = chatJsonPath(username, groupId)
19021944
if (fs.existsSync(jsonPath)) await fs.promises.unlink(jsonPath)
1945+
try {
1946+
const ctxRoot = join(chatDir(username, groupId), 'context_cache')
1947+
const names = await readdir(ctxRoot)
1948+
for (const name of names)
1949+
try {
1950+
if ((await stat(join(ctxRoot, name))).isDirectory())
1951+
await pruneSidecarsNotInSet(username, groupId, name, new Set())
1952+
}
1953+
catch { /* ignore */ }
1954+
1955+
}
1956+
catch { /* ignore */ }
19031957
await deleteChatData(username, groupId)
19041958
chatMetadatas.delete(groupId)
19051959
delete summariesCache[groupId]
@@ -2017,6 +2071,7 @@ export async function deleteMessage(groupId, channelId, index) {
20172071

20182072
const owner = chatMetadatas.get(groupId)?.username
20192073
await mirrorDeleteToDag(groupId, entry, owner)
2074+
await reconcileContextSidecarsWithChatLog(chatMetadata.username, groupId, chatMetadata.chatLog)
20202075
}
20212076

20222077
/**

src/public/parts/shells/chat/src/endpoints.mjs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,26 @@ export function setEndpoints(router) {
476476
router.post('/api/parts/shells\\:chat/groups/:groupId/mailbox-batch', authenticate, async (req, res) => {
477477
const { username } = await getUserByReq(req)
478478
const { groupId } = req.params
479-
const out = await appendEncryptedMailboxBatch(username, groupId, req.body || {})
479+
const body = req.body || {}
480+
const { channelId } = body
481+
if (!channelId)
482+
return res.status(400).json({ error: 'channelId required' })
483+
const { state } = await getState(username, groupId)
484+
const chMeta = state.channels.get(String(channelId))
485+
const scheme = chMeta?.encryptionScheme ?? 'none'
486+
const requiredVersion = typeof chMeta?.encryptionVersion === 'number' && Number.isFinite(chMeta.encryptionVersion)
487+
? Math.max(1, Math.floor(chMeta.encryptionVersion))
488+
: 1
489+
if (scheme !== 'mailbox-ecdh') {
490+
const unknownScheme = scheme !== 'none' && scheme !== 'mailbox-ecdh'
491+
return res.status(409).json({
492+
error: 'ENCRYPTION_SCHEME_MISMATCH',
493+
currentScheme: scheme,
494+
requiredVersion,
495+
...unknownScheme ? { messageKey: 'chat.group.unknownEncryptionScheme' } : {},
496+
})
497+
}
498+
const out = await appendEncryptedMailboxBatch(username, groupId, body)
480499
res.status(200).json({ event: out })
481500
})
482501

src/scripts/p2p/constants.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const AUTHZ_EVENT_TYPES = new Set([
3333
'role_revoke',
3434
'channel_create',
3535
'channel_update',
36+
'channel_crypto_migrate',
3637
'channel_delete',
3738
'channel_permission_update', // 频道级角色 allow/deny 覆写
3839
'list_item_update',

src/scripts/p2p/materialized_state.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,21 @@ export function foldAuthzEvent(state, event) {
181181
}
182182
break
183183
}
184+
case 'channel_crypto_migrate': {
185+
const id = c.channelId || event.channelId
186+
const cur = id ? state.channels.get(id) : null
187+
if (cur && id) {
188+
const ver = c.newVersion != null && Number.isFinite(Number(c.newVersion))
189+
? Math.max(1, Math.floor(Number(c.newVersion)))
190+
: 1
191+
state.channels.set(id, {
192+
...cur,
193+
encryptionScheme: String(c.newScheme || cur.encryptionScheme || 'none'),
194+
encryptionVersion: ver,
195+
})
196+
}
197+
break
198+
}
184199
case 'channel_permission_update': {
185200
// content: { channelId, roleId, allow?: Record<string,boolean>, deny?: Record<string,boolean> }
186201
// 若 allow/deny 均缺省则清除该角色的频道覆写

0 commit comments

Comments
 (0)