diff --git a/packages/core/README.md b/packages/core/README.md index 2650eaa..17c051f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -137,7 +137,7 @@ const { complete, nextIndex, meta } = await result; ## 必须注意的几点 -**1. 终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这台设备在这个频道里」,服务端按它占用席位。不持久化的话,每次打开链接都会占用一个新席位。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。 +**1. 终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这台设备在这个频道里」,服务端按它登记独立设备席位以便认证和撤销;频道成员上限按账号数计算,同一账号的多台设备不重复占名额。不持久化的话,每次打开链接都会登记一个新设备终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。 **2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 859f00d..7ea4c00 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ */ export { ChannelSession, openLink } from './session.js'; +export { parseEnvelope } from './frame.js'; export type { AuthRole, ChannelSettings, @@ -40,6 +41,8 @@ export { buildLink, parseLink } from './link.js'; export type { LinkParts, ParsedLink } from './link.js'; export { fromBase64Url, toBase64Url } from './bytes.js'; +export { contentEventPayload, frameManifestHash, generateSigningKeyPair, importSigningPublicKey, signStatement, verifyStatement } from './signatures.js'; +export type { ContentEventStatement } from './signatures.js'; export { Flags, MAX_FRAME_DATA, PROTOCOL_VERSION } from './constants.js'; export type { ChannelMode, ItemKind } from './constants.js'; diff --git a/packages/core/src/reader.ts b/packages/core/src/reader.ts index 4d9e16e..944a149 100644 --- a/packages/core/src/reader.ts +++ b/packages/core/src/reader.ts @@ -3,6 +3,7 @@ import { concat, equalBytes, tryDecodeUtf8, toBase64Url, type Bytes } from './by import { IntegrityError, type IntegrityReason } from './errors.js'; import { openFrame, parseEnvelope, type Envelope } from './frame.js'; import { sha256 } from './kdf.js'; +import { frameManifestHash } from './signatures.js'; import { decodeFirstFrame, type ItemMeta } from './item.js'; import { parseLogRecord, type LogRecord, type RemovedReason } from './log.js'; @@ -29,6 +30,8 @@ export interface ReceivedItem { complete: boolean; /** 到第一个缺口为止已收到的内容。大文件请改用 decryptItemStream,不要在这里缓冲。 */ content: Bytes; + /** SHA-256 manifest over the complete ciphertext envelopes in frame-index order. */ + ciphertextManifestHash?: Bytes; } export type ItemEvent = @@ -104,7 +107,7 @@ export class ItemReader { const itemId = toBase64Url(env.itemId); try { - return await this.#accept(env, itemId, seq); + return await this.#accept(env, itemId, seq, Uint8Array.from(envelope)); } catch (e) { if (!(e instanceof IntegrityError)) throw e; this.#open.delete(itemId); @@ -118,7 +121,7 @@ export class ItemReader { this.#done.delete(itemId); } - async #accept(env: Envelope, itemId: string, seq: number | undefined): Promise { + async #accept(env: Envelope, itemId: string, seq: number | undefined, envelope: Bytes): Promise { const isEncFrame = (env.flags & Flags.Enc) !== 0; // 降级防御:enc 会话只接受认证过的 enc 帧,服务端不能注入明文帧;raw 会话反之 if (isEncFrame !== (this.#ctx.mode === 'enc')) throw new IntegrityError('wrong_mode'); @@ -139,7 +142,7 @@ export class ItemReader { } // 重复帧:内容必须逐字节相同,相同则视为重放,不产生事件 - const digest = await sha256(env.payload); + const digest = await sha256(envelope); const dup = state?.frames.get(env.frameIndex); if (dup) { if (!equalBytes(dup.digest, digest)) throw new IntegrityError('duplicate_mismatch'); @@ -186,6 +189,9 @@ export class ItemReader { content: concat(...parts), }; if (state.meta) item.meta = state.meta; + if (complete) item.ciphertextManifestHash = Uint8Array.from(await frameManifestHash( + Array.from({ length: state.finalIndex! + 1 }, (_, index) => state!.frames.get(index)!.digest), + )); if (complete) { this.#open.delete(itemId); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index c9f3a83..3dc38ee 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -92,6 +92,8 @@ interface SessionInit { * JS 无法保证这些内存被真正擦除。调用 dispose() 只能尽力清零。 */ export class ChannelSession { + /** Applications may supply independently authorized object keys instead of the space root. */ + objectKeyResolver?: (itemId: string) => Promise; readonly id: string; readonly idBytes: Bytes; readonly mode: ChannelMode; @@ -247,6 +249,7 @@ export class ChannelSession { /** 一个条目的 AES-GCM 密钥,带缓存。 */ itemKey(itemId: Uint8Array): Promise { + if (this.objectKeyResolver) return this.objectKeyResolver(toBase64Url(itemId)); if (!this.#keys) throw new CapabilityError('enc'); const cacheKey = toBase64Url(itemId); let key = this.#itemKeys.get(cacheKey); diff --git a/packages/core/src/signatures.ts b/packages/core/src/signatures.ts new file mode 100644 index 0000000..16d9f0c --- /dev/null +++ b/packages/core/src/signatures.ts @@ -0,0 +1,110 @@ +const SIGNATURE_MAGIC = new Uint8Array([0x57, 0x44, 0x53, 0x01]); // WDS\x01 +const MAX_KIND_BYTES = 64; +const MAX_PAYLOAD_BYTES = 1_048_576; +const source = (value: Uint8Array): ArrayBuffer => { + const copy = new Uint8Array(value.length); + copy.set(value); + return copy.buffer; +}; + +export interface ContentEventStatement { + spaceId: Uint8Array; + channelId: string; + username: string; + deviceId: string; + eventId: Uint8Array; + eventKind: string; + domain: 'chat' | 'files'; + epoch: number; + itemId: Uint8Array; + frameCount: number; + manifestHash: Uint8Array; + targetItemId?: Uint8Array; +} + +export function contentEventPayload(event: ContentEventStatement): Uint8Array { + const channel = new TextEncoder().encode(event.channelId.toLowerCase()); + const username = new TextEncoder().encode(event.username.toLowerCase()); + const device = new TextEncoder().encode(event.deviceId.toLowerCase()); + const kind = new TextEncoder().encode(event.eventKind); + const target = event.targetItemId ?? new Uint8Array(); + if (event.spaceId.length !== 16 || event.eventId.length !== 16 || event.itemId.length !== 16 || event.manifestHash.length !== 32 + || target.length !== 0 && target.length !== 16 || !Number.isInteger(event.epoch) || event.epoch < 0 + || !Number.isInteger(event.frameCount) || event.frameCount < 1 || event.frameCount > 65_536 + || !/^[0-9a-f-]{36}$/.test(event.channelId.toLowerCase()) || !/^[0-9a-f-]{36}$/.test(event.deviceId.toLowerCase()) + || !/^[a-z0-9_-]{3,32}$/.test(event.username.toLowerCase()) || !/^[a-z][a-z.\-]{0,31}$/.test(event.eventKind)) + throw new TypeError('签名事件字段无效'); + const bytes = new Uint8Array(16 + 1 + channel.length + 1 + username.length + 1 + device.length + 16 + 1 + kind.length + 1 + 4 + 16 + 4 + 32 + 1 + 16); + let offset = 0; + bytes.set(event.spaceId, offset); offset += 16; + bytes[offset++] = channel.length; bytes.set(channel, offset); offset += channel.length; + bytes[offset++] = username.length; bytes.set(username, offset); offset += username.length; + bytes[offset++] = device.length; bytes.set(device, offset); offset += device.length; + bytes.set(event.eventId, offset); offset += 16; + bytes[offset++] = kind.length; bytes.set(kind, offset); offset += kind.length; + bytes[offset++] = event.domain === 'chat' ? 1 : 2; + new DataView(bytes.buffer).setUint32(offset, event.epoch, false); offset += 4; + bytes.set(event.itemId, offset); offset += 16; + new DataView(bytes.buffer).setUint32(offset, event.frameCount, false); offset += 4; + bytes.set(event.manifestHash, offset); offset += 32; + bytes[offset++] = target.length === 0 ? 0 : 1; + if (target.length) bytes.set(target, offset); + return bytes; +} + +export async function frameManifestHash(frameDigests: readonly Uint8Array[]): Promise { + if (frameDigests.length < 1 || frameDigests.length > 65_536 || frameDigests.some(digest => digest.length !== 32)) + throw new RangeError('帧清单无效'); + const bytes = new Uint8Array(8 + frameDigests.length * 36); + bytes.set([0x57, 0x44, 0x31, 0x4d]); // WD1M + new DataView(bytes.buffer).setUint32(4, frameDigests.length, false); + let offset = 8; + for (let index = 0; index < frameDigests.length; index++) { + new DataView(bytes.buffer).setUint32(offset, index, false); offset += 4; + bytes.set(frameDigests[index]!, offset); offset += 32; + } + return new Uint8Array(await crypto.subtle.digest('SHA-256', source(bytes))); +} + +function signingBytes(kind: string, payload: Uint8Array): Uint8Array { + if (!/^[a-z][a-z0-9._-]{0,63}$/.test(kind)) throw new TypeError('签名类型无效'); + if (payload.length > MAX_PAYLOAD_BYTES) throw new RangeError('签名内容过大'); + const kindBytes = new TextEncoder().encode(kind); + if (kindBytes.length > MAX_KIND_BYTES) throw new RangeError('签名类型过长'); + const bytes = new Uint8Array(SIGNATURE_MAGIC.length + 2 + kindBytes.length + 4 + payload.length); + let offset = 0; + bytes.set(SIGNATURE_MAGIC, offset); offset += SIGNATURE_MAGIC.length; + new DataView(bytes.buffer).setUint16(offset, kindBytes.length, false); offset += 2; + bytes.set(kindBytes, offset); offset += kindBytes.length; + new DataView(bytes.buffer).setUint32(offset, payload.length, false); offset += 4; + bytes.set(payload, offset); + return bytes; +} + +export async function generateSigningKeyPair(extractable = false): Promise { + return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, extractable, ['sign', 'verify']); +} + +export async function signStatement(privateKey: CryptoKey, kind: string, payload: Uint8Array): Promise { + const signature = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, privateKey, source(signingBytes(kind, payload)), + ); + const bytes = new Uint8Array(signature); + if (bytes.length !== 64) throw new Error('签名格式无效'); + return bytes; +} + +export async function verifyStatement( + publicKey: CryptoKey, kind: string, payload: Uint8Array, signature: Uint8Array, +): Promise { + if (signature.length !== 64) return false; + return crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, publicKey, source(signature), source(signingBytes(kind, payload)), + ); +} + +export async function importSigningPublicKey(key: JsonWebKey): Promise { + if (key.kty !== 'EC' || key.crv !== 'P-256' || !key.x || !key.y || key.d !== undefined) + throw new TypeError('签名公钥格式无效'); + return crypto.subtle.importKey('jwk', key, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); +} diff --git a/packages/core/src/stream.ts b/packages/core/src/stream.ts index d25979a..3acae14 100644 --- a/packages/core/src/stream.ts +++ b/packages/core/src/stream.ts @@ -3,6 +3,8 @@ import { equalBytes, type Bytes } from './bytes.js'; import { IntegrityError } from './errors.js'; import { MAX_ENC_PAYLOAD, openFrame, parseEnvelope, peekEnvelopeLength } from './frame.js'; import { decodeFirstFrame, type ItemMeta } from './item.js'; +import { sha256 } from './kdf.js'; +import { frameManifestHash } from './signatures.js'; import type { ReaderContext } from './reader.js'; /** 字节队列:接收任意切分的块,按需取出前 n 个字节。 */ @@ -106,6 +108,8 @@ export interface ItemDecodeResult { nextIndex: number; /** 第 0 帧里的元数据;从中间帧续传时没有。 */ meta: ItemMeta | undefined; + /** Present only for complete reads starting at frame zero. */ + ciphertextManifestHash?: Bytes; } /** @@ -125,8 +129,10 @@ export async function* decryptItemFrames( let flagsMask: number | undefined; let finalSeen = false; let meta: ItemMeta | undefined; + const frameDigests: Bytes[] = []; for await (const bytes of splitEnvelopes(source)) { + frameDigests.push(await sha256(bytes)); const env = parseEnvelope(bytes); const isEnc = (env.flags & Flags.Enc) !== 0; @@ -157,7 +163,8 @@ export async function* decryptItemFrames( if (data.length > 0) yield data; } - return { complete: finalSeen, nextIndex: expected, meta }; + return { complete: finalSeen, nextIndex: expected, meta, + ...(finalSeen && (opts.fromIndex ?? 0) === 0 ? { ciphertextManifestHash: Uint8Array.from(await frameManifestHash(frameDigests)) } : {}) }; } /** 把 decryptItemFrames 包成 ReadableStream,便于直接 pipe 到文件。 */ diff --git a/packages/core/test/e2e.test.ts b/packages/core/test/e2e.test.ts index ac277b3..9b03a50 100644 --- a/packages/core/test/e2e.test.ts +++ b/packages/core/test/e2e.test.ts @@ -78,9 +78,12 @@ beforeAll(async () => { const verifier = crypto.getRandomValues(new Uint8Array(32)); const keys = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); const pubkey = await crypto.subtle.exportKey('jwk', keys.publicKey); + const signingKeys = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); + const signingPublicKey = await crypto.subtle.exportKey('jwk', signingKeys.publicKey); const encoded = (value: Uint8Array) => Buffer.from(value).toString('base64'); const registered = await fetch(`${base}/api/v1/accounts`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - username: 'core_e2e', salt: encoded(crypto.getRandomValues(new Uint8Array(16))), verifier: encoded(verifier), vault: encoded(new Uint8Array(28)), public_key: JSON.stringify(pubkey), + username: 'core_e2e', salt: encoded(crypto.getRandomValues(new Uint8Array(16))), verifier: encoded(verifier), vault: encoded(new Uint8Array(28)), + public_key: JSON.stringify(pubkey), signing_public_key: JSON.stringify(signingPublicKey), }) }); expect(registered.status).toBe(204); const session = await fetch(`${base}/api/v1/accounts/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'core_e2e', verifier: encoded(verifier) }) }); @@ -98,7 +101,7 @@ afterAll(async () => { const api = (path: string) => `${base}/api/v1${path}`; async function createChannel(opts: Parameters[0] = {}) { - const created = await ChannelSession.create(opts); + const created = await ChannelSession.create({ ...opts, settings: { name: 'Core E2E', ...opts.settings } }); const res = await fetch(api('/channels'), { method: 'POST', headers: { ...created.request.headers, 'X-WD-Account': accountToken, 'Content-Type': 'application/json' }, diff --git a/packages/core/test/signatures.test.ts b/packages/core/test/signatures.test.ts new file mode 100644 index 0000000..f11e0e9 --- /dev/null +++ b/packages/core/test/signatures.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { fromBase64Url } from '../src/bytes.js'; +import { contentEventPayload, frameManifestHash, generateSigningKeyPair, signStatement, verifyStatement } from '../src/signatures.js'; +import { h, vectors } from './helpers.js'; + +describe('协议签名原语', () => { + it('跨语言固定向量可以验签,且类型和负载均受签名保护', async () => { + const fixture = vectors.signatures; + const publicKey = await crypto.subtle.importKey('jwk', { + kty: 'EC', crv: 'P-256', x: fixture.x, y: fixture.y, ext: true, + }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); + const payload = h(fixture.payload); + const signature = h(fixture.signature); + expect(await verifyStatement(publicKey, fixture.kind, payload, signature)).toBe(true); + expect(await verifyStatement(publicKey, 'chat.edit', payload, signature)).toBe(false); + payload[0] ^= 1; + expect(await verifyStatement(publicKey, fixture.kind, payload, signature)).toBe(false); + expect(await verifyStatement(publicKey, fixture.kind, h(fixture.payload), signature.subarray(1))).toBe(false); + expect(fromBase64Url(fixture.x)).toHaveLength(32); + }); + + it('浏览器签名可验证,篡改被拒绝', async () => { + const pair = await generateSigningKeyPair(); + expect(pair.privateKey.extractable).toBe(false); + const payload = new TextEncoder().encode('signed command bytes'); + const signature = await signStatement(pair.privateKey, 'workspace.channel.create', payload); + expect(signature).toHaveLength(64); + expect(await verifyStatement(pair.publicKey, 'workspace.channel.create', payload, signature)).toBe(true); + expect(await verifyStatement(pair.publicKey, 'workspace.group.create', payload, signature)).toBe(false); + await expect(signStatement(pair.privateKey, 'Workspace.Channel.Create', payload)).rejects.toThrow(); + }); + + it('聊天/文件签名上下文与帧清单跨语言向量一致', async () => { + const event = vectors.content_event; + const manifest = await frameManifestHash(event.frame_digests.map((digest: string) => h(digest))); + expect(manifest).toEqual(h(event.manifest_hash)); + expect(contentEventPayload({ + spaceId: h(event.space_id), channelId: event.channel_id, username: event.username, deviceId: event.device_id, + eventId: h(event.event_id), eventKind: event.kind, domain: event.domain, epoch: event.epoch, + itemId: h(event.item_id), frameCount: event.frame_count, manifestHash: manifest, targetItemId: h(event.target_item_id), + })).toEqual(h(event.payload)); + }); +}); diff --git a/scripts/browser-account-channel-smoke.js b/scripts/browser-account-channel-smoke.js index 803e1df..cad2cae 100644 --- a/scripts/browser-account-channel-smoke.js +++ b/scripts/browser-account-channel-smoke.js @@ -36,7 +36,7 @@ async (page) => { await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor(); if (await owner.getByLabel('个人名称', { exact: true }).inputValue() !== '多设备测试用户') throw new Error('Profile did not survive reload'); await owner.getByRole('img', { name: '个人头像', exact: true }).waitFor(); - results.push('login, encrypted profile and avatar survive reload'); + results.push('server-owned profile and avatar survive login and reload'); await owner.getByRole('button', { name: '新建空间', exact: true }).click(); await owner.getByLabel('空间名称(加入者和服务端可见)').fill('架构验收空间'); await owner.getByRole('button', { name: '创建', exact: true }).click(); @@ -52,9 +52,11 @@ async (page) => { await owner.getByRole('button', { name: '频道设置 常规', exact: true }).click(); await owner.getByLabel('所属分组').selectOption({ label: '工作' }); await owner.getByRole('button', { name: '保存', exact: true }).click(); + await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' }); await owner.getByRole('button', { name: '重命名分组 工作', exact: true }).click(); await owner.getByLabel('分组名称', { exact: true }).fill('项目'); await owner.getByRole('button', { name: '保存', exact: true }).click(); + await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' }); await owner.getByRole('button', { name: '分组 项目', exact: true }).click(); if (await owner.getByRole('button', { name: '讨论', exact: true }).isVisible()) throw new Error('Group did not collapse'); await owner.getByRole('button', { name: '分组 项目', exact: true }).click(); @@ -65,12 +67,14 @@ async (page) => { await owner.getByRole('button', { name: '频道设置 讨论', exact: true }).click(); await owner.getByLabel('所属分组').selectOption({ label: '未分组' }); await owner.getByRole('button', { name: '保存', exact: true }).click(); + await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' }); if (await owner.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '讨论', exact: true }).count()) throw new Error('Ungrouping did not remove group membership'); await owner.getByRole('button', { name: '频道设置 讨论', exact: true }).click(); await owner.getByLabel('所属分组').selectOption({ label: '项目' }); await owner.getByRole('button', { name: '保存', exact: true }).click(); + await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' }); await owner.getByRole('button', { name: '常规', exact: true }).click(); - results.push('channel groups create, rename, collapse and survive encrypted replay'); + results.push('backend channels and groups create, rename, collapse and survive reload'); const path = new URL(owner.url()).pathname; if (owner.url().includes('#')) throw new Error('Account channel navigation retained a secret fragment'); await owner.locator('textarea').last().fill('常规 thread 聊天验证'); @@ -116,7 +120,7 @@ async (page) => { await owner.getByLabel('移动所选文件到文件夹').selectOption({ label: '资料目录' }); await owner.getByRole('button', { name: '资料目录', exact: true }).dblclick(); await owner.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor(); - results.push('folder navigation and encrypted file moves work'); + results.push('backend folders and file locations update without re-encrypting content'); await second.goto(origin); const selfRow = second.getByRole('region', { name: '账号设备' }).locator('div').filter({ has: second.getByText('本机', { exact: true }) }); await selfRow.locator('input').fill('验收副设备'); diff --git a/scripts/browser-backend-data-smoke.js b/scripts/browser-backend-data-smoke.js new file mode 100644 index 0000000..8f97d9a --- /dev/null +++ b/scripts/browser-backend-data-smoke.js @@ -0,0 +1,53 @@ +// Works against either the development proxy or the embedded production frontend. +async (page) => { + const origin = new URL(page.url()).origin; + const context = await page.context().browser().newContext({ viewport: { width: 1440, height: 900 } }); + const owner = await context.newPage(); const results = []; + try { + await owner.goto(origin); + await owner.getByLabel('用户名', { exact: true }).fill(`backend_${Date.now()}`); + await owner.getByLabel('密码', { exact: true }).fill('Backend-data-test-password'); + await owner.getByRole('button', { name: '创建账号', exact: true }).click(); + await owner.getByRole('button', { name: '新建空间', exact: true }).click(); + await owner.getByLabel('空间名称(加入者和服务端可见)').fill('后端数据源测试'); + await owner.getByRole('button', { name: '创建', exact: true }).click(); + await owner.getByRole('button', { name: '常规', exact: true }).waitFor(); + const spaceId = new URL(owner.url()).pathname.split('/').pop(); + await owner.getByRole('button', { name: '新建频道分组', exact: true }).click(); + await owner.getByLabel('分组名称', { exact: true }).fill('前端分组'); + const requestPromise = owner.waitForRequest(request => request.method() === 'POST' && request.url().endsWith(`/spaces/${spaceId}/groups`)); + await owner.getByRole('button', { name: '创建', exact: true }).click(); + const request = await requestPromise; + const allHeaders = await request.allHeaders(); + const headers = Object.fromEntries(['authorization', 'x-wd-role', 'x-wd-endpoint', 'x-wd-account', 'content-type'].filter(key => allHeaders[key]).map(key => [key, allHeaders[key]])); + await owner.getByRole('button', { name: '分组 前端分组', exact: true }).waitFor(); + const initial = await context.request.get(`${origin}/api/v1/spaces/${spaceId}/structure`, { headers }).then(r => r.json()); + const groupId = initial.structure.groups[0].id; + const renamed = await context.request.patch(`${origin}/api/v1/spaces/${spaceId}/groups/${groupId}`, { headers, data: { name: '后端分组' } }); + if (!renamed.ok()) throw new Error(`Backend rename failed ${renamed.status()}`); + await owner.getByRole('button', { name: '分组 后端分组', exact: true }).waitFor(); + const created = await context.request.post(`${origin}/api/v1/spaces/${spaceId}/channels`, { headers, data: { name: '后端频道', groupId } }); + if (created.status() !== 201) throw new Error('Backend channel creation failed'); + await owner.getByRole('button', { name: '后端频道', exact: true }).waitFor(); + results.push('direct backend group and channel edits update the UI without client structure records'); + const locationResponse = owner.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith(`/spaces/${spaceId}/locations`)); + await owner.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('后端作者归属测试'); + await owner.getByRole('button', { name: '发送消息', exact: true }).click(); + if (!(await locationResponse).ok()) throw new Error('Backend item association failed'); + const profile = await context.request.patch(`${origin}/api/v1/accounts/profile`, { headers, data: { displayName: '后端昵称' } }); + if (!profile.ok()) throw new Error('Backend profile update failed'); + await owner.getByRole('article', { name: '后端昵称 的消息', exact: true }).getByText('后端作者归属测试', { exact: true }).waitFor(); + results.push('backend account profile and stored authors determine message identity'); + const spaces = await context.request.get(`${origin}/api/v1/accounts/spaces`, { headers }).then(r => r.json()); + if (!spaces.some(space => space.id === spaceId) || spaces.some(space => 'read_hash' in space || 'ownerLink' in space || 'memberLink' in space)) throw new Error('Space metadata catalog is invalid'); + for (const path of ['/accounts/profile/import', `/spaces/${spaceId}/structure/import`]) { + const response = await context.request.post(`${origin}/api/v1${path}`, { headers, data: {} }); + if (response.status() !== 404 && response.status() !== 405) throw new Error('Unexpected legacy import route'); + } + await owner.reload(); + await owner.getByRole('button', { name: '后端频道', exact: true }).waitFor(); + await owner.getByRole('article', { name: '后端昵称 的消息', exact: true }).waitFor(); + results.push('server-owned state survives reload and there are no migration endpoints'); + return { passed: results }; + } finally { await context.close(); } +} diff --git a/scripts/browser-channel-domains-smoke.js b/scripts/browser-channel-domains-smoke.js new file mode 100644 index 0000000..63d793e --- /dev/null +++ b/scripts/browser-channel-domains-smoke.js @@ -0,0 +1,50 @@ +async (page) => { + const origin = page.url().startsWith('http') ? new URL(page.url()).origin : 'http://127.0.0.1:5179'; const browser = page.context().browser(); const contexts = []; const results = []; + const password = 'Channel-domains-test-password'; const suffix = Date.now(); const ownerName = `domains_owner_${suffix}`; const memberName = `domains_member_${suffix}`; + let owner, member; const failures = []; + async function account(username) { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); contexts.push(context); const target = await context.newPage(); + await target.goto(origin); await target.getByLabel('用户名', { exact: true }).fill(username); await target.getByLabel('密码', { exact: true }).fill(password); + await target.getByRole('button', { name: '创建账号', exact: true }).click(); await target.getByRole('button', { name: '新建空间', exact: true }).waitFor(); return target; + } + async function send(target, text) { await target.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill(text); await target.getByRole('button', { name: '发送消息', exact: true }).click(); await target.getByRole('article').getByText(text, { exact: true }).waitFor(); } + try { + owner = await account(ownerName); member = await account(memberName); + member.on('response', async response => { if (response.status() >= 400 && response.url().includes('/api/v1/')) failures.push({ status: response.status(), method: response.request().method(), path: new URL(response.url()).pathname, code: (await response.json().catch(() => ({}))).error }); }); + await owner.getByRole('button', { name: '新建空间', exact: true }).click(); await owner.getByLabel('空间名称(加入者和服务端可见)').fill('独立数据域测试'); await owner.getByRole('button', { name: '创建', exact: true }).click(); + await owner.getByRole('button', { name: '常规', exact: true }).waitFor(); const path = new URL(owner.url()).pathname; + await send(owner, '加入前的历史聊天'); + const ownerExplorer = owner.getByRole('region', { name: '频道文件管理器' }); + await ownerExplorer.locator('input[type=file]').setInputFiles({ name: 'old-domain-file.txt', mimeType: 'text/plain', buffer: Buffer.from('historical file must remain private') }); + await owner.getByRole('button', { name: '继续上传', exact: true }).click(); await owner.getByRole('button', { name: 'old-domain-file.txt', exact: true }).waitFor(); + await owner.getByRole('button', { name: '在未分组新建频道', exact: true }).click(); await owner.getByLabel('频道名称', { exact: true }).fill('单独授权频道'); await owner.getByRole('button', { name: '创建频道', exact: true }).click(); + await owner.getByRole('button', { name: '单独授权频道', exact: true }).waitFor(); await owner.getByRole('button', { name: '常规', exact: true }).click(); + await owner.getByRole('button', { name: '频道设置 常规', exact: true }).click(); await owner.getByLabel('新成员可读历史聊天').uncheck(); await owner.getByLabel('新成员可读历史文件').uncheck(); + await owner.getByRole('button', { name: '保存', exact: true }).click(); await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' }); + await owner.getByRole('button', { name: '空间设置', exact: true }).click(); await owner.getByLabel('被邀请用户的用户名').fill(memberName); await owner.getByRole('button', { name: '发送邀请', exact: true }).click(); + await owner.getByText(`${memberName} · 已邀请`, { exact: true }).waitFor(); await owner.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); + await member.goto(origin + path); await member.getByRole('button', { name: '接受并加入空间', exact: true }).click(); await member.getByRole('button', { name: '常规', exact: true }).waitFor(); + if (await member.getByRole('button', { name: '单独授权频道', exact: true }).count()) throw new Error('Member received an unauthorized channel'); + if (await member.getByRole('article').getByText('加入前的历史聊天', { exact: true }).count()) throw new Error('Member received historical chat keys'); + if (await member.getByRole('button', { name: 'old-domain-file.txt', exact: true }).count()) throw new Error('Member received historical file keys'); + await send(owner, '加入后的新聊天'); await member.getByRole('article').getByText('加入后的新聊天', { exact: true }).waitFor(); + const ownerMessage = owner.locator('article').filter({ hasText: '加入后的新聊天' }); + const memberCopy = member.locator('article').filter({ hasText: '加入后的新聊天' }); + if (await ownerMessage.evaluate(element => getComputedStyle(element).flexDirection) !== 'row-reverse' + || await memberCopy.evaluate(element => getComputedStyle(element).flexDirection) !== 'row') throw new Error('Messages are not aligned by sender'); + await send(member, '成员发言验证'); await owner.getByRole('article').getByText('成员发言验证', { exact: true }).waitFor(); + results.push('default-channel access is automatic; other channels and past epochs remain inaccessible'); + const explorer = ownerExplorer; + await explorer.locator('input[type=file]').setInputFiles({ name: 'new-domain-file.txt', mimeType: 'text/plain', buffer: Buffer.from('independent random file key verification') }); + await owner.getByRole('button', { name: '继续上传', exact: true }).click(); await member.getByRole('button', { name: 'new-domain-file.txt', exact: true }).waitFor(); + await member.getByRole('button', { name: 'new-domain-file.txt', exact: true }).dblclick(); + await member.getByRole('dialog').locator('pre').filter({ hasText: 'independent random file key verification' }).waitFor(); + results.push('file-group key unwraps an independent file key and decrypts the file on another account'); + await member.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); await member.reload(); await member.getByRole('article').getByText('加入后的新聊天', { exact: true }).waitFor(); + if (await member.getByRole('article').getByText('加入前的历史聊天', { exact: true }).count()) throw new Error('Reload restored disallowed history'); + results.push('authorization and epoch boundaries survive reload'); + return { passed: results }; + } catch (error) { + throw new Error(`${error.message}; failures=${JSON.stringify(failures)}; owner alerts=${JSON.stringify(owner ? await owner.getByRole('alert').allTextContents() : [])}; member alerts=${JSON.stringify(member ? await member.getByRole('alert').allTextContents() : [])}`); + } finally { for (const context of contexts) await context.close(); } +} diff --git a/scripts/browser-chat-files-smoke.js b/scripts/browser-chat-files-smoke.js index 48aec77..bbb71bf 100644 --- a/scripts/browser-chat-files-smoke.js +++ b/scripts/browser-chat-files-smoke.js @@ -42,13 +42,20 @@ async (page) => { await file.getByRole('img', { name: '小白 的头像', exact: true }).waitFor(); await viewer.reload(); await viewer.getByRole('article', { name: '小白 上传了 project-notes.txt', exact: true }).getByRole('img', { name: '小白 的头像', exact: true }).waitFor(); if (downloads !== 1) throw new Error('Reloading file bubbles consumed a download'); - results.push('identity survives download updates and encrypted history replay without automatic content reads'); + results.push('server-owned identity survives download updates and reload without automatic content reads'); await viewer.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('我收到了。'); await viewer.getByRole('button', { name: '发送消息', exact: true }).click(); await sender.getByRole('article', { name: '小黑 的消息', exact: true }).getByText('我收到了。', { exact: true }).waitFor(); results.push('each participant keeps their own sender identity'); await viewer.screenshot({ path: 'C:/Users/JustMe/Desktop/webdrop-proj/.playwright-mcp/chat-file-messages.png', fullPage: true }); for (const width of [1440, 390]) { await viewer.setViewportSize({ width, height: 900 }); for (const colorScheme of ['light', 'dark']) { - await viewer.emulateMedia({ colorScheme }); if (await viewer.evaluate(() => document.documentElement.scrollWidth > innerWidth)) throw new Error('Chat layout overflow'); + await viewer.emulateMedia({ colorScheme }); + // Allow the media-query subscription to switch the React workspace layout. + await viewer.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + if (await viewer.evaluate(() => document.documentElement.scrollWidth > innerWidth)) { + const overflow = await viewer.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth, + elements: [...document.querySelectorAll('body *')].filter(e => e.getBoundingClientRect().right > innerWidth + 1).slice(0, 12).map(e => ({ tag: e.tagName, cls: e.className, right: e.getBoundingClientRect().right })) })); + throw new Error(`Chat layout overflow: ${JSON.stringify(overflow)}`); + } } } return { passed: results }; } finally { for (const context of contexts) await context.close(); } diff --git a/scripts/browser-passkey-smoke.js b/scripts/browser-passkey-smoke.js new file mode 100644 index 0000000..a746e33 --- /dev/null +++ b/scripts/browser-passkey-smoke.js @@ -0,0 +1,44 @@ +async (page) => { + const origin = new URL(page.url()).origin; + const context = await page.context().browser().newContext(); const owner = await context.newPage(); + const cdp = await context.newCDPSession(owner); const results = []; + const username = `passkey_${Date.now()}`; const password = 'Passkey-test-password-2026'; + const errors = []; const calls = []; + owner.on('pageerror', error => errors.push(error.message)); + owner.on('request', request => { if (request.url().includes('/api/v1/')) calls.push(new URL(request.url()).pathname); }); + await cdp.send('WebAuthn.enable'); + const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', ctap2Version: 'ctap2_1', transport: 'internal', hasResidentKey: true, + hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, hasPrf: true } }); + let secretExtensionSent = false; + owner.on('request', request => { if (request.url().includes('/accounts/passkeys/') && request.postData()) { + const body = JSON.parse(request.postData()); if (body.credential && JSON.parse(body.credential).clientExtensionResults?.prf?.results) secretExtensionSent = true; + } }); + try { + await owner.goto(origin); await owner.getByLabel('用户名', { exact: true }).fill(username); await owner.getByLabel('密码', { exact: true }).fill(password); + await owner.getByRole('button', { name: '创建账号', exact: true }).click(); await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor(); + await owner.getByLabel('Passkey名称', { exact: true }).fill('测试恢复凭据'); await owner.getByLabel('验证账号密码', { exact: true }).fill(password); + await owner.getByRole('button', { name: '绑定Passkey', exact: true }).click(); + await owner.getByText('测试恢复凭据 · 登录与解密恢复', { exact: true }).waitFor(); + if (secretExtensionSent) throw new Error('PRF results were sent to the API'); + results.push('verified WebAuthn binding enables encrypted recovery without sending PRF outputs to the server'); + await owner.getByRole('button', { name: '新建空间', exact: true }).click(); await owner.getByLabel('空间名称(加入者和服务端可见)').fill('Passkey恢复测试'); + await owner.getByRole('button', { name: '创建', exact: true }).click(); await owner.getByRole('button', { name: '常规', exact: true }).waitFor(); + await owner.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('Passkey恢复后的私密内容'); await owner.getByRole('button', { name: '发送消息', exact: true }).click(); + await owner.getByRole('article').getByText('Passkey恢复后的私密内容', { exact: true }).waitFor(); + const path = new URL(owner.url()).pathname; + await owner.goto(origin); await owner.getByRole('button', { name: '退出登录', exact: true }).click(); + await owner.getByRole('button', { name: '使用Passkey登录', exact: true }).waitFor(); await owner.getByRole('button', { name: '使用Passkey登录', exact: true }).click(); + await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor(); await owner.goto(origin + path); + await owner.getByRole('article').getByText('Passkey恢复后的私密内容', { exact: true }).waitFor(); + results.push('Passkey restores the vault and decrypts resources without entering the password'); + await owner.goto(origin); await owner.getByRole('button', { name: '撤销 测试恢复凭据', exact: true }).click(); await owner.getByText('Passkey已撤销', { exact: true }).waitFor(); + await owner.getByRole('button', { name: '退出登录', exact: true }).click(); + await owner.getByLabel('用户名', { exact: true }).fill(username); await owner.getByLabel('密码', { exact: true }).fill(password); + await owner.getByRole('button', { name: '登录并同步', exact: true }).click(); await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor(); + results.push('revocation removes the credential while password recovery remains available'); + return { passed: results }; + } catch (error) { + const inputs = await owner.locator('input').evaluateAll(inputs => inputs.map(input => ({ name: input.name, type: input.type, length: input.value.length, valid: input.validity.valid, message: input.validationMessage }))); + throw new Error(`Passkey smoke failed after ${results.length} checks: ${error.message}; body=${(await owner.locator('body').innerText()).slice(0, 800)}; inputs=${JSON.stringify(inputs)}; errors=${JSON.stringify(errors)}; calls=${JSON.stringify(calls)}`); + } finally { await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }).catch(() => {}); await context.close(); } +} diff --git a/web/AGENTS.md b/web/AGENTS.md index 6686932..33842bc 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -14,8 +14,7 @@ ## 范围 -- 只修改 `web/` 与前端相关文档(`docs/ui-design.md`、`docs/frontend.md`)。 -- **不修改** `server/`、`docs/protocol.md`、`docs/api.md`、`docs/openapi.yaml`、`docs/threat-model.md`、`docs/architecture.md`。需要变更时,写明诉求交给负责人。 +- 按用户要求修改项目文件,遵循对应目录的约定与测试要求。 - `@webdrop/core` 未就绪时,在 `src/core-bridge/` 按文档接口做适配与假实现,不自行发明协议。 ## 命令 diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 8fadfee..0653937 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -10,6 +10,7 @@ import { ShareScreen } from './ShareScreen'; import { channelPath, navigate, useRoute } from './navigation'; import { Splash } from './Splash'; import { AccountScreen } from '@/features/home/AccountScreen'; +import { AccountDock } from '@/features/home/AccountDock'; import { useAccount } from '@/store/account'; const SEEN_KEY = 'wd.splash'; @@ -80,6 +81,7 @@ export function App() { return ( <> {screen} + {username ? : null} {splash ? : null} ); diff --git a/web/src/core-bridge/account.ts b/web/src/core-bridge/account.ts index c8f5dc5..c0b618b 100644 --- a/web/src/core-bridge/account.ts +++ b/web/src/core-bridge/account.ts @@ -1,10 +1,12 @@ import { callWorker } from './workerClient'; +import type { AccountDeviceBody, InvitationBody } from './generated/api'; +import type { PersonalProfile } from './protocol/account'; const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; -export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: { displayName: string; avatar?: string; modifiedAt: number }; error?: string } -export interface AccountDevice { id: string; name: string; created_at: number; last_seen: number; current: boolean } -export interface AccountInvitation { channel_id: string; username: string; payload: string; created_at: number } +export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string } +export type AccountDevice = AccountDeviceBody; +export type AccountInvitation = InvitationBody; export const accountApi = { register: (username: string, password: string, remember = true) => callWorker('registerAccount', { username, password, remember, apiBase: API_BASE }), diff --git a/web/src/core-bridge/api.ts b/web/src/core-bridge/api.ts index 7807369..1237899 100644 --- a/web/src/core-bridge/api.ts +++ b/web/src/core-bridge/api.ts @@ -1,9 +1,9 @@ import type { ParsedChannelLink } from '@/lib/channelLink'; import type { AccountInvitation } from './account'; -import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types'; +import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types'; /** - * 常驻空间的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。 + * 常驻空间的 Worker 边界;业务结构来自后端,资源加解密由 @webdrop/core 处理。 * 便捷分享有自己的边界,见 shareApi.ts。 */ export interface WorkspaceApi { @@ -21,8 +21,6 @@ export interface WorkspaceApi { listSaved(): Promise; /** 仅从本机删除保存的链接和终端密钥,不会删服务端空间 */ forget(id: string): Promise; - /** 仅假实现提供:载入演示用的空间 */ - loadDemo?(): ChannelInfo[]; /** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */ connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void; @@ -33,6 +31,7 @@ export interface WorkspaceApi { renameGroup(id: string, name: string): Promise; groupThread(id: string, groupId?: string): Promise; renameThread(threadId: string, name: string): Promise; + setChannelHistory(threadId: string, chatHistory: boolean, filesHistory: boolean): Promise; createFolder(threadId: string, name: string, parentId?: string): Promise; renameFolder(folderId: string, threadId: string, name: string): Promise; moveItems(itemIds: string[], threadId: string, folderId?: string): Promise; @@ -46,6 +45,4 @@ export interface WorkspaceApi { listArchive(id: string): Promise; /** 标记「别的设备的修改」已看过(本地状态) */ markSeen(id: string): Promise; - /** 仅假实现提供,用于演示连接状态 */ - simulateConnection?(state: ConnectionState): void; } diff --git a/web/src/core-bridge/captcha.ts b/web/src/core-bridge/captcha.ts index df439ec..5cea3c7 100644 --- a/web/src/core-bridge/captcha.ts +++ b/web/src/core-bridge/captcha.ts @@ -1,18 +1,8 @@ -const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; - -interface ServerInfo { - captcha?: { enabled?: boolean; api_endpoint?: string }; -} +import { serverApi } from './server'; /** Cap performs challenge solving in its own workers and only returns a one-use token. */ export async function solveCreationCaptcha(): Promise { - const response = await fetch(`${API_BASE.replace(/\/$/, '')}/info`, { - cache: 'no-store', - credentials: 'omit', - referrerPolicy: 'no-referrer', - }); - if (!response.ok) throw new Error('无法读取服务端能力'); - const info = await response.json() as ServerInfo; + const info = await serverApi.info(); if (!info.captcha?.enabled) return undefined; if (typeof info.captcha.api_endpoint !== 'string') throw new Error('验证码服务配置无效'); diff --git a/web/src/core-bridge/channelAccess.ts b/web/src/core-bridge/channelAccess.ts index aa9e143..8a5f39a 100644 --- a/web/src/core-bridge/channelAccess.ts +++ b/web/src/core-bridge/channelAccess.ts @@ -1,8 +1,9 @@ import { callWorker } from './workerClient'; import type { ChannelInfo } from './types'; import type { ParsedChannelLink } from '@/lib/channelLink'; +import type { MemberBody } from './generated/api'; const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; -export interface ChannelMember { username: string; role: 'owner' | 'member'; joined_at: number } +export type ChannelMember = MemberBody; export const channelAccessApi = { members: (id: string) => callWorker('channelMembers', { id, apiBase }), preview: (link: ParsedChannelLink) => callWorker('previewChannel', { link, apiBase }), diff --git a/web/src/core-bridge/database.ts b/web/src/core-bridge/database.ts index ebb45f0..3f168ab 100644 --- a/web/src/core-bridge/database.ts +++ b/web/src/core-bridge/database.ts @@ -1,11 +1,16 @@ import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; import type { Item } from './types'; +import type { ChannelInfo as ChannelMetadata, ShareInfo as ShareMetadata, WorkspaceBody } from './generated/api'; export type ManifestEntry = | { t: 'file'; id: string; name: string; mime: string; size: number; ts: number } | { t: 'text'; localId: string; name?: string; text: string; ts: number }; export interface SavedResource { + domainKeys?: Record; + workspaceMetadata?: WorkspaceBody; + channelMetadata?: ChannelMetadata; + shareMetadata?: ShareMetadata; creatorAccount?: string; localScope?: string; modifiedAt?: number; @@ -26,7 +31,6 @@ export interface SavedResource { createdAt: number; expiresAt: number; seats: number; - membersCanUpload: boolean; state?: 'draft' | 'sealed'; maxVisitors?: number; maxDownloads?: number | null; @@ -55,6 +59,7 @@ interface DraftFile { } interface WebDropDb extends DBSchema { + locationOutbox: { key: string; value: QueuedLocation; indexes: { 'by-resource': string } }; resourceDeletes: { key: string; value: { id: string; modifiedAt: number } }; resources: { key: string; value: SavedResource }; outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string; 'by-resource-created': [string, number] } }; @@ -73,8 +78,9 @@ export function setResourceChangeListener(listener: (() => void) | undefined): v function db(): Promise> { let dbPromise = databases.get(resourceScope); - dbPromise ??= openDB(`webdrop-2-${resourceScope}`, 2, { + dbPromise ??= openDB(`webdrop-2-${resourceScope}`, 3, { upgrade(database, oldVersion, _newVersion, tx) { + if (oldVersion < 3) database.createObjectStore('locationOutbox', { keyPath: 'key' }).createIndex('by-resource', 'resourceId'); if (oldVersion < 1) { database.createObjectStore('resources', { keyPath: 'id' }); const outbox = database.createObjectStore('outbox', { keyPath: 'key' }); @@ -143,6 +149,19 @@ export async function getResource(id: string): Promise): Promise { + if (resource.localScope !== currentResourceScope()) throw new DOMException('账号已切换', 'AbortError'); + const database = await db(); + const tx = database.transaction('resources', 'readwrite'); + const current = await tx.store.get(resource.id); + if (!current) throw new Error('资源已移除'); + const updated = { ...current, ...patch }; + await tx.store.put(updated); + await tx.done; + return updated; +} + export async function listResources(kind: SavedResource['kind']): Promise { const values = await (await db()).getAll('resources'); return values.filter((resource) => resource.kind === kind).sort((a, b) => b.createdAt - a.createdAt); @@ -150,7 +169,9 @@ export async function listResources(kind: SavedResource['kind']): Promise { const database = await db(); - const tx = database.transaction(['resources', 'outbox', 'draftFiles', 'resourceDeletes'], 'readwrite'); + const tx = database.transaction(['resources', 'outbox', 'draftFiles', 'resourceDeletes', 'locationOutbox'], 'readwrite'); + const locationKeys = await tx.objectStore('locationOutbox').index('by-resource').getAllKeys(id); + await Promise.all(locationKeys.map(key => tx.objectStore('locationOutbox').delete(key))); await tx.objectStore('resources').delete(id); await tx.objectStore('resourceDeletes').put({ id, modifiedAt: Date.now() }); const keys = await tx.objectStore('outbox').index('by-resource').getAllKeys(id); @@ -213,4 +234,14 @@ export async function dequeueItem(resourceId: string, itemId: string): Promise frame.itemId === itemId).map((frame) => tx.store.delete(frame.key))); await tx.done; + await (await db()).delete('locationOutbox', `${resourceId}:${itemId}`); } + +export interface QueuedLocation { key: string; resourceId: string; itemId: string; threadId: string; folderId?: string } +export async function queueLocation(resourceId: string, itemId: string, threadId: string, folderId?: string): Promise { + await (await db()).put('locationOutbox', { key: `${resourceId}:${itemId}`, resourceId, itemId, threadId, folderId }); +} +export async function nextQueuedLocation(resourceId: string): Promise { + return (await (await db()).getAllFromIndex('locationOutbox', 'by-resource', resourceId, 1))[0]; +} +export async function dequeueLocation(key: string): Promise { await (await db()).delete('locationOutbox', key); } diff --git a/web/src/core-bridge/errors.ts b/web/src/core-bridge/errors.ts index 9789756..55653ac 100644 --- a/web/src/core-bridge/errors.ts +++ b/web/src/core-bridge/errors.ts @@ -1,6 +1,6 @@ const messages: Record = { not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。', - seats_full: '空间席位已满,请联系所有者。', not_joined: '你已被移出此空间。', + seats_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。', visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。', forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。', too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。', @@ -12,6 +12,7 @@ const messages: Record = { account_invalid: '用户名或密码错误,或登录已过期。', account_required: '请先登录账号。', invitation_required: '该空间仅允许受邀账号加入,请登录受邀账号或联系空间主。', channel_password_required: '请输入空间加入密码。', channel_password_invalid: '空间加入密码不正确。', + space_name_required: '请填写空间名称。', }; export function errorMessage(error: unknown): string { diff --git a/web/src/core-bridge/mock.ts b/web/src/core-bridge/mock.ts deleted file mode 100644 index 037845b..0000000 --- a/web/src/core-bridge/mock.ts +++ /dev/null @@ -1,277 +0,0 @@ -import type { ParsedChannelLink } from '@/lib/channelLink'; -import { previewKindOf } from '@/lib/fileKind'; -import type { WorkspaceApi } from './api'; -import { b64url } from './mockLinks'; -import { PHONE, SELF, archiveEntries, channel as demoChannel, devices as demoDevices, makeSvgBlob, makeVideoBlob, seedItems, snapshot as demoSnapshot } from './mockSeed'; -import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, FileUploadOptions, Item, Snapshot, WorkspaceEvent } from './types'; - -const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -/** protocol §2:链接形态。enc 带 k,raw 不带 */ -function buildLinks(id: string, o: CreateOptions): Pick { - const base = `${location.origin}/c/${id}`; - const a = b64url(32); - if (o.mode === 'enc') { - const k = b64url(32); - return { memberLink: `${base}#k=${k}`, ownerLink: `${base}#k=${k}&a=${a}` }; - } - const w = o.membersCanUpload ? b64url(32) : null; - return { memberLink: w ? `${base}#w=${w}` : base, ownerLink: `${base}#a=${a}${w ? `&w=${w}` : ''}` }; -} - -interface Runtime { - info: ChannelInfo; - items: Map; - blobs: Map>; - devices: Device[]; - snapshot: Snapshot; - demo: boolean; -} - -const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', self: true, joinedAt: Date.now() }; - -/** 空间的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */ -export function createMockApi(): WorkspaceApi { - const runtimes = new Map(); - const timers = new Set(); - let current: Runtime | undefined; - let emit: (e: WorkspaceEvent) => void = () => {}; - - const later = (ms: number, fn: () => void) => { - const id = window.setTimeout(() => { - timers.delete(id); - fn(); - }, ms); - timers.add(id); - }; - const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`; - const put = (item: Item) => { - current?.items.set(item.id, item); - emit({ t: 'item', item }); - }; - - function newRuntime(info: ChannelInfo, devices: Device[]): Runtime { - const rt: Runtime = { info, items: new Map(), blobs: new Map(), devices, snapshot: { text: '', updatedBy: '', updatedAt: 0 }, demo: false }; - runtimes.set(info.id, rt); - return rt; - } - - function seedDemo(): Runtime { - const rt = newRuntime(demoChannel, demoDevices); - rt.demo = true; - rt.snapshot = demoSnapshot; - for (const item of seedItems().items) rt.items.set(item.id, item); - rt.blobs.set('it-image', makeSvgBlob()); - rt.blobs.set('it-video', makeVideoBlob().then((b) => b ?? Promise.reject(new Error('unsupported')))); - rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' })); - rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' })); - return rt; - } - - /** 演示空间里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */ - function runDemoActivity(rt: Runtime) { - later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' })); - later(14000, () => { - const cur = rt.items.get('it-image'); - if (cur?.kind !== 'file') return; - const prev = { id: `v${(cur.versions?.length ?? 0) + 1}`, ts: cur.ts, deviceId: cur.deviceId, size: cur.size }; - put({ ...cur, ts: Date.now(), deviceId: PHONE, size: cur.size + 140, versions: [prev, ...(cur.versions ?? [])], unseen: true }); - }); - later(5000, () => emit({ t: 'devices', devices: rt.devices.map((d) => (d.id === PHONE ? { ...d, editing: true } : d)) })); - later(11000, () => emit({ t: 'devices', devices: rt.devices })); - } - - return { - async inviteUser() {}, - async listInvitations() { return []; }, - async revokeInvitation() {}, - async listSaved() { return []; }, - async forget() {}, - async createChannel(opts) { - await wait(300); - const id = b64url(16); - const createdAt = Date.now(); - const info: ChannelInfo = { id, name: opts.name?.trim() || undefined, mode: opts.mode, role: 'owner', seats: opts.seats, seatsUsed: 1, membersCanUpload: opts.membersCanUpload, maxDownloads: opts.fileMaxDownloads ?? 10, ttlSeconds: opts.ttlSeconds, createdAt, expiresAt: createdAt + opts.ttlSeconds * 1000, ...buildLinks(id, opts) }; - newRuntime(info, [selfDevice]); - return info; - }, - - async joinChannel(link: ParsedChannelLink) { - await wait(500); - const known = runtimes.get(link.id); - if (known) return { ...known.info, role: 'member', ownerLink: undefined }; - const base = `${location.origin}/c/${link.id}`; - const frag = (keys: Record) => - Object.entries(keys).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join('&'); - const member = frag({ k: link.keys.k, w: link.keys.w }); - const info: ChannelInfo = { - id: link.id, - mode: link.mode, - role: link.isOwner ? 'owner' : 'member', - seats: 3, - seatsUsed: 1, - membersCanUpload: true, - maxDownloads: 10, - ttlSeconds: 86400, - createdAt: Date.now(), - expiresAt: Date.now() + 86400_000, - memberLink: member ? `${base}#${member}` : base, - ownerLink: link.isOwner ? `${base}#${frag(link.keys)}` : undefined, - }; - newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', joinedAt: Date.now() }]); - return info; - }, - - loadDemo() { - return [(runtimes.get(demoChannel.id) ?? seedDemo()).info]; - }, - - connect(channelId, onEvent) { - emit = onEvent; - current = runtimes.get(channelId); - const rt = current; - if (!rt) return () => {}; - later(500, () => { - emit({ t: 'channel', info: rt.info }); - emit({ t: 'devices', devices: rt.devices }); - emit({ t: 'snapshot', snapshot: rt.snapshot }); - for (const item of rt.items.values()) emit({ t: 'item', item }); - emit({ t: 'connection', state: 'online' }); - emit({ t: 'ready' }); - }); - if (rt.demo) runDemoActivity(rt); - return () => { - for (const id of timers) clearTimeout(id); - timers.clear(); - emit = () => {}; - }; - }, - - async sendText(text, threadId) { - put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text, ...(threadId ? { threadId } : {}) }); - }, - - async updateChannelSettings(patch) { - if (!current || current.info.role !== 'owner') throw new Error('只有所有者可以修改空间设置'); - current.info = { - ...current.info, - ...(patch.name === undefined ? {} : { name: patch.name.trim() || undefined }), - ...(patch.seats === undefined ? {} : { seats: patch.seats }), - ...(patch.membersCanUpload === undefined ? {} : { membersCanUpload: patch.membersCanUpload, canUpload: current.info.role === 'owner' || patch.membersCanUpload }), - ...(patch.ttlSeconds === undefined ? {} : { expiresAt: Date.now() + patch.ttlSeconds * 1000 }), - ...(patch.fileMaxDownloads === undefined ? {} : { maxDownloads: patch.fileMaxDownloads }), - }; - emit({ t: 'channel', info: current.info }); - }, - - async renameDevice(endpointHash, name) { - if (!current) throw new Error('空间未打开'); - const device = current.devices.find((entry) => entry.id === endpointHash); - if (!device) throw new Error('设备不存在'); - if (current.info.role !== 'owner' && !device.self) throw new Error('只有所有者可以重命名其他设备'); - current.devices = current.devices.map((entry) => entry.id === endpointHash ? { ...entry, name: name.trim() } : entry); - emit({ t: 'devices', devices: current.devices }); - }, - - async releaseDevice(endpointHash) { - if (!current || current.info.role !== 'owner') throw new Error('只有所有者可以移除设备'); - current.devices = current.devices.filter((entry) => entry.id !== endpointHash); - current.info = { ...current.info, seatsUsed: current.devices.length }; - emit({ t: 'devices', devices: current.devices }); - emit({ t: 'channel', info: current.info }); - }, - - async sendFiles(files, options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId, folderId) { - for (const file of files) { - const id = uid(); - const base: FileItem = { - id, kind: 'file', ts: Date.now(), deviceId: SELF, state: { kind: 'receiving', progress: 0 }, - ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), - name: file.name, mime: file.type, size: file.size, remaining: options.directPreview || options.maxDownloads === 0 ? null : options.maxDownloads, - gated: !options.directPreview, - preview: previewKindOf(file.type, file.name), - }; - current?.blobs.set(id, file); - put(base); - for (let p = 0.2; p < 1; p += 0.2) { - await wait(250); - put({ ...base, state: { kind: 'receiving', progress: p } }); - } - await wait(250); - put({ ...base, state: { kind: 'complete' } }); - } - }, - - async createThread(name, groupId) { - const id = crypto.randomUUID(); - emit({ t: 'thread-created', thread: { id, name: name.trim(), createdAt: Date.now(), createdBy: SELF, ...(groupId ? { groupId } : {}) } }); - return id; - }, - - async renameThread(threadId, name) { emit({ t: 'thread-renamed', id: threadId, name: name.trim() }); }, - async createGroup(name) { const id = crypto.randomUUID(); emit({ t: 'group-created', group: { id, name: name.trim(), createdAt: Date.now() } }); return id; }, - async renameGroup(id, name) { emit({ t: 'group-renamed', id, name: name.trim() }); }, - async groupThread(id, groupId) { emit({ t: 'thread-grouped', id, ...(groupId ? { groupId } : {}) }); }, - - async createFolder(threadId, name, parentId) { - emit({ t: 'folder-created', folder: { id: crypto.randomUUID(), threadId, ...(parentId ? { parentId } : {}), name: name.trim() } }); - }, - - async renameFolder(folderId, _threadId, name) { emit({ t: 'folder-renamed', id: folderId, name: name.trim() }); }, - - async moveItems(itemIds, threadId, folderId) { - for (const id of itemIds) { - const item = current?.items.get(id); - if (item) put({ ...item, threadId, folderId }); - } - emit({ t: 'items-moved', itemIds, threadId, ...(folderId ? { folderId } : {}) }); - }, - - async editText(id, text) { - const cur = current?.items.get(id); - if (cur?.kind !== 'text') return; - put({ ...cur, text, editedAt: Date.now() }); - }, - - async resolveConflict(id, choice) { - const cur = current?.items.get(id); - if (cur?.kind !== 'text' || !cur.conflict) return; - put({ ...cur, text: choice === 'theirs' ? cur.conflict.text : cur.text, conflict: undefined }); - }, - - async remove(id) { - current?.items.delete(id); - current?.blobs.delete(id); - emit({ t: 'removed', id }); - }, - - async setSnapshot(text) { - const snapshot = { text, updatedBy: SELF, updatedAt: Date.now() }; - if (current) current.snapshot = snapshot; - emit({ t: 'snapshot', snapshot }); - }, - - async getBlob(id) { - const b = current?.blobs.get(id); - if (b) return b; - const item = current?.items.get(id); - if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' }); - throw new Error('not found'); - }, - - async markSeen(id) { - const cur = current?.items.get(id); - if (cur?.kind !== 'file' || !cur.unseen) return; - put({ ...cur, unseen: false }); - }, - - async listArchive() { - await wait(250); - return archiveEntries; - }, - - simulateConnection(state: ConnectionState) { - emit({ t: 'connection', state }); - }, - }; -} diff --git a/web/src/core-bridge/mockLinks.ts b/web/src/core-bridge/mockLinks.ts deleted file mode 100644 index 72505af..0000000 --- a/web/src/core-bridge/mockLinks.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** 假实现用的随机 id 与密钥:n 字节 → base64url 无填充(16 字节 22 字符,32 字节 43 字符) */ -export function b64url(bytes: number): string { - const b = crypto.getRandomValues(new Uint8Array(bytes)); - return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} diff --git a/web/src/core-bridge/mockSeed.ts b/web/src/core-bridge/mockSeed.ts deleted file mode 100644 index 7dc8038..0000000 --- a/web/src/core-bridge/mockSeed.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { ArchiveEntry, ChannelInfo, Device, Item, Snapshot } from './types'; - -/** 仅供假实现使用的演示数据与演示用文件内容 */ - -export const SELF = 'self'; -export const PHONE = 'phone'; -export const DEMO_ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n'; - -const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF'; - -export const devices: Device[] = [ - { id: SELF, name: '本机 · Chrome', kind: 'desktop', self: true, joinedAt: Date.now() }, - { id: PHONE, name: 'iPhone', kind: 'phone', joinedAt: Date.now() - 3600_000 }, - { id: 'tablet', name: 'iPad', kind: 'tablet', joinedAt: Date.now() - 3 * 3600_000 }, -]; - -export const channel: ChannelInfo = { - id: DEMO_ID, - mode: 'enc', - role: 'owner', - seats: 3, - seatsUsed: 3, - createdAt: Date.now() - 3 * 3600_000, - expiresAt: Date.now() + 21 * 3600_000, - memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`, - ownerLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}&a=${KEY}`, -}; - -export const snapshot: Snapshot = { - text: '待办\n- 取快递\n- 发周报\n- 备份相册', - updatedBy: PHONE, - updatedAt: Date.now() - 20 * 60_000, -}; - -export const archiveEntries: ArchiveEntry[] = [ - { path: 'project-assets/', size: 0, dir: true }, - { path: 'project-assets/README.md', size: 2_140, dir: false }, - { path: 'project-assets/design/', size: 0, dir: true }, - { path: 'project-assets/design/logo.svg', size: 8_420, dir: false }, - { path: 'project-assets/design/cover.png', size: 3_281_990, dir: false }, - { path: 'project-assets/design/mockup.fig', size: 18_204_113, dir: false }, - { path: 'project-assets/fonts/', size: 0, dir: true }, - { path: 'project-assets/fonts/SourceHanSans-Regular.otf', size: 16_532_204, dir: false }, - { path: 'project-assets/data/', size: 0, dir: true }, - { path: 'project-assets/data/export.csv', size: 412_873, dir: false }, - { path: 'project-assets/notes.txt', size: 1_822, dir: false }, -]; - -const ago = (min: number) => Date.now() - min * 60_000; - -export function seedItems(): { items: Item[]; sizeOverride: Record } { - const logText = Array.from({ length: 180 }, (_, i) => { - const level = i % 17 === 0 ? 'WARN' : 'INFO'; - return `2026-10-01 14:${String(i % 60).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')} ${level} worker-${i % 4} handled request #${1000 + i} in ${20 + ((i * 13) % 80)}ms`; - }).join('\n'); - - const items: Item[] = [ - { - id: 'it-video', kind: 'file', ts: ago(3), deviceId: PHONE, state: { kind: 'complete' }, - name: '屏幕录制.webm', mime: 'video/webm', size: 1_840_000, remaining: null, preview: 'video', - }, - { - id: 'it-zip', kind: 'file', ts: ago(7), deviceId: PHONE, state: { kind: 'complete' }, - name: 'project-assets.zip', mime: 'application/zip', size: 48_200_000, remaining: 8, preview: 'archive', - }, - { - id: 'it-image', kind: 'file', ts: ago(12), deviceId: PHONE, state: { kind: 'complete' }, - name: '设计稿.svg', mime: 'image/svg+xml', size: 6_420, remaining: null, preview: 'image', - versions: [ - { id: 'v2', ts: ago(55), deviceId: SELF, size: 6_110 }, - { id: 'v1', ts: ago(130), deviceId: SELF, size: 5_820 }, - ], - unseen: true, - }, - { - id: 'it-log', kind: 'text', ts: ago(18), deviceId: SELF, state: { kind: 'complete' }, - text: logText, - }, - { - id: 'it-conflict', kind: 'text', ts: ago(26), deviceId: SELF, state: { kind: 'complete' }, - text: '会议室 B204', editedAt: ago(25), conflict: { deviceId: PHONE, text: '会议室 B305' }, - }, - { - id: 'it-json', kind: 'text', ts: ago(30), deviceId: PHONE, state: { kind: 'complete' }, - text: '{"name":"webdrop","version":1,"tags":["share","e2ee"],"limits":{"seats":3,"ttl":86400}}', - }, - { - id: 'it-short', kind: 'text', ts: ago(41), deviceId: PHONE, state: { kind: 'complete' }, - text: '明天 10 点开会,带电脑', - }, - { - id: 'it-pdf', kind: 'file', ts: ago(95), deviceId: SELF, state: { kind: 'complete' }, - name: 'invoice-2026-09.pdf', mime: 'application/pdf', size: 1_240_000, remaining: null, preview: 'none', - }, - ]; - return { items, sizeOverride: {} }; -} - -export function makeSvgBlob(): Blob { - const svg = ` - - - -设计稿 · 演示图片`; - return new Blob([svg], { type: 'image/svg+xml' }); -} - -/** 用 canvas + MediaRecorder 临时生成一段几秒的演示视频;浏览器不支持则返回 null */ -export async function makeVideoBlob(): Promise { - try { - if (typeof MediaRecorder === 'undefined') return null; - const canvas = document.createElement('canvas'); - canvas.width = 480; - canvas.height = 270; - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - const rec = new MediaRecorder(canvas.captureStream(24), { mimeType: 'video/webm' }); - const chunks: Blob[] = []; - rec.ondataavailable = (e) => chunks.push(e.data); - const done = new Promise((res) => { - rec.onstop = () => res(new Blob(chunks, { type: 'video/webm' })); - }); - rec.start(); - const t0 = performance.now(); - await new Promise((resolve) => { - const draw = () => { - const t = (performance.now() - t0) / 1000; - ctx.fillStyle = `hsl(${(t * 60) % 360} 10% ${30 + 10 * Math.sin(t * 2)}%)`; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = '#fff'; - ctx.font = '28px sans-serif'; - ctx.fillText(`演示视频 ${t.toFixed(1)}s`, 150, 140); - if (t < 3) requestAnimationFrame(draw); - else resolve(); - }; - draw(); - }); - rec.stop(); - return await done; - } catch { - return null; - } -} - -export function makeLogBlob(text: string): Blob { - return new Blob([text], { type: 'text/plain' }); -} diff --git a/web/src/core-bridge/mockShare.ts b/web/src/core-bridge/mockShare.ts deleted file mode 100644 index 784dda5..0000000 --- a/web/src/core-bridge/mockShare.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { previewKindOf } from '@/lib/fileKind'; -import type { ShareApi } from './shareApi'; -import { b64url } from './mockLinks'; -import { PHONE, archiveEntries, makeSvgBlob, seedItems } from './mockSeed'; -import type { FileItem, Item, Share, ShareEvent, ShareSettings } from './types'; - -const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); -const CREATOR = 'creator'; -export const DEMO_SHARE_ID = 'ShareDemoShareDemoAbcd'; -const DEMO_KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF'; - -interface Runtime { - share: Share; - items: Map; - blobs: Map>; -} - -/** 便捷分享的假实现:不联网,数据都在内存里,刷新即丢。接口见 shareApi.ts */ -export function createMockShareApi(): ShareApi { - const runtimes = new Map(); - let current: Runtime | undefined; - let emit: (e: ShareEvent) => void = () => {}; - const timers = new Set(); - - const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`; - const put = (item: Item) => { - current?.items.set(item.id, item); - emit({ t: 'item', item }); - }; - const draftOnly = () => { - if (!current || current.share.state !== 'draft') throw new Error('forbidden'); - return current; - }; - - function seedDemo(): Runtime { - const share: Share = { - id: DEMO_SHARE_ID, - state: 'sealed', - mine: false, - link: `${location.origin}/s/${DEMO_SHARE_ID}#k=${DEMO_KEY}`, - createdAt: Date.now() - 2 * 3600_000, - expiresAt: Date.now() + 22 * 3600_000, - ttlSeconds: 86400, - maxVisitors: 10, - maxDownloads: 5, - }; - const rt: Runtime = { share, items: new Map(), blobs: new Map() }; - const keep = new Set(['it-zip', 'it-image', 'it-json', 'it-short', 'it-pdf']); - for (const item of seedItems().items) { - if (!keep.has(item.id)) continue; - rt.items.set(item.id, item.kind === 'file' - ? { ...item, deviceId: CREATOR, versions: undefined, unseen: false, remaining: share.maxDownloads } - : { ...item, deviceId: CREATOR }); - } - rt.blobs.set('it-image', makeSvgBlob()); - rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' })); - rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' })); - runtimes.set(share.id, rt); - return rt; - } - - return { - async listSaved() { return []; }, - async forget() {}, - async createDraft(settings) { - await wait(300); - const id = b64url(16); - const share: Share = { - id, - state: 'draft', - mine: true, - link: `${location.origin}/s/${id}#k=${b64url(32)}`, - createdAt: Date.now(), - expiresAt: Date.now() + 86400_000, - ...settings, - }; - runtimes.set(id, { share, items: new Map(), blobs: new Map() }); - return share; - }, - - async updateSettings(id, patch: Partial) { - const rt = runtimes.get(id); - if (!rt || rt.share.state !== 'draft') throw new Error('forbidden'); - rt.share = { ...rt.share, ...patch }; - emit({ t: 'share', share: rt.share }); - return rt.share; - }, - - async seal(id) { - await wait(300); - const rt = runtimes.get(id); - if (!rt || rt.share.state !== 'draft') throw new Error('forbidden'); - // 丢弃所有者密钥:此后没有人能改,创建者也只是一个读者 - rt.share = { ...rt.share, state: 'sealed' }; - for (const item of rt.items.values()) { - if (item.kind === 'file') rt.items.set(item.id, { ...item, remaining: rt.share.maxDownloads }); - } - emit({ t: 'share', share: rt.share }); - for (const item of rt.items.values()) emit({ t: 'item', item }); - return rt.share; - }, - - async join(link) { - await wait(300); - const known = runtimes.get(link.id); - if (known) return known.share; - // 原型里不认识的链接:当作一个已封存、没有内容的分享 - const share: Share = { - id: link.id, - state: 'sealed', - mine: false, - link: `${location.origin}/s/${link.id}#k=${link.key}`, - createdAt: Date.now(), - expiresAt: Date.now() + 86400_000, - ttlSeconds: 86400, - maxVisitors: 10, - maxDownloads: null, - }; - runtimes.set(link.id, { share, items: new Map(), blobs: new Map() }); - return share; - }, - - loadDemo() { - return [(runtimes.get(DEMO_SHARE_ID) ?? seedDemo()).share]; - }, - - connect(shareId, onEvent) { - emit = onEvent; - current = runtimes.get(shareId); - const rt = current; - if (!rt) return () => {}; - const id = window.setTimeout(() => { - timers.delete(id); - emit({ t: 'share', share: rt.share }); - for (const item of rt.items.values()) emit({ t: 'item', item }); - emit({ t: 'ready' }); - }, 400); - timers.add(id); - return () => { - for (const t of timers) clearTimeout(t); - timers.clear(); - emit = () => {}; - }; - }, - - async addText(text) { - draftOnly(); - put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text }); - }, - - async editText(id, text) { - const rt = draftOnly(); - const item = rt.items.get(id); - if (!item || item.kind !== 'text') throw new Error('not_found'); - put({ ...item, text, ts: Date.now() }); - }, - - async addFiles(files) { - const rt = draftOnly(); - for (const file of files) { - const id = uid(); - const base: FileItem = { - id, kind: 'file', ts: Date.now(), deviceId: PHONE, state: { kind: 'receiving', progress: 0 }, - name: file.name, mime: file.type, size: file.size, remaining: null, - preview: previewKindOf(file.type, file.name), - }; - rt.blobs.set(id, file); - put(base); - for (let p = 0.25; p < 1; p += 0.25) { - await wait(200); - put({ ...base, state: { kind: 'receiving', progress: p } }); - } - await wait(200); - put({ ...base, state: { kind: 'complete' } }); - } - }, - - async removeItem(id) { - const rt = draftOnly(); - rt.items.delete(id); - rt.blobs.delete(id); - emit({ t: 'removed', id }); - }, - - async getBlob(id) { - const item = current?.items.get(id); - // 封存后每次拉取消耗一次下载(GATED,api.md);用完即 410 - if (item?.kind === 'file' && current?.share.state === 'sealed' && item.remaining !== null) { - if (item.remaining <= 0) throw new Error('gone'); - put({ ...item, remaining: item.remaining - 1 }); - } - const b = current?.blobs.get(id); - if (b) return b; - if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' }); - throw new Error('not found'); - }, - - async listArchive() { - await wait(250); - return archiveEntries; - }, - }; -} diff --git a/web/src/core-bridge/passkeys.ts b/web/src/core-bridge/passkeys.ts new file mode 100644 index 0000000..aba29d7 --- /dev/null +++ b/web/src/core-bridge/passkeys.ts @@ -0,0 +1,58 @@ +import { callWorker } from './workerClient'; +import type { AccountStatus } from './account'; +import type { PasskeyOptionsBody, PasskeyInfoBody } from './generated/api'; + +const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; +const call = (operation: string, payload: object = {}) => callWorker(operation, { ...payload, apiBase }); +export const passkeysAvailable = () => typeof PublicKeyCredential !== 'undefined' && window.isSecureContext; +const decode = (value: string): Uint8Array => Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=')), c => c.charCodeAt(0)); +interface PrfResult { enabled?: boolean; results?: { first?: ArrayBuffer } } +const prfResult = (credential: PublicKeyCredential) => (credential.getClientExtensionResults() as AuthenticationExtensionsClientOutputs & { prf?: PrfResult }).prf; +function credentialJson(credential: PublicKeyCredential): string { + // W3C PRF: omit client extension outputs when sending credentials to the server. + return JSON.stringify({ ...credential.toJSON(), clientExtensionResults: {} }); +} +async function assertion(options: PasskeyOptionsBody, credentialId?: string): Promise { + const json = JSON.parse(options.options) as PublicKeyCredentialRequestOptionsJSON; + const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json); + if (credentialId) publicKey.allowCredentials = [{ type: 'public-key', id: decode(credentialId) }]; + const evalByCredential = Object.fromEntries(options.credentials.filter(p => !credentialId || p.id === credentialId).map(p => [p.id, { first: decode(p.prf_salt) }])); + publicKey.extensions = { ...publicKey.extensions, prf: { evalByCredential } } as AuthenticationExtensionsClientInputs; + const credential = await navigator.credentials.get({ publicKey }); + if (!(credential instanceof PublicKeyCredential)) throw new Error('Passkey验证未完成'); + return credential; +} +export async function bindPasskey(password: string, name: string): Promise { + if (!passkeysAvailable()) throw new Error('当前浏览器不支持Passkey,请使用HTTPS或localhost'); + let prf: ArrayBuffer | undefined; + try { + const options = await call('beginPasskeyRegistration', { password }); + const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(JSON.parse(options.options)); + publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: decode(options.prf_salt!) } } } as AuthenticationExtensionsClientInputs; + const credential = await navigator.credentials.create({ publicKey }); + if (!(credential instanceof PublicKeyCredential)) throw new Error('Passkey绑定未完成'); + const extension = prfResult(credential); prf = extension?.results?.first; + const registered = await call('finishPasskeyRegistration', { id: options.id, credentialId: credential.id, credential: credentialJson(credential), name, prf }); + if (!registered.can_recover && extension?.enabled) { + const request = await call('beginPasskeyRecovery'); + const proof = await assertion(request, registered.id); prf = prfResult(proof)?.results?.first; + if (prf) await call('finishPasskeyRecovery', { id: request.id, credentialId: proof.id, credential: credentialJson(proof), prf }); + } + } finally { + if (prf) new Uint8Array(prf).fill(0); + await call('cancelPasskey').catch(() => {}); + } +} +export async function loginWithPasskey(username: string, remember: boolean): Promise { + if (!passkeysAvailable()) throw new Error('当前浏览器不支持Passkey,请使用HTTPS或localhost'); + const options = await call('beginPasskeyLogin', { username }); + const proof = await assertion(options); const prf = prfResult(proof)?.results?.first; + try { return await call('finishPasskeyLogin', { username, id: options.id, credential: credentialJson(proof), prf, remember }); } + finally { if (prf) new Uint8Array(prf).fill(0); } +} +export const passkeyApi = { + list: () => call('listPasskeys'), + remove: (id: string) => call('removePasskey', { id }), + unlock: (password: string, remember: boolean) => call('unlockPasskeyLogin', { password, remember }), + cancel: () => call('cancelPasskey'), +}; diff --git a/web/src/core-bridge/protocol/account.ts b/web/src/core-bridge/protocol/account.ts new file mode 100644 index 0000000..726f7d9 --- /dev/null +++ b/web/src/core-bridge/protocol/account.ts @@ -0,0 +1,27 @@ +import type { AccountProfile } from '../generated/api'; +export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } +export interface SigningKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } +/** Account vault v2: credentials and cached metadata, encrypted before transport. */ +export type PersonalProfile = Pick & { avatar?: string }; +export interface ResourceDelete { id: string; modifiedAt: number } +export interface VaultResource { + domainKeys?: Record; + id: string; + kind: 'channel' | 'share'; + mode: 'enc' | 'raw'; + role: 'owner' | 'member'; + link: string; + ownerLink?: string; + modifiedAt?: number; + createdAt: number; + expiresAt: number; + creatorAccount?: string; + name?: string; + seats: number; + ttlSeconds?: number; + maxDownloads?: number | null; + maxVisitors?: number; + mine?: boolean; + state?: 'draft' | 'sealed'; +} +export interface VaultProfile { v: 3; keys: InvitationKeyPair & { signing: SigningKeyPair }; resources: VaultResource[]; deletes: ResourceDelete[] } diff --git a/web/src/core-bridge/protocol/workspace.ts b/web/src/core-bridge/protocol/workspace.ts new file mode 100644 index 0000000..f8818da --- /dev/null +++ b/web/src/core-bridge/protocol/workspace.ts @@ -0,0 +1,15 @@ +/** Encrypted resource metadata v1. Account and structure DTOs are generated from the backend. */ +export interface PublishedFile { + id: string; + kind: 'file'; + threadId: string; + folderId?: string; + name: string; + mime: string; + size: number; + ts: number; + deviceId: string; + gated: boolean; +} +export type ResourceControl = { op: 'file.published'; item: PublishedFile }; +export const WORKSPACE_APP = 'webdrop.workspace'; diff --git a/web/src/core-bridge/real.ts b/web/src/core-bridge/real.ts index 8bb6ff5..ac294fc 100644 --- a/web/src/core-bridge/real.ts +++ b/web/src/core-bridge/real.ts @@ -4,7 +4,7 @@ import type { ParsedShareLink } from '@/lib/shareLink'; import type { WorkspaceApi } from './api'; import type { ShareApi } from './shareApi'; import { callWorker, subscribeWorker } from './workerClient'; -import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types'; +import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types'; const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; @@ -44,6 +44,7 @@ export const realApi: WorkspaceApi = { renameGroup: (groupId, name) => callWorker('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }), groupThread: (threadId, groupId) => callWorker('groupThread', { id: currentChannelId(), threadId, groupId, apiBase: API_BASE }), renameThread: (threadId, name) => callWorker('renameThread', { id: currentChannelId(), threadId, name, apiBase: API_BASE }), + setChannelHistory: (threadId, chatHistory, filesHistory) => callWorker('setChannelHistory', { id: currentChannelId(), threadId, chatHistory, filesHistory, apiBase: API_BASE }), createFolder: (threadId, name, parentId) => callWorker('createFolder', { id: currentChannelId(), threadId, name, parentId, apiBase: API_BASE }), renameFolder: (folderId, threadId, name) => callWorker('renameFolder', { id: currentChannelId(), folderId, threadId, name, apiBase: API_BASE }), moveItems: (itemIds, threadId, folderId) => callWorker('moveItems', { id: currentChannelId(), itemIds, threadId, folderId, apiBase: API_BASE }), @@ -96,5 +97,3 @@ function currentChannelId(): string { function currentShareId(): string { return activeShareId; } - -export function simulateConnection(_state: ConnectionState): void {} diff --git a/web/src/core-bridge/server.ts b/web/src/core-bridge/server.ts new file mode 100644 index 0000000..30c6f16 --- /dev/null +++ b/web/src/core-bridge/server.ts @@ -0,0 +1,5 @@ +import { callWorker } from './workerClient'; +import type { ServerInfoBody } from './generated/api'; + +const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; +export const serverApi = { info: () => callWorker('serverInfo', { apiBase }) }; diff --git a/web/src/core-bridge/settings.ts b/web/src/core-bridge/settings.ts new file mode 100644 index 0000000..ed7391a --- /dev/null +++ b/web/src/core-bridge/settings.ts @@ -0,0 +1,8 @@ +import type { DefaultsBody } from './generated/api'; +import type { CreateOptions } from './types'; + +export function creationOptions(defaults: DefaultsBody): CreateOptions { + const settings = defaults.channel; + return { mode: defaults.channel_mode, seats: settings.seats, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload, + fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests }; +} diff --git a/web/src/core-bridge/shareApi.ts b/web/src/core-bridge/shareApi.ts index 001e364..e92589b 100644 --- a/web/src/core-bridge/shareApi.ts +++ b/web/src/core-bridge/shareApi.ts @@ -20,8 +20,6 @@ export interface ShareApi { join(link: ParsedShareLink): Promise; /** 仅从本机移除链接和终端密钥,不会删除已封存的远端分享 */ forget(id: string): Promise; - /** 仅假实现提供:载入演示用的分享 */ - loadDemo?(): Share[]; /** 订阅某个分享的事件流,返回取消订阅函数 */ connect(shareId: string, onEvent: (e: ShareEvent) => void): () => void; diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts index 2fb6766..0efddf7 100644 --- a/web/src/core-bridge/types.ts +++ b/web/src/core-bridge/types.ts @@ -1,3 +1,5 @@ +import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceFolder, WorkspaceState, AccountProfile } from './generated/api'; + export type DeviceKind = 'desktop' | 'phone' | 'tablet'; export type ConnectionState = 'online' | 'reconnecting' | 'offline'; export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none'; @@ -14,6 +16,7 @@ export interface Device { /** 常驻空间:自己多台设备之间长期同步 */ export interface ChannelInfo { + dataSource?: 'server' | 'cache'; creatorAccount?: string; joinPolicy?: 'open' | 'invite'; allowGuests?: boolean; @@ -21,10 +24,10 @@ export interface ChannelInfo { id: string; /** 服务端 channel settings 中的共享名称,加入者可见。 */ name?: string; - mode: 'enc' | 'raw'; + mode: ChannelMode; role: 'owner' | 'member'; seats: number; - seatsUsed?: number; + membersUsed?: number; membersCanUpload?: boolean; ttlSeconds?: number; /** 每个新上传文件的默认下载次数上限。 */ @@ -35,6 +38,7 @@ export interface ChannelInfo { createdAt: number; expiresAt: number; canUpload?: boolean; + canManageStructure?: boolean; } /** 新建空间的选项(protocol §12 设置的子集) */ @@ -43,7 +47,7 @@ export interface CreateOptions { allowGuests?: boolean; joinPassword?: string; name?: string; - mode: 'enc' | 'raw'; + mode: ChannelMode; seats: number; ttlSeconds: number; membersCanUpload: boolean; @@ -72,6 +76,7 @@ export interface ChannelSettingsPatch { * 完成分享时丢弃,此后无人可改,只能等过期。始终是加密模式。 */ export interface Share { + dataSource?: 'server' | 'cache'; id: string; /** draft:创建者可编辑;sealed:已封存,只读 */ state: 'draft' | 'sealed'; @@ -112,7 +117,7 @@ interface ItemBase { folderId?: string; } -export interface SenderInfo { username: string; displayName: string; avatar?: string } +export type SenderInfo = Pick & { avatar?: string }; export interface TextItem extends ItemBase { kind: 'text'; @@ -165,11 +170,12 @@ export interface Snapshot { updatedAt: number; } -export interface ThreadInfo { id: string; name: string; createdAt: number; createdBy: string; groupId?: string } -export interface ChannelGroupInfo { id: string; name: string; createdAt: number } -export interface FolderInfo { id: string; threadId: string; parentId?: string; name: string } +export type ThreadInfo = Omit & { groupId?: string }; +export type ChannelGroupInfo = WorkspaceGroup; +export type FolderInfo = Omit & { parentId?: string }; export type WorkspaceEvent = + | { t: 'structure'; state: WorkspaceState; people: AccountProfile[] } | { t: 'reset' } | { t: 'error'; message: string } | { t: 'ready' } @@ -178,16 +184,7 @@ export type WorkspaceEvent = | { t: 'devices'; devices: Device[] } | { t: 'item'; item: Item } | { t: 'file-catalog'; item: FileItem } - | { t: 'item-author'; id: string; author: SenderInfo } | { t: 'removed'; id: string } - | { t: 'thread-created'; thread: ThreadInfo } - | { t: 'thread-renamed'; id: string; name: string } - | { t: 'group-created'; group: ChannelGroupInfo } - | { t: 'group-renamed'; id: string; name: string } - | { t: 'thread-grouped'; id: string; groupId?: string } - | { t: 'folder-created'; folder: FolderInfo } - | { t: 'folder-renamed'; id: string; name: string } - | { t: 'items-moved'; itemIds: string[]; threadId: string; folderId?: string } | { t: 'snapshot'; snapshot: Snapshot }; export type ShareEvent = diff --git a/web/src/core-bridge/worker.ts b/web/src/core-bridge/worker.ts index 1607b6c..bc7e58a 100644 --- a/web/src/core-bridge/worker.ts +++ b/web/src/core-bridge/worker.ts @@ -1,4 +1,5 @@ -import { setApiBase } from './worker/http'; +import { setApiBase, getServerInfo } from './worker/http'; +import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys'; import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems'; import { createChannel, forgetChannel, joinChannel, listChannels, releaseDevice, renameDevice, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources'; import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend'; @@ -11,7 +12,7 @@ import { setEventSink } from './worker/port'; import { serial } from './worker/serial'; import { errorMessage } from './errors'; import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account'; -import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread } from './worker/channelThreads'; +import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads'; import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account'; interface WorkerRequest { requestId: number; operation: string; payload: unknown } @@ -26,9 +27,21 @@ setEventSink((resourceId, event) => scope.postMessage({ resourceId, event })); async function dispatch(operation: string, input: unknown): Promise { const payload = input as Record; if (typeof payload.apiBase === 'string') setApiBase(payload.apiBase); + if (operation === 'serverInfo') return getServerInfo(); await restoreAccount(); const id = typeof payload.id === 'string' ? payload.id : ''; switch (operation) { + case 'setChannelHistory': return setChannelHistory(id, String(payload.threadId ?? ''), payload.chatHistory === true, payload.filesHistory === true); + case 'listPasskeys': return listPasskeys(); + case 'removePasskey': return removePasskey(String(payload.id ?? '')); + case 'cancelPasskey': clearPasskeyState(); return accountStatus(); + case 'beginPasskeyRegistration': return beginPasskeyRegistration(String(payload.password ?? '')); + case 'finishPasskeyRegistration': return finishPasskeyRegistration(id, String(payload.credentialId ?? ''), String(payload.credential ?? ''), String(payload.name ?? ''), payload.prf as ArrayBuffer | undefined); + case 'beginPasskeyRecovery': return beginPasskeyRecovery(); + case 'finishPasskeyRecovery': return finishPasskeyRecovery(id, String(payload.credentialId ?? ''), String(payload.credential ?? ''), payload.prf as ArrayBuffer); + case 'beginPasskeyLogin': return beginPasskeyLogin(String(payload.username ?? '')); + case 'finishPasskeyLogin': return finishPasskeyLogin(String(payload.username ?? ''), id, String(payload.credential ?? ''), payload.prf as ArrayBuffer | undefined, payload.remember !== false); + case 'unlockPasskeyLogin': return unlockPasskeyLogin(String(payload.password ?? ''), payload.remember !== false); case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); return accountStatus(); case 'accountDevices': return listAccountDevices(); case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true; diff --git a/web/src/core-bridge/worker/account.ts b/web/src/core-bridge/worker/account.ts index 0fc6b0e..c7d7e31 100644 --- a/web/src/core-bridge/worker/account.ts +++ b/web/src/core-bridge/worker/account.ts @@ -2,24 +2,38 @@ import { importResources, importResourceDeletes, listAllResources, listResourceD import { apiFetch } from './http'; import { clearResourceSessions, getSession } from './sessions'; import { setAccountIdentity, requireAccount } from './accountSession'; -import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys } from './accountCrypto'; -import { mergeVault, validateVault, validatePersonal, type VaultProfile, type PersonalProfile } from './accountVault'; +import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto'; +import { mergeVault, validateVault, type VaultProfile } from './accountVault'; import { joinChannel } from './channelResources'; import { openChannelGrant, sealChannelGrant } from './channelGrant'; import { recoverAccountChannels } from './accountChannels'; import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence'; +import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api'; -interface VaultBody { revision: number; vault: string } +import { loadPersonal, personalProfile } from './accountProfile'; +import type { AccountProfile, AccountProfileRequest } from '../generated/api'; + +import type { PersonalProfile } from '../protocol/account'; +import { passkeyUnlockUsername, clearPasskeyState } from './passkeys'; +import { packageInvitationDomains } from './channelDataKeys'; +import { clearDeviceSigner, ensureDeviceSigning } from './accountSigning'; + +type VaultBody = AccountVaultBody; interface AccountSession { username: string; token: string; key: CryptoKey; profile: VaultProfile; revision: number; - remember: boolean; + deviceSigning?: CryptoKeyPair; + remember: boolean; personal?: PersonalProfile; controller: AbortController; dirty: boolean; syncing?: Promise; error?: string; timer?: ReturnType; polling?: ReturnType; } -export interface Invitation { channel_id: string; username: string; payload: string; created_at: number } +export type Invitation = InvitationBody; let account: AccountSession | undefined; +export function unlockedAccount() { + if (!account) throw new Error('请先登录账号'); + return account; +} let restoration: Promise | undefined; -function usernameOf(input: string): string { +export function usernameOf(input: string): string { const username = input.trim().toLowerCase(); if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new Error('用户名需为 3–32 位字母、数字、下划线或短横线'); return username; @@ -33,7 +47,7 @@ async function applyProfile(profile: VaultProfile): Promise { await importResourceDeletes(profile.deletes); } async function persistSession(session: AccountSession, value: VaultBody): Promise { - if (session.remember) await rememberAccount({ username: session.username, token: session.token, key: session.key, ...value }); + if (session.remember) await rememberAccount({ username: session.username, token: session.token, key: session.key, personal: session.personal, ...value }); else await forgetAccount(); } async function syncVault(): Promise { @@ -41,6 +55,7 @@ async function syncVault(): Promise { const session = account; if (session.syncing) { session.dirty = true; return session.syncing; } session.syncing = (async () => { + session.personal = await loadPersonal(session.token); await recoverAccountChannels(session.username, session.profile.keys, session.controller.signal); do { session.dirty = false; @@ -48,11 +63,14 @@ async function syncVault(): Promise { const remote = await apiFetch('/accounts/vault', { headers: headers(session), signal: session.controller.signal }).then((r) => r.json() as Promise); const profile = validateVault(await openJson(remote.vault, session.key)); if (profile.keys.publicKey.x !== session.profile.keys.publicKey.x || profile.keys.publicKey.y !== session.profile.keys.publicKey.y) throw new Error('账号邀请密钥发生冲突,已停止同步'); - const merged = mergeVault(profile, await listAllResources(), await listResourceDeletes(), session.profile.personal); + const remoteSigning = profile.keys.signing.publicKey; const localSigning = session.profile.keys.signing.publicKey; + if (remoteSigning.x !== localSigning.x || remoteSigning.y !== localSigning.y) + throw new Error('账号签名密钥发生冲突,已停止同步'); + const merged = mergeVault(profile, await listAllResources(), await listResourceDeletes()); if (JSON.stringify(merged) === JSON.stringify(profile)) { stillActive(session); await applyProfile(merged); session.profile = merged; session.revision = remote.revision; session.error = undefined; - setAccountIdentity(session, merged.keys.publicKey, merged.personal); + setAccountIdentity(session, merged.keys.publicKey, session.personal); await persistSession(session, remote); break; } @@ -64,7 +82,7 @@ async function syncVault(): Promise { stillActive(session); await applyProfile(merged); session.profile = merged; session.revision = result.revision; session.error = undefined; - setAccountIdentity(session, merged.keys.publicKey, merged.personal); + setAccountIdentity(session, merged.keys.publicKey, session.personal); await persistSession(session, { vault, revision: result.revision }); break; } catch (error) { @@ -75,6 +93,7 @@ async function syncVault(): Promise { })().catch((error: unknown) => { if (account === session) session.error = error instanceof Error ? error.message : '同步失败'; if (account === session && error instanceof Error && 'status' in error && error.status === 401) { + clearDeviceSigner(); account = undefined; clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null); void forgetAccount(); @@ -85,11 +104,17 @@ async function syncVault(): Promise { return session.syncing; } -async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true): Promise { +async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true, cachedPersonal?: PersonalProfile): Promise { const profile = validateVault(await openJson(remote.vault, key)); + const deviceSigning = await ensureDeviceSigning(identity.username, identity.token, profile.keys.signing); clearResourceSessions(); setResourceScope(identity.username); - const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false }; - account = session; setAccountIdentity(identity, profile.keys.publicKey, profile.personal); + const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false, deviceSigning }; + try { session.personal = await loadPersonal(identity.token); } + catch (error) { + if (!(error instanceof Error && error.message === '网络连接失败')) throw error; + session.personal = cachedPersonal; + } + account = session; setAccountIdentity(identity, profile.keys.publicKey, session.personal); await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes())); setResourceChangeListener(scheduleSync); session.polling = setInterval(scheduleSync, 20_000); @@ -108,10 +133,8 @@ export function restoreAccount(): Promise { remote = saved; } try { - const session = await activate(saved, saved.key, remote); - const local = validateVault(await openJson(saved.vault, saved.key)); - if (local.personal && local.personal.modifiedAt > (session.profile.personal?.modifiedAt ?? -1)) session.profile = { ...session.profile, personal: local.personal }; - setAccountIdentity(session, session.profile.keys.publicKey, session.profile.personal); + const session = await activate(saved, saved.key, remote, true, saved.personal); + setAccountIdentity(session, session.profile.keys.publicKey, session.personal); await syncVault().catch(() => {}); } catch (error) { await logoutAccount(); throw error; } })().catch((error: unknown) => { restoration = undefined; throw error; }); @@ -129,10 +152,12 @@ export async function registerAccount(input: string, password: string, remember if (password.length < 12 || password.length > 1024) throw new Error('密码长度需为 12–1024 个字符'); const salt = crypto.getRandomValues(new Uint8Array(16)); const { verifier, key } = await deriveAccountKeys(password, salt); - const profile: VaultProfile = { v: 2, keys: await generateInvitationKeys(), resources: [], deletes: [] }; + const invitationKeys = await generateInvitationKeys(); const signing = await generateAccountSigningKeys(); + const profile: VaultProfile = { v: 3, keys: { ...invitationKeys, signing }, resources: [], deletes: [] }; try { await apiFetch('/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey), vault: await sealJson(profile, key) }) }); + body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey), + signing_public_key: JSON.stringify(signing.publicKey), vault: await sealJson(profile, key) } satisfies AccountRegisterRequest) }); } finally { verifier.fill(0); } return loginAccount(username, password, remember); } @@ -140,22 +165,31 @@ export async function loginAccount(input: string, password: string, remember = t if (account) throw new Error('请先退出当前账号'); const username = usernameOf(input); const challenge = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }) - .then((r) => r.json() as Promise<{ salt: string }>); + .then((r) => r.json() as Promise); const { verifier, key } = await deriveAccountKeys(password, unb64(challenge.salt)); - let identity: { username: string; token: string }; + let identity: AccountSessionBody; try { const device = await localAccountDevice(); - identity = await apiFetch('/accounts/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, verifier: b64(verifier), device_id: device.id, device_name: device.name }) }) - .then((r) => r.json() as Promise<{ username: string; token: string }>); + identity = await apiFetch('/accounts/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, verifier: b64(verifier), device_id: device.id, device_name: device.name } satisfies AccountSessionRequest) }) + .then((r) => r.json() as Promise); } finally { verifier.fill(0); } + const status = await unlockAuthenticatedAccount(identity, key, remember); + if (remember) await saveLoginFields(username, password); else await forgetLoginFields(); + return status; +} +export async function unlockAuthenticatedAccount(identity: AccountSessionBody, key: CryptoKey, remember = true): Promise>> { + if (account) throw new Error('请先退出当前账号'); try { - const remote = await apiFetch('/accounts/vault', { headers: headers(identity) }).then((r) => r.json() as Promise); + const remote = await apiFetch('/accounts/vault', { headers: headers(identity) }).then(r => r.json() as Promise); const profile = validateVault(await openJson(remote.vault, key)); - const publicKey = await apiFetch(`/accounts/users/${username}/key`, { headers: headers(identity) }).then((r) => r.json() as Promise<{ public_key: string }>); + const publicKey = await apiFetch('/accounts/users/' + identity.username + '/key', { headers: headers(identity) }).then(r => r.json() as Promise); const published = JSON.parse(publicKey.public_key) as JsonWebKey; if (published.x !== profile.keys.publicKey.x || published.y !== profile.keys.publicKey.y) throw new Error('账号邀请公钥与保险库不匹配'); + if (!publicKey.signing_public_key) throw new Error('账号签名公钥不存在。'); + const publishedSigning = JSON.parse(publicKey.signing_public_key) as JsonWebKey; + if (publishedSigning.x !== profile.keys.signing.publicKey.x || publishedSigning.y !== profile.keys.signing.publicKey.y) + throw new Error('账号签名公钥与保险库不匹配'); const session = await activate(identity, key, remote, remember); - if (remember) await saveLoginFields(username, password); else await forgetLoginFields(); await persistSession(session, remote); await syncVault().catch(() => {}); return accountStatus(); @@ -165,13 +199,15 @@ export async function loginAccount(input: string, password: string, remember = t throw error; } } -export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string }> { +export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }> { return { username: account?.username ?? null, revision: account?.revision ?? 0, - remembered: account?.remember ?? false, - ...(account?.profile.personal ? { personal: account.profile.personal } : {}), + remembered: account?.remember ?? false, unlockUsername: passkeyUnlockUsername(), + ...(account?.personal ? { personal: account.personal } : {}), ...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) }; } export async function logoutAccount(): Promise { + clearDeviceSigner(); + clearPasskeyState(); const session = account; account = undefined; if (session) { clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); await session.syncing?.catch(() => {}); } setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null); @@ -192,13 +228,15 @@ export async function clearSavedLogin(): Promise { } export async function updatePersonal(displayName: string, avatar?: string): Promise { if (!account) throw new Error('请先登录账号'); - account.profile = { ...account.profile, personal: validatePersonal({ displayName, ...(avatar ? { avatar } : {}), modifiedAt: Date.now() }) }; - setAccountIdentity(account, account.profile.keys.publicKey, account.profile.personal); - // Retain an offline profile edit for the next session restoration. - await persistSession(account, { vault: await sealJson(account.profile, account.key), revision: account.revision }); - await syncVault(); + const session = account; + const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), 'Content-Type': 'application/json' }, + body: JSON.stringify({ displayName, avatar } satisfies AccountProfileRequest) }).then(r => r.json() as Promise); + stillActive(session); + session.personal = personalProfile(value); + setAccountIdentity(session, session.profile.keys.publicKey, session.personal); + await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision }); } -export async function listAccountDevices(): Promise { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); } +export async function listAccountDevices(): Promise { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json() as Promise); } export async function renameAccountDevice(id: string, name: string): Promise { requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); if ((await localAccountDevice()).id === id) await saveDeviceName(name.trim()); @@ -221,9 +259,12 @@ export async function acceptInvitation(channelId: string): Promise { export async function inviteChannelUser(channelId: string, input: string): Promise { requireAccount(); const username = usernameOf(input); const { session, resource } = await getSession(channelId); if (!session.can('owner')) throw new Error('只有空间主可以邀请用户'); - const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<{ public_key: string }>); + const plan = await apiFetch(`/channels/${channelId}/invitations/${username}/crypto-plan`, { headers: session.authHeaders('owner') }) + .then(r => r.json() as Promise); + const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise); const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey); - await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload }) }); + const domainEnvelopes = await packageInvitationDomains(resource, session, plan); + await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) }); } export async function listChannelInvitations(channelId: string): Promise { const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise); diff --git a/web/src/core-bridge/worker/accountChannels.ts b/web/src/core-bridge/worker/accountChannels.ts index b3f729f..8be6e2d 100644 --- a/web/src/core-bridge/worker/accountChannels.ts +++ b/web/src/core-bridge/worker/accountChannels.ts @@ -1,18 +1,18 @@ import { ChannelSession } from '@webdrop/core'; -import { getResource, importResources, listAllResources, listResourceDeletes, type SavedResource } from '../database'; -import { openChannelGrant, sealChannelGrant } from './channelGrant'; +import { getResource, importResources, listResourceDeletes, type SavedResource } from '../database'; +import { openChannelGrant } from './channelGrant'; import { makeLink } from './links'; import { apiFetch, readJson } from './http'; import type { InvitationKeyPair } from './accountCrypto'; import type { ChannelWire } from './wire'; - -interface AccountChannel { channel_id: string; role: 'owner' | 'member'; payload: string; created_at: number } +import type { AccountChannelBody } from '../generated/api'; +import { channelCache } from './resourceMetadata'; export async function recoverAccountChannels(username: string, keys: InvitationKeyPair, signal: AbortSignal): Promise { - const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise); + const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise); const deletes = new Map((await listResourceDeletes()).map((row) => [row.id, row.modifiedAt])); - const known = new Set(grants.map((row) => row.channel_id)); for (const grant of grants) { + if (grant.role !== 'owner' && grant.role !== 'member') throw new Error('空间成员权限无效'); if (signal.aborted) throw new DOMException('账号已切换', 'AbortError'); if ((deletes.get(grant.channel_id) ?? -1) >= grant.created_at || await getResource(grant.channel_id)) continue; const parsed = await openChannelGrant(grant.payload, username, grant.channel_id, keys.privateKey, grant.role, self.location.origin); @@ -21,25 +21,13 @@ export async function recoverAccountChannels(username: string, keys: InvitationK const info = await readJson(`/channels/${session.id}`, session.authHeaders('read')); session.verifyChannelInfo(info); if (grant.role === 'owner' && info.creator_account !== username) throw new Error('空间所属账号不匹配'); - const resource: SavedResource = { id: session.id, kind: 'channel', mode: session.mode, role: grant.role, - creatorAccount: info.creator_account, name: info.settings.name, + const resource: SavedResource = { ...channelCache(info), id: session.id, kind: 'channel', mode: session.mode, role: grant.role, + creatorAccount: info.creator_account ?? undefined, name: info.settings.name ?? undefined, link: session.link(self.location.origin), ...(grant.role === 'owner' ? { ownerLink: session.link(self.location.origin, 'owner') } : {}), endpointSecret: new Uint8Array(32), createdAt: info.created_at, modifiedAt: grant.created_at, expiresAt: info.expires_at, - seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, maxDownloads: info.settings.file_max_downloads }; + seats: info.settings.seats, maxDownloads: info.settings.file_max_downloads }; if (signal.aborted) throw new DOMException('账号已切换', 'AbortError'); await importResources([resource]); } finally { session.dispose(); } } - // Populate owner grants for channels created before account-bound delivery existed. - for (const resource of await listAllResources()) { - if (resource.kind !== 'channel' || resource.role !== 'owner' || !resource.ownerLink || known.has(resource.id)) continue; - const session = await ChannelSession.open(resource.ownerLink, { endpointSecret: resource.endpointSecret }); - try { - const payload = await sealChannelGrant(resource.ownerLink, username, keys.publicKey); - await apiFetch(`/channels/${resource.id}/key-package`, { method: 'PUT', signal, - headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ payload }) }); - } catch (error) { - if (!(error instanceof Error && 'status' in error && error.status === 404)) throw error; - } finally { session.dispose(); } - } } diff --git a/web/src/core-bridge/worker/accountCrypto.ts b/web/src/core-bridge/worker/accountCrypto.ts index 08ffacd..2786578 100644 --- a/web/src/core-bridge/worker/accountCrypto.ts +++ b/web/src/core-bridge/worker/accountCrypto.ts @@ -8,17 +8,19 @@ export function b64(value: Uint8Array): string { return btoa(text); } export function unb64(value: string): Uint8Array { return Uint8Array.from(atob(value), (c) => c.charCodeAt(0)); } -export async function deriveAccountKeys(password: string, salt: Uint8Array): Promise<{ verifier: Uint8Array; key: CryptoKey }> { +export async function deriveAccountKeys(password: string, salt: Uint8Array, exportMaterial = false): Promise<{ verifier: Uint8Array; key: CryptoKey; material?: Uint8Array }> { if (salt.length !== 16 || password.length > 1024) throw new Error('账号凭据格式无效'); const base = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits']); const root = new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: bytes(salt), iterations: 600_000 }, base, 256)); try { const hmac = await crypto.subtle.importKey('raw', root, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const verifier = new Uint8Array(await crypto.subtle.sign('HMAC', hmac, encoder.encode('webdrop-account-login-v1'))); - const hkdf = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveKey']); - const key = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: bytes(salt), info: encoder.encode(vaultLabel) }, hkdf, - { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); - return { verifier, key }; + const hkdf = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveBits']); + const material = new Uint8Array(await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: bytes(salt), info: encoder.encode(vaultLabel) }, hkdf, 256)); + try { + const key = await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']); + return { verifier, key, ...(exportMaterial ? { material: Uint8Array.from(material) } : {}) }; + } finally { material.fill(0); } } finally { root.fill(0); } } export async function sealJson(value: unknown, key: CryptoKey, context = vaultLabel): Promise { @@ -36,11 +38,18 @@ export async function openJson(value: string, key: CryptoKey, context = vaultLab return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plain)); } catch { throw new Error('加密数据校验失败,已拒绝导入'); } } -export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } +export type { InvitationKeyPair } from '../protocol/account'; +import type { InvitationKeyPair } from '../protocol/account'; +import type { SigningKeyPair } from '../protocol/account'; +import { generateSigningKeyPair } from '@webdrop/core'; export async function generateInvitationKeys(): Promise { const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), privateKey: await crypto.subtle.exportKey('jwk', pair.privateKey) }; } +export async function generateAccountSigningKeys(): Promise { + const pair = await generateSigningKeyPair(true); + return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), privateKey: await crypto.subtle.exportKey('jwk', pair.privateKey) }; +} async function invitationKey(privateKey: JsonWebKey, publicKey: JsonWebKey, context: string): Promise { const own = await crypto.subtle.importKey('jwk', privateKey, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']); const peer = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDH', namedCurve: 'P-256' }, false, []); diff --git a/web/src/core-bridge/worker/accountPersistence.ts b/web/src/core-bridge/worker/accountPersistence.ts index b0811ae..a10f4b2 100644 --- a/web/src/core-bridge/worker/accountPersistence.ts +++ b/web/src/core-bridge/worker/accountPersistence.ts @@ -1,8 +1,10 @@ import { openDB, type DBSchema } from 'idb'; import { apiPath } from './http'; +import type { PersonalProfile } from '../protocol/account'; import { decryptLogin, encryptLogin, type EncryptedLogin, type LoginFields } from './credentialCipher'; export interface RememberedAccount { + personal?: PersonalProfile; username: string; token: string; key: CryptoKey; @@ -13,10 +15,13 @@ interface LocalIdentity extends DBSchema { credentials: { key: string; value: EncryptedLogin }; sessions: { key: string; value: RememberedAccount }; device: { key: string; value: { id: string; name: string } }; + deviceSigning: { key: string; value: CryptoKeyPair }; + trustedSigningRoots: { key: string; value: JsonWebKey }; } -const database = () => openDB('webdrop-account-local-2', 2, { upgrade(db, oldVersion) { +const database = () => openDB('webdrop-account-local-2', 3, { upgrade(db, oldVersion) { if (oldVersion < 1) { db.createObjectStore('sessions'); db.createObjectStore('device'); } if (oldVersion < 2) db.createObjectStore('credentials'); + if (oldVersion < 3) { db.createObjectStore('deviceSigning'); db.createObjectStore('trustedSigningRoots'); } } }); const endpoint = () => apiPath('/'); export async function rememberedAccount(): Promise { return (await database()).get('sessions', endpoint()); } @@ -43,3 +48,18 @@ export async function localAccountDevice(): Promise<{ id: string; name: string } export async function saveDeviceName(name: string): Promise { const device = await localAccountDevice(); await (await database()).put('device', { ...device, name }, endpoint()); } +export async function localDeviceSigningKeys(username: string, deviceId: string): Promise { + return (await database()).get('deviceSigning', `${endpoint()}:${username}:${deviceId}`); +} +export async function saveLocalDeviceSigningKeys(username: string, deviceId: string, keys: CryptoKeyPair): Promise { + await (await database()).put('deviceSigning', keys, `${endpoint()}:${username}:${deviceId}`); +} +export async function pinSigningRoot(username: string, key: JsonWebKey): Promise { + const databaseRef = await database(); const tx = databaseRef.transaction('trustedSigningRoots', 'readwrite'); + const dbKey = `${endpoint()}:${username}`; const existing = await tx.store.get(dbKey); + if (existing && (existing.kty !== key.kty || existing.crv !== key.crv || existing.x !== key.x || existing.y !== key.y)) { + tx.abort(); throw new Error('账号签名公钥已变化,已阻止静默替换;请通过可信渠道核对指纹。'); + } + if (!existing) await tx.store.put(key, dbKey); + await tx.done; +} diff --git a/web/src/core-bridge/worker/accountProfile.ts b/web/src/core-bridge/worker/accountProfile.ts new file mode 100644 index 0000000..d4107b8 --- /dev/null +++ b/web/src/core-bridge/worker/accountProfile.ts @@ -0,0 +1,12 @@ +import { apiFetch } from './http'; +import type { AccountProfile } from '../generated/api'; +import type { PersonalProfile } from '../protocol/account'; + +export function personalProfile(value: AccountProfile): PersonalProfile { + return { displayName: value.displayName, avatar: value.avatar ?? undefined, modifiedAt: value.modifiedAt }; +} +export async function loadPersonal(token: string): Promise { + const headers = { 'X-WD-Account': token }; + const profile = await apiFetch('/accounts/profile', { headers }).then(r => r.json() as Promise); + return personalProfile(profile); +} diff --git a/web/src/core-bridge/worker/accountSession.ts b/web/src/core-bridge/worker/accountSession.ts index bd8bab0..c2f1ef0 100644 --- a/web/src/core-bridge/worker/accountSession.ts +++ b/web/src/core-bridge/worker/accountSession.ts @@ -1,4 +1,4 @@ -import type { PersonalProfile } from './accountVault'; +import type { PersonalProfile } from '../protocol/account'; let identity: { username: string; token: string } | undefined; let publicKey: JsonWebKey | undefined; let personal: PersonalProfile | undefined; diff --git a/web/src/core-bridge/worker/accountSigning.ts b/web/src/core-bridge/worker/accountSigning.ts new file mode 100644 index 0000000..64ba963 --- /dev/null +++ b/web/src/core-bridge/worker/accountSigning.ts @@ -0,0 +1,98 @@ +import { contentEventPayload, frameManifestHash, fromBase64Url, generateSigningKeyPair, signStatement, toBase64Url, verifyStatement } from '@webdrop/core'; +import type { AccountDeviceSigningRequest } from '../generated/api'; +import { b64 } from './accountCrypto'; +import { apiFetch } from './http'; +import { localAccountDevice, localDeviceSigningKeys, pinSigningRoot, saveLocalDeviceSigningKeys } from './accountPersistence'; +import type { SigningKeyPair } from '../protocol/account'; + +interface ActiveSigner { username: string; deviceId: string; pair: CryptoKeyPair } +let activeSigner: ActiveSigner | undefined; + +function certificatePayload(username: string, deviceId: string, key: JsonWebKey): Uint8Array { + const name = new TextEncoder().encode(username.toLowerCase()); + const device = new TextEncoder().encode(deviceId.toLowerCase()); + if (name.length < 3 || name.length > 32 || device.length !== 36 || !/^[a-z0-9_-]+$/.test(username.toLowerCase()) + || !/^[0-9a-f-]{36}$/.test(deviceId.toLowerCase()) || !key.x || !key.y) + throw new Error('设备签名证书字段无效'); + const x = fromBase64Url(key.x); const y = fromBase64Url(key.y); + if (!x || !y || x.length !== 32 || y.length !== 32) throw new Error('设备签名公钥格式无效'); + const payload = new Uint8Array(1 + name.length + 1 + device.length + 64); + let offset = 0; + payload[offset++] = name.length; payload.set(name, offset); offset += name.length; + payload[offset++] = device.length; payload.set(device, offset); offset += device.length; + payload.set(x, offset); payload.set(y, offset + 32); + return payload; +} + +export async function ensureDeviceSigning( + username: string, accountToken: string, root: SigningKeyPair, +): Promise { + await pinSigningRoot(username, root.publicKey); + const device = await localAccountDevice(); + let pair = await localDeviceSigningKeys(username, device.id); + if (!pair) { + pair = await generateSigningKeyPair(); + await saveLocalDeviceSigningKeys(username, device.id, pair); + } + const publicKey = await crypto.subtle.exportKey('jwk', pair.publicKey); + const rootPrivateKey = await crypto.subtle.importKey('jwk', root.privateKey, + { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']); + const signature = await signStatement(rootPrivateKey, 'device.certificate', certificatePayload(username, device.id, publicKey)); + const request: AccountDeviceSigningRequest = { + device_id: device.id, + public_key: JSON.stringify(publicKey), + signature: b64(signature), + }; + await apiFetch('/accounts/devices/signing-key', { method: 'PUT', + headers: { 'X-WD-Account': accountToken, 'Content-Type': 'application/json' }, body: JSON.stringify(request) }); + activeSigner = { username: username.toLowerCase(), deviceId: device.id.toLowerCase(), pair }; + return pair; +} + +export function clearDeviceSigner(): void { activeSigner = undefined; } + +export async function signContentEvent(input: { + spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string; + eventKind: string; targetItemId?: string; frameDigests: readonly Uint8Array[]; +}): Promise> { + return signContentEventMetadata({ ...input, frameCount: input.frameDigests.length, manifestHash: await frameManifestHash(input.frameDigests) }); +} + +export async function signExistingContentEvent(input: { + spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string; + eventKind: string; frameCount: number; manifestHash: Uint8Array; targetItemId?: string; +}): Promise> { + return signContentEventMetadata(input); +} + +async function signContentEventMetadata(input: { + spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string; + eventKind: string; frameCount: number; manifestHash: Uint8Array; targetItemId?: string; +}): Promise> { + const signer = activeSigner; + if (!signer) throw new Error('当前账号设备没有已注册的签名密钥'); + const spaceId = fromBase64Url(input.spaceId, 16); const itemId = fromBase64Url(input.itemId, 16); + const targetItemId = input.targetItemId ? fromBase64Url(input.targetItemId, 16) : undefined; + if (!spaceId || !itemId || (input.targetItemId && !targetItemId)) throw new Error('签名事件资源 ID 无效'); + const eventId = crypto.getRandomValues(new Uint8Array(16)); + const payload = contentEventPayload({ spaceId, channelId: input.channelId, username: signer.username, deviceId: signer.deviceId, + eventId, eventKind: input.eventKind, domain: input.domain, epoch: input.epoch, itemId, + frameCount: input.frameCount, manifestHash: input.manifestHash, ...(targetItemId ? { targetItemId } : {}) }); + const signature = await signStatement(signer.pair.privateKey, 'content.event', payload); + return { + 'X-WD-Event-Id': toBase64Url(eventId), 'X-WD-Event-Kind': input.eventKind, + 'X-WD-Event-Channel': input.channelId, 'X-WD-Event-Domain': input.domain, + 'X-WD-Event-Epoch': String(input.epoch), 'X-WD-Event-Frames': String(input.frameCount), + 'X-WD-Event-Manifest': toBase64Url(input.manifestHash), 'X-WD-Event-Signature': toBase64Url(signature), + ...(input.targetItemId ? { 'X-WD-Event-Target': input.targetItemId } : {}), + }; +} + +export async function verifyDeviceCertificate( + username: string, deviceId: string, publicKey: JsonWebKey, signature: Uint8Array, root: CryptoKey, +): Promise { + try { + const payload = certificatePayload(username, deviceId, publicKey); + return verifyStatement(root, 'device.certificate', payload, signature); + } catch { return false; } +} diff --git a/web/src/core-bridge/worker/accountVault.test.ts b/web/src/core-bridge/worker/accountVault.test.ts index b6c90fd..7129282 100644 --- a/web/src/core-bridge/worker/accountVault.test.ts +++ b/web/src/core-bridge/worker/accountVault.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { exportResource, mergeVault, validatePersonal, type VaultProfile } from './accountVault'; +import { exportResource, mergeVault, type VaultProfile } from './accountVault'; import type { SavedResource } from '../database'; const resource = (modifiedAt = 10): SavedResource => ({ id: 'AAAAAAAAAAAAAAAAAAAAAA', kind: 'channel', mode: 'enc', role: 'owner', link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), deviceLabel: 'device-a', localScope: 'account:alice', - createdAt: 1, modifiedAt, expiresAt: 100, seats: 3, membersCanUpload: true, name: `version-${modifiedAt}` }); -const profile = (): VaultProfile => ({ v: 2, keys: { publicKey: {}, privateKey: {} }, resources: [exportResource(resource(20))], deletes: [] }); + createdAt: 1, modifiedAt, expiresAt: 100, seats: 3, name: `version-${modifiedAt}` }); +const profile = (): VaultProfile => ({ v: 3, keys: { publicKey: {}, privateKey: {}, signing: { publicKey: {}, privateKey: {} } }, resources: [exportResource(resource(20))], deletes: [] }); describe('account vault portability and merge', () => { it('never synchronizes device identity or local scope', () => { const exported = exportResource(resource()); @@ -24,12 +24,5 @@ describe('account vault portability and merge', () => { const merged = mergeVault(remote, [resource(40)], []); expect(merged.resources).toHaveLength(1); expect(merged.deletes).toEqual([]); }); - it('keeps the newest personal profile across stale devices, including avatar removal', () => { - const remote = { ...profile(), personal: { displayName: '最新名称', avatar: 'data:image/png;base64,AA==', modifiedAt: 20 } }; - expect(mergeVault(remote, [], [], { displayName: '旧名称', modifiedAt: 10 }).personal).toEqual(remote.personal); - expect(mergeVault(remote, [], [], { displayName: '新名称', modifiedAt: 30 }).personal).not.toHaveProperty('avatar'); - }); - it('rejects remote executable and external avatars', () => { - for (const avatar of ['https://tracker.test/avatar.png', 'data:image/svg+xml;base64,AA==']) expect(() => validatePersonal({ displayName: 'Alice', avatar, modifiedAt: 1 })).toThrow(); - }); + }); diff --git a/web/src/core-bridge/worker/accountVault.ts b/web/src/core-bridge/worker/accountVault.ts index 46dc3fe..6a20b53 100644 --- a/web/src/core-bridge/worker/accountVault.ts +++ b/web/src/core-bridge/worker/accountVault.ts @@ -1,37 +1,36 @@ import type { SavedResource } from '../database'; -import type { InvitationKeyPair } from './accountCrypto'; -export interface ResourceDelete { id: string; modifiedAt: number } -export type VaultResource = Omit; -export interface PersonalProfile { displayName: string; avatar?: string; modifiedAt: number } -export interface VaultProfile { v: 2; keys: InvitationKeyPair; resources: VaultResource[]; deletes: ResourceDelete[]; personal?: PersonalProfile } +import type { ResourceDelete, VaultResource, VaultProfile } from '../protocol/account'; +export type { ResourceDelete, VaultResource, VaultProfile }; export function exportResource(resource: SavedResource): VaultResource { - const entry = { ...resource }; - delete entry.localScope; delete entry.localItems; delete entry.deviceLabel; delete entry.needsJoin; delete entry.pendingManifestId; - const { endpointSecret: _endpointSecret, ...portable } = entry; - return portable; + // Explicit protocol projection prevents browser draft/UI fields leaking into the vault. + return { id: resource.id, kind: resource.kind, mode: resource.mode, role: resource.role, + domainKeys: resource.domainKeys, + link: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt, + modifiedAt: resource.modifiedAt, creatorAccount: resource.creatorAccount, name: resource.name, + seats: resource.seats, ttlSeconds: resource.ttlSeconds, + maxDownloads: resource.maxDownloads, maxVisitors: resource.maxVisitors, mine: resource.mine, state: resource.state }; } export function validateVault(value: unknown): VaultProfile { if (!value || typeof value !== 'object') throw new Error('账号保险库格式无效'); const profile = value as VaultProfile; - if (profile.v !== 2 || !profile.keys?.privateKey?.d || profile.keys.privateKey.crv !== 'P-256' + if (profile.v !== 3) throw new Error('旧账号保险库版本不兼容 API 6,且不会自动迁移。'); + if (!profile.keys?.privateKey?.d || profile.keys.privateKey.crv !== 'P-256' || !profile.keys.publicKey?.x || profile.keys.publicKey.d || !Array.isArray(profile.resources) || !Array.isArray(profile.deletes)) throw new Error('账号保险库格式无效'); + if (!profile.keys.signing || profile.keys.signing.privateKey?.crv !== 'P-256' + || !profile.keys.signing.privateKey.d || profile.keys.signing.privateKey.kty !== 'EC' + || profile.keys.signing.publicKey?.crv !== 'P-256' || !profile.keys.signing.publicKey.x + || !profile.keys.signing.publicKey.y || profile.keys.signing.publicKey.d !== undefined) + throw new Error('账号签名密钥格式无效'); for (const row of profile.resources) { if (!row || !/^[A-Za-z0-9_-]{22}$/.test(row.id) || !['channel', 'share'].includes(row.kind) || typeof row.link !== 'string' || !['owner', 'member'].includes(row.role) || !Number.isFinite(row.createdAt) || !Number.isFinite(row.expiresAt) || (row.modifiedAt !== undefined && !Number.isFinite(row.modifiedAt))) throw new Error('账号资源清单格式无效'); } for (const row of profile.deletes) if (!row || !/^[A-Za-z0-9_-]{22}$/.test(row.id) || !Number.isFinite(row.modifiedAt)) throw new Error('账号资源清单格式无效'); - if (profile.personal) validatePersonal(profile.personal); - return profile; + return { v: profile.v, keys: profile.keys, resources: profile.resources, deletes: profile.deletes }; } -export function validatePersonal(value: PersonalProfile): PersonalProfile { - if (typeof value.displayName !== 'string' || value.displayName.trim().length > 48 || /[\u0000-\u001f]/.test(value.displayName) - || !Number.isFinite(value.modifiedAt) || (value.avatar !== undefined && (typeof value.avatar !== 'string' || value.avatar.length > 350_000 - || !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/.test(value.avatar)))) throw new Error('个人资料格式无效'); - return { ...value, displayName: value.displayName.trim() }; -} -export function mergeVault(remote: VaultProfile, resources: SavedResource[], deletes: ResourceDelete[], personal?: PersonalProfile): VaultProfile { +export function mergeVault(remote: VaultProfile, resources: SavedResource[], deletes: ResourceDelete[]): VaultProfile { const byId = new Map(remote.resources.map((entry) => [entry.id, entry])); const removed = new Map(remote.deletes.map((entry) => [entry.id, entry])); for (const row of resources) { @@ -43,6 +42,5 @@ export function mergeVault(remote: VaultProfile, resources: SavedResource[], del if ((removed.get(id)?.modifiedAt ?? -1) >= (row.modifiedAt ?? row.createdAt)) byId.delete(id); else removed.delete(id); } - const newest = personal && personal.modifiedAt > (remote.personal?.modifiedAt ?? -1) ? personal : remote.personal; - return { ...remote, ...(newest ? { personal: newest } : {}), resources: [...byId.values()], deletes: [...removed.values()] }; + return { v: remote.v, keys: remote.keys, resources: [...byId.values()], deletes: [...removed.values()] }; } diff --git a/web/src/core-bridge/worker/channelDataKeys.ts b/web/src/core-bridge/worker/channelDataKeys.ts new file mode 100644 index 0000000..11d65cb --- /dev/null +++ b/web/src/core-bridge/worker/channelDataKeys.ts @@ -0,0 +1,216 @@ +import { contentEventPayload, fromBase64Url, importSigningPublicKey, toBase64Url, verifyStatement, type ChannelSession } from '@webdrop/core'; +import type { CryptoState, DomainEnvelope, InvitationCryptoPlan, ObjectEnvelope, PublicKeyBody } from '../generated/api'; +import { getResource, saveResource, type SavedResource } from '../database'; +import { apiFetch } from './http'; +import { accountPublicKey, accountToken, requireAccount } from './accountSession'; +import { decryptInvitation, encryptInvitation } from './accountCrypto'; +import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto'; +import { loadWorkspace } from './workspaceStructure'; +import { pinSigningRoot } from './accountPersistence'; +import { signExistingContentEvent, verifyDeviceCertificate } from './accountSigning'; + +const objectKeys = new Map(); +const states = new Map(); +const domainId = (channelId: string, domain: string, epoch: number) => `${channelId}:${domain}:${epoch}`; +const contextId = (spaceId: string, channelId: string, domain: string, epoch: number) => `${spaceId}:${channelId}:${domain}:${epoch}`; +export function clearDataKeys(): void { objectKeys.clear(); states.clear(); } +export function attachDataKeys(session: ChannelSession, resource: SavedResource): void { + if (resource.kind !== 'channel') return; + session.objectKeyResolver = async itemId => { + const cached = objectKeys.get(`${resource.id}:${itemId}`); if (cached) return cached; + const { keys } = await readDataKeys(resource, session); + const object = states.get(resource.id)?.objects.find(o => o.itemId === itemId); + if (!object || object.domain !== 'chat' && object.domain !== 'files') throw new Error('该对象没有已授权的解密数据域'); + const key = keys[domainId(object.channelId, object.domain, object.epoch)]; + if (!key) throw new Error('未获得该频道数据域的访问授权'); + const unwrapped = await unwrapObjectKey(key, object.keyEnvelope, { spaceId: resource.id, channelId: object.channelId, domain: object.domain, epoch: object.epoch, itemId }); + objectKeys.set(`${resource.id}:${itemId}`, unwrapped); return unwrapped; + }; +} +export async function readDataKeys(resource: SavedResource, session: ChannelSession): Promise<{ state: CryptoState; keys: Record }> { + const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise); + states.set(resource.id, state); + const latest = await getResource(resource.id); const keys = { ...latest?.domainKeys }; + for (const envelope of state.envelopes) { + const { unlockedAccount } = await import('./account'); const account = unlockedAccount(); + const id = domainId(envelope.channelId, envelope.domain, envelope.epoch); + if (keys[id]) continue; + const plain = await decryptInvitation(envelope.payload, account.username, contextId(resource.id, envelope.channelId, envelope.domain, envelope.epoch), account.profile.keys.privateKey); + const value = JSON.parse(plain) as { channelId: string; domain: string; epoch: number; key: string }; + if (value.channelId !== envelope.channelId || value.domain !== envelope.domain || value.epoch !== envelope.epoch || typeof value.key !== 'string') throw new Error('数据域密钥包与授权范围不匹配'); + keys[id] = value.key; + } + if (latest && JSON.stringify(keys) !== JSON.stringify(latest.domainKeys ?? {})) await saveResource({ ...latest, domainKeys: keys }); + return { state, keys }; +} +async function envelope(spaceId: string, channelId: string, domain: 'chat' | 'files', epoch: number, key: string, username: string): Promise { + const publicKey = username === requireAccount() ? accountPublicKey() : JSON.parse((await apiFetch(`/accounts/users/${username}/key`).then(r => r.json() as Promise)).public_key) as JsonWebKey; + return { channelId, domain, epoch, username, payload: await encryptInvitation(JSON.stringify({ channelId, domain, epoch, key }), username, contextId(spaceId, channelId, domain, epoch), publicKey) }; +} +export async function packageInvitationDomains(resource: SavedResource, session: ChannelSession, plan: InvitationCryptoPlan): Promise { + if (plan.channelId !== (await loadWorkspace(resource.id)).structure.defaultChannelId) throw new Error('服务端授权计划的默认频道不匹配'); + if (resource.mode !== 'enc') { + if (plan.batches.length) throw new Error('未加密空间收到加密授权计划'); + return []; + } + if (!session.can('owner')) throw new Error('只有空间主可以封装频道密钥'); + const { keys } = await readDataKeys(resource, session); + const result: DomainEnvelope[] = []; + for (const batch of plan.batches) { + if (batch.domain !== 'chat' && batch.domain !== 'files' || batch.epoch < 1 || batch.recipients.length < 1 || new Set(batch.recipients).size !== batch.recipients.length) + throw new Error('服务端加密授权计划格式无效'); + const keyId = domainId(plan.channelId, batch.domain, batch.epoch); + let key = keys[keyId]; + if (!key) { + if (!batch.rotate) throw new Error('缺少历史 epoch 密钥,不能为受邀成员重建或替换密钥'); + key = randomDomainKey(); keys[keyId] = key; + const latest = await getResource(resource.id); + if (!latest) throw new Error('空间本地密钥状态不存在'); + await saveResource({ ...latest, domainKeys: { ...latest.domainKeys, [keyId]: key } }); + } + for (const username of batch.recipients) result.push(await envelope(resource.id, plan.channelId, batch.domain, batch.epoch, key, username)); + } + return result; +} +export async function initializeChannelKeys(resource: SavedResource, session: ChannelSession, channelId: string): Promise { + if (resource.mode !== 'enc') return; + const { structure } = await loadWorkspace(resource.id); const channel = structure.channels.find(c => c.id === channelId); + if (!channel) throw new Error('频道不存在'); + if (!session.can('owner')) return; + const owner = requireAccount(); + for (const domain of ['chat', 'files'] as const) { + const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch; + if (epoch > 0) continue; + const key = randomDomainKey(); const latest = (await getResource(resource.id))!; + const id = domainId(channelId, domain, 1); + const existing = latest.domainKeys?.[id]; const selected = existing ?? key; + if (!existing) await saveResource({ ...latest, domainKeys: { ...latest.domainKeys, [id]: selected } }); + const packaged = await envelope(resource.id, channelId, domain, 1, selected, owner); + await apiFetch(`/spaces/${resource.id}/crypto/domains`, { method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, + body: JSON.stringify({ channelId, domain, epoch: 1, envelopes: [packaged] }) }); + } + await loadWorkspace(resource.id); +} +export interface PreparedObject { itemIdBytes: Uint8Array; itemId: string; channelId: string; domain: 'chat' | 'files'; epoch: number } +export async function prepareObject(resource: SavedResource, session: ChannelSession, channelId: string, domain: 'chat' | 'files'): Promise { + const idBytes = crypto.getRandomValues(new Uint8Array(16)); const itemId = toBase64Url(idBytes); + const { structure } = await loadWorkspace(resource.id); let channel = structure.channels.find(c => c.id === channelId); + if (!channel) throw new Error('请先创建频道'); + if (resource.mode === 'enc' && !(domain === 'chat' ? channel.chatEpoch : channel.filesEpoch)) { + await initializeChannelKeys(resource, session, channelId); + channel = (await loadWorkspace(resource.id)).structure.channels.find(c => c.id === channelId)!; + } + const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch; + let keyEnvelope = ''; + if (resource.mode === 'enc') { + const { keys } = await readDataKeys(resource, session); const key = keys[domainId(channelId, domain, epoch)]; + if (!key) throw new Error('未获得该频道数据域的访问授权'); + const created = await newObjectKey(key, { spaceId: resource.id, channelId, domain, epoch, itemId }); keyEnvelope = created.envelope; + objectKeys.set(`${resource.id}:${itemId}`, created.key); + } + const object = await apiFetch(`/spaces/${resource.id}/crypto/objects`, { method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, + body: JSON.stringify({ itemId, channelId, domain, epoch, keyEnvelope }) }).then(r => r.json() as Promise); + const state = states.get(resource.id); if (state) state.objects.push(object); + return { itemIdBytes: idBytes, itemId, channelId, domain, epoch }; +} +export async function canDecryptRecord(resource: SavedResource, session: ChannelSession, itemId: string): Promise { + if (resource.mode !== 'enc') return true; + let state = states.get(resource.id); let object = state?.objects.find(o => o.itemId === itemId); + let keys = (await getResource(resource.id))?.domainKeys ?? {}; + if (!object) { const refreshed = await readDataKeys(resource, session); state = refreshed.state; keys = refreshed.keys; object = state.objects.find(o => o.itemId === itemId); } + if (!object) return false; + return Boolean(keys[domainId(object.channelId, object.domain, object.epoch)]); +} + +export async function verifyObjectEvent(resource: SavedResource, session: ChannelSession, itemId: string, manifestHash: Uint8Array): Promise { + if (resource.mode === 'raw' && !accountToken()) return true; + let state = states.get(resource.id); + let object = state?.objects.find(row => row.itemId === itemId); + if (!object?.event?.signature) { + const refreshed = await readDataKeys(resource, session); state = refreshed.state; + object = state.objects.find(row => row.itemId === itemId); + } + if (resource.mode === 'raw' && !object?.event?.signature) return true; + const signed = object?.event; + if (!object || !signed || !object.authorAccount || !signed.eventId || !signed.kind || !signed.frameCount + || !signed.manifestHash || !signed.signerDeviceId || !signed.signerRootPublicKey + || !signed.signerDevicePublicKey || !signed.deviceCertificateSignature || !signed.signature) return false; + const expectedManifest = fromBase64Url(signed.manifestHash, 32); + const spaceId = fromBase64Url(resource.id, 16); const eventId = fromBase64Url(signed.eventId, 16); + const itemBytes = fromBase64Url(itemId, 16); const eventSignature = fromBase64Url(signed.signature, 64); + const deviceCertificateSignature = fromBase64Url(signed.deviceCertificateSignature, 64); + if (!expectedManifest || !spaceId || !eventId || !itemBytes || !eventSignature || !deviceCertificateSignature + || expectedManifest.length !== manifestHash.length || !expectedManifest.every((byte, index) => byte === manifestHash[index])) return false; + const { unlockedAccount } = await import('./account'); const local = unlockedAccount(); + const rootJwk = JSON.parse(signed.signerRootPublicKey) as JsonWebKey; + if (object.authorAccount === local.username && local.profile.keys.signing + && (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false; + await pinSigningRoot(object.authorAccount, rootJwk); + const root = await importSigningPublicKey(rootJwk); + const deviceJwk = JSON.parse(signed.signerDevicePublicKey) as JsonWebKey; + if (!await verifyDeviceCertificate(object.authorAccount, signed.signerDeviceId, deviceJwk, deviceCertificateSignature, root)) return false; + const deviceKey = await importSigningPublicKey(deviceJwk); + const domain = object.domain; + if (domain !== 'chat' && domain !== 'files') return false; + const targetItemId = signed.targetItemId ? fromBase64Url(signed.targetItemId, 16) : undefined; + if (signed.targetItemId && !targetItemId) return false; + const payload = contentEventPayload({ spaceId, channelId: object.channelId, username: object.authorAccount, + deviceId: signed.signerDeviceId, eventId, eventKind: signed.kind, domain, epoch: object.epoch, + itemId: itemBytes, frameCount: signed.frameCount, manifestHash: expectedManifest, + ...(targetItemId ? { targetItemId } : {}) }); + return verifyStatement(deviceKey, 'content.event', payload, eventSignature); +} + +export async function objectEventProjection(resource: SavedResource, session: ChannelSession, itemId: string): Promise<{ kind: string; targetItemId?: string } | undefined> { + let state = states.get(resource.id); let object = state?.objects.find(row => row.itemId === itemId); + if (!object) { state = (await readDataKeys(resource, session)).state; object = state.objects.find(row => row.itemId === itemId); } + return object?.event?.signature && object.event.kind + ? { kind: object.event.kind, ...(object.event.targetItemId ? { targetItemId: object.event.targetItemId } : {}) } + : undefined; +} + +export async function signObjectDeletion(resource: SavedResource, session: ChannelSession, itemId: string): Promise> { + const { state } = await readDataKeys(resource, session); + const object = state.objects.find(row => row.itemId === itemId); + const signed = object?.event; + if (!signed?.manifestHash || !signed.frameCount || (object?.domain !== 'chat' && object?.domain !== 'files')) + throw new Error('对象缺少已验签的密文清单,不能生成删除事件'); + const manifestHash = fromBase64Url(signed.manifestHash, 32); + if (!manifestHash) throw new Error('对象密文清单格式无效'); + return signExistingContentEvent({ spaceId: resource.id, channelId: object.channelId, domain: object.domain, + epoch: object.epoch, itemId, eventKind: object.domain === 'chat' ? 'chat.delete' : 'file.delete', + frameCount: signed.frameCount, manifestHash }); +} + +export async function verifyObjectDeletion(resource: SavedResource, session: ChannelSession, itemId: string): Promise { + let state = states.get(resource.id); let object = state?.objects.find(row => row.itemId === itemId); + if (!object?.deleteEvent?.signature) { + const refreshed = await readDataKeys(resource, session); state = refreshed.state; + object = state.objects.find(row => row.itemId === itemId); + } + const deleted = object?.deleteEvent; + if (resource.mode === 'raw' && !deleted?.signature) return true; + if (!object || !deleted?.signature || !deleted.eventId || !deleted.kind || !deleted.signerDeviceId + || !deleted.signerRootPublicKey || !deleted.signerDevicePublicKey || !deleted.deviceCertificateSignature + || !object.authorAccount || !object.event?.manifestHash || !object.event.frameCount || (object.domain !== 'chat' && object.domain !== 'files')) return false; + const spaceId = fromBase64Url(resource.id, 16); const itemBytes = fromBase64Url(itemId, 16); + const eventId = fromBase64Url(deleted.eventId, 16); const manifestHash = fromBase64Url(object.event.manifestHash, 32); + const signature = fromBase64Url(deleted.signature, 64); const certificate = fromBase64Url(deleted.deviceCertificateSignature, 64); + if (!spaceId || !itemBytes || !eventId || !manifestHash || !signature || !certificate) return false; + const rootJwk = JSON.parse(deleted.signerRootPublicKey) as JsonWebKey; + const deviceJwk = JSON.parse(deleted.signerDevicePublicKey) as JsonWebKey; + const root = await importSigningPublicKey(rootJwk); + const { unlockedAccount } = await import('./account'); const local = unlockedAccount(); + if (object.authorAccount === local.username && local.profile.keys.signing + && (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false; + await pinSigningRoot(object.authorAccount, rootJwk); + if (!await verifyDeviceCertificate(object.authorAccount, deleted.signerDeviceId, deviceJwk, certificate, root)) return false; + const domain = object.domain; + const expectedKind = domain === 'chat' ? 'chat.delete' : 'file.delete'; + if (deleted.kind !== expectedKind) return false; + const payload = contentEventPayload({ spaceId, channelId: object.channelId, username: object.authorAccount, + deviceId: deleted.signerDeviceId, eventId, eventKind: expectedKind, domain, epoch: object.epoch, + itemId: itemBytes, frameCount: object.event.frameCount, manifestHash }); + const deviceKey = await importSigningPublicKey(deviceJwk); + return verifyStatement(deviceKey, 'content.event', payload, signature); +} diff --git a/web/src/core-bridge/worker/channelEvents.ts b/web/src/core-bridge/worker/channelEvents.ts index bd49540..c473ac7 100644 --- a/web/src/core-bridge/worker/channelEvents.ts +++ b/web/src/core-bridge/worker/channelEvents.ts @@ -1,12 +1,16 @@ -import { parseLogRecords, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core'; -import { getResource, saveResource, type SavedResource } from '../database'; +import { parseLogRecords, parseEnvelope, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core'; +import { canDecryptRecord, objectEventProjection, readDataKeys, verifyObjectDeletion, verifyObjectEvent } from './channelDataKeys'; +import { getResource, cacheResource, type SavedResource } from '../database'; +import { channelCache } from './resourceMetadata'; +import { loadWorkspace } from './workspaceStructure'; +import type { SeatInfo } from '../generated/api'; import type { FileItem, Item, Snapshot } from '../types'; import { previewKindOf } from '@/lib/fileKind'; import { blobs, readers } from './sessions'; import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation'; import { type ChannelWire, toChannelInfo } from './wire'; import { apiFetch, readJson } from './http'; -import { applyControl } from './channelThreads'; +import { readResourceRecord } from './workspaceRecords'; import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog'; export function mapReceived(item: ReceivedItem): Item { @@ -30,6 +34,10 @@ export function mapReceived(item: ReceivedItem): Item { } export async function applyChannelRecord(resource: SavedResource, session: ChannelSession, input: Uint8Array | LogRecord): Promise { + if (!(input instanceof Uint8Array)) { + const itemId = input.kind === 'frame' ? toBase64Url(parseEnvelope(input.envelope).itemId) : input.itemId; + if (!(await canDecryptRecord(resource, session, itemId))) return; + } let reader = readers.get(resource.id); if (!reader) { reader = session.createItemReader(); @@ -39,7 +47,15 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann for (const event of events) { switch (event.type) { case 'item': { - const control = applyControl(event.item); + if (!event.item.complete || !event.item.ciphertextManifestHash + || !await verifyObjectEvent(resource, session, event.item.itemId, event.item.ciphertextManifestHash)) { + showRejected(resource.id, { type: 'rejected', itemId: event.item.itemId, seq: event.item.seq, reason: 'auth_failed' }); + break; + } + const signedEvent = await objectEventProjection(resource, session, event.item.itemId); + if (signedEvent?.kind === 'chat.edit' && signedEvent.targetItemId) + publishWorkspace(resource.id, { t: 'removed', id: signedEvent.targetItemId }); + const control = readResourceRecord(event.item); if (control !== false) { if (control && control.t === 'file-catalog') { const key = `${resource.id}:${control.item.id}`; @@ -84,6 +100,10 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann break; } case 'removed': + if (event.reason === 'deleted' && !await verifyObjectDeletion(resource, session, event.itemId)) { + showRejected(resource.id, { type: 'rejected', itemId: event.itemId, seq: event.seq, reason: 'auth_failed' }); + break; + } visibleFiles.delete(`${resource.id}:${event.itemId}`); fileCatalogs.delete(`${resource.id}:${event.itemId}`); blobs.delete(`${resource.id}:${event.itemId}`); @@ -101,19 +121,16 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann } export async function channelMetadata(resource: SavedResource, session: ChannelSession): Promise { - const info = await readJson(`/channels/${resource.id}`, session.authHeaders('read')); + const info = await readJson(`/channels/${resource.id}`, session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read')); session.verifyChannelInfo(info); - resource.name = info.settings.name; - resource.membersCanUpload = info.settings.members_can_upload; - resource.expiresAt = info.expires_at; - const latest = await getResource(resource.id); - await saveResource({ ...latest, ...resource, localItems: latest?.localItems }); + Object.assign(resource, await cacheResource(resource, channelCache(info))); publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) }); + await loadWorkspace(resource.id); const ownId = toBase64Url(session.endpointHash); const ownName = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name; let devices = [deviceInfo(ownId, Date.now(), ownName, ownId)]; if (session.can('owner')) { - const seats = await readJson>( + const seats = await readJson( `/channels/${resource.id}/seats`, session.authHeaders('owner')); devices = await Promise.all(seats.map(async (seat) => { let name: string | undefined; @@ -128,6 +145,7 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS } export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise { + if (resource.mode === 'enc') await readDataKeys(resource, session); publishWorkspace(resource.id, { t: 'reset' }); clearFileCatalog(resource.id); readers.set(resource.id, session.createItemReader()); diff --git a/web/src/core-bridge/worker/channelGrant.ts b/web/src/core-bridge/worker/channelGrant.ts index 830ad97..e05b372 100644 --- a/web/src/core-bridge/worker/channelGrant.ts +++ b/web/src/core-bridge/worker/channelGrant.ts @@ -21,17 +21,12 @@ export async function sealChannelGrant(link: string, recipient: string, publicKe export async function openChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey, role: 'owner' | 'member', origin: string): Promise { const plain = await decryptInvitation(payload, recipient, channelId, privateKey); - let parsed: ReturnType; - if (plain.startsWith('{')) { - const grant = JSON.parse(plain) as ChannelGrant; - if (grant.v !== 1 || grant.channelId !== channelId || grant.role !== role || !['enc', 'raw'].includes(grant.mode) - || !grant.keys || typeof grant.keys !== 'object') throw new Error('空间授权包校验失败'); - parsed = parseChannelLink(makeLink(origin, channelId, grant.keys)); - if (!parsed.ok || parsed.link.mode !== grant.mode) throw new Error('空间授权包模式不匹配'); - } else { - // Existing invitations hold an encrypted link; their secret never becomes a navigation URL. - parsed = parseChannelLink(plain); - } + let grant: ChannelGrant; + try { grant = JSON.parse(plain) as ChannelGrant; } catch { throw new Error('空间授权包格式无效'); } + if (!grant || grant.v !== 1 || grant.channelId !== channelId || grant.role !== role || !['enc', 'raw'].includes(grant.mode) + || !grant.keys || typeof grant.keys !== 'object') throw new Error('空间授权包校验失败'); + const parsed = parseChannelLink(makeLink(origin, channelId, grant.keys)); + if (!parsed.ok || parsed.link.mode !== grant.mode) throw new Error('空间授权包模式不匹配'); if (!parsed.ok || parsed.link.id !== channelId || parsed.link.isOwner !== (role === 'owner')) throw new Error('空间授权包权限不匹配'); return parsed.link; } diff --git a/web/src/core-bridge/worker/channelItems.ts b/web/src/core-bridge/worker/channelItems.ts index 894693a..ac5b29c 100644 --- a/web/src/core-bridge/worker/channelItems.ts +++ b/web/src/core-bridge/worker/channelItems.ts @@ -13,6 +13,8 @@ import { IntegrityError } from '@webdrop/core'; import { fileCatalogs, verifyFileCatalog } from './fileCatalog'; import { dequeueItem, saveResource } from '../database'; import { serial } from './serial'; +import { signObjectDeletion } from './channelDataKeys'; +import { accountToken } from './accountSession'; export function connectChannelResource(id: string): () => void { void connectChannel(id); @@ -23,7 +25,8 @@ export async function removeChannelItem(resourceId: string, itemId: string): Pro const { session, resource } = await getSession(resourceId); const role = session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'; await serial(`outbox:${resourceId}`, async () => { - await apiFetch(`/channels/${resourceId}/items/${itemId}`, { method: 'DELETE', headers: session.authHeaders(role) }); + const eventHeaders = resource.kind === 'channel' && accountToken() ? await signObjectDeletion(resource, session, itemId) : {}; + await apiFetch(`/channels/${resourceId}/items/${itemId}`, { method: 'DELETE', headers: { ...session.authHeaders(role), ...eventHeaders } }); await dequeueItem(resourceId, itemId); resource.localItems = resource.localItems?.filter((item) => item.id !== itemId); await saveResource(resource); diff --git a/web/src/core-bridge/worker/channelResources.ts b/web/src/core-bridge/worker/channelResources.ts index 459d7d0..6e6fdb7 100644 --- a/web/src/core-bridge/worker/channelResources.ts +++ b/web/src/core-bridge/worker/channelResources.ts @@ -1,9 +1,13 @@ import { ChannelSession, toBase64Url } from '@webdrop/core'; -import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; +import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; import type { ParsedChannelLink } from '@/lib/channelLink'; import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types'; -import { apiFetch, readJson } from './http'; +import { apiFetch, readJson, getServerInfo } from './http'; +import { channelCache, isOfflineFailure } from './resourceMetadata'; +import { accountToken } from './accountSession'; +import { attachDataKeys, initializeChannelKeys } from './channelDataKeys'; +import type { ChannelSettingsPatch as SettingsRequest, SeatJoined, MemberBody } from '../generated/api'; import { getSession, sessions } from './sessions'; import { deviceIdentity } from './presentation'; import { makeLink } from './links'; @@ -13,26 +17,26 @@ import { publishWorkspace } from './presentation'; import { channelMetadata } from './channelEvents'; import { requireAccount, accountPublicKey } from './accountSession'; import { sealChannelGrant } from './channelGrant'; -import { createThread } from './channelThreads'; export async function createChannel(options: CreateOptions, captchaToken?: string): Promise { const username = requireAccount(); + const defaults = (await getServerInfo()).defaults?.channel; + if (!defaults) throw new Error('服务端需要升级以提供统一设置'); + if (defaults.join_policy !== 'open' && defaults.join_policy !== 'invite') throw new Error('空间加入规则无效'); const endpointSecret = crypto.getRandomValues(new Uint8Array(32)); const { session, request } = await ChannelSession.create({ mode: options.mode, endpointSecret, settings: { + ...defaults, name: options.name?.trim() || undefined, seats: options.seats, ttl_seconds: options.ttlSeconds, members_can_upload: options.membersCanUpload, - delete_policy: 'own', - max_items: 1000, - file_max_downloads: options.fileMaxDownloads ?? 10, - ui_restrict_copy_download: false, - join_policy: options.joinPolicy ?? 'invite', allow_guests: options.allowGuests ?? false, + file_max_downloads: options.fileMaxDownloads, + join_policy: options.joinPolicy ?? defaults.join_policy, allow_guests: options.allowGuests ?? defaults.allow_guests, }, }); try { @@ -42,13 +46,14 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin }).then((response) => response.json() as Promise); session.verifyChannelInfo(info); const resource: SavedResource = { + ...channelCache(info), id: session.id, - creatorAccount: info.creator_account, + creatorAccount: info.creator_account ?? undefined, kind: 'channel', mode: session.mode, role: 'owner', - name: info.settings.name, + name: info.settings.name ?? undefined, link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'), endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at, - seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, + seats: info.settings.seats, maxDownloads: info.settings.file_max_downloads, localItems: [], }; const ownerLabel = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name); @@ -57,7 +62,9 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin }); await saveResource(resource); sessions.set(session.id, session); - await createThread(session.id, '常规'); + attachDataKeys(session, resource); + const workspace = await import('./workspaceStructure').then(module => module.loadWorkspace(session.id)); + if (workspace.structure.defaultChannelId) await initializeChannelKeys(resource, session, workspace.structure.defaultChannelId); return toChannelInfo(info, resource); } catch (error) { session.dispose(); @@ -75,15 +82,16 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s session.verifyChannelInfo(info); const joined = await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }), - }).then((response) => response.json() as Promise<{ seats_used: number }>); + }).then((response) => response.json() as Promise); info.seats_used = joined.seats_used; const resource: SavedResource = { + ...channelCache(info), id, kind: 'channel', mode: session.mode, role: session.role, - creatorAccount: info.creator_account, - name: info.settings.name, + creatorAccount: info.creator_account ?? undefined, + name: info.settings.name ?? undefined, link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined, endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at, - seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, + seats: info.settings.seats, maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [], }; const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name); @@ -106,28 +114,52 @@ export async function previewChannel(link: ParsedChannelLink): Promise { +export async function channelMembers(id: string): Promise { const { session } = await getSession(id); return readJson(`/channels/${id}/members`, session.authHeaders(session.can('owner') ? 'owner' : 'read')); } export async function listChannels(): Promise { const saved = await listResources('channel'); - return saved.map((resource) => ({ + let catalog: Map | undefined; + if (accountToken()) { + try { + const rows = await apiFetch('/accounts/spaces').then(r => r.json() as Promise); + catalog = new Map(rows.map(row => [row.id, row])); + } catch (error) { if (!isOfflineFailure(error)) throw error; } + } + const rows = await Promise.all(saved.map(async (resource): Promise => { + try { + if (catalog && !catalog.has(resource.id)) return null; + const { session } = await getSession(resource.id); + const raw = catalog?.get(resource.id) ?? await readJson(`/channels/${resource.id}`, session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read')); + session.verifyChannelInfo(raw); + const info = toChannelInfo(raw, resource); + await cacheResource(resource, channelCache(raw)); + return { ...info, dataSource: 'server' }; + } catch (error) { + if (error instanceof Error && 'status' in error && (error.status === 404 || error.status === 410)) return null; + if (!isOfflineFailure(error)) throw error; + if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' }; + return { id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, seats: resource.seats, maxDownloads: resource.maxDownloads ?? undefined, creatorAccount: resource.creatorAccount, memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt, + dataSource: 'cache', + }; + } })); + return rows.filter((row): row is ChannelInfo => row !== null); } export async function updateChannelSettings(resourceId: string, patch: ChannelSettingsPatch): Promise { const { session, resource } = await getSession(resourceId); if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置'); - const body: Record = {}; + const body: SettingsRequest = {}; if (patch.name !== undefined) body.name = patch.name.trim(); if (patch.seats !== undefined) body.seats = patch.seats; if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload; @@ -141,13 +173,8 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe body: JSON.stringify(body), }).then((response) => response.json() as Promise); session.verifyChannelInfo(info); - resource.name = info.settings.name; - resource.seats = info.settings.seats; - resource.membersCanUpload = info.settings.members_can_upload; - resource.maxDownloads = info.settings.file_max_downloads; - resource.expiresAt = info.expires_at; - await saveResource(resource); - publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, resource) }); + const updated = await cacheResource(resource, channelCache(info)); + publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) }); } export async function renameDevice(resourceId: string, endpointHash: string, name: string): Promise { diff --git a/web/src/core-bridge/worker/channelSend.ts b/web/src/core-bridge/worker/channelSend.ts index 3953629..dcdd4a2 100644 --- a/web/src/core-bridge/worker/channelSend.ts +++ b/web/src/core-bridge/worker/channelSend.ts @@ -1,9 +1,10 @@ import { removeChannelItem } from './channelItems'; -import { queueFileCatalog, queueItemAuthor } from './channelThreads'; +import { queueFileCatalog } from './channelThreads'; import { senderSnapshot } from './senderSnapshot'; +import { prepareObject } from './channelDataKeys'; import { errorMessage } from '../errors'; -import { saveResource } from '../database'; +import { saveResource, queueLocation } from '../database'; import { previewKindOf } from '@/lib/fileKind'; import type { FileItem, FileUploadOptions, Item } from '../types'; @@ -16,31 +17,35 @@ import { deviceIdentity } from './presentation'; import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer'; import { publishWorkspace } from './presentation'; -export async function sendText(resourceId: string, text: string, threadId?: string): Promise<{ itemId: string; uploaded: boolean }> { +export async function sendText(resourceId: string, text: string, threadId?: string, + event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }): Promise<{ itemId: string; uploaded: boolean }> { if (!threadId) throw new Error('请先选择频道'); const { session, resource } = await getSession(resourceId); - if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限'); + if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限'); const identity = await deviceIdentity(session.endpointSecret); const device = { ...identity, name: resource.deviceLabel ?? identity.name }; session.deviceName = device.name; const ts = Date.now(); const author = await senderSnapshot(); - const plan = session.encryptText(text, { dev: device.name, ts, threadId }); + const prepared = await prepareObject(resource, session, threadId, 'chat'); + const plan = session.encryptText(text, { dev: device.name, ts, threadId, itemId: prepared.itemIdBytes }); const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}) }; blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' })); publishWorkspace(resourceId, { t: 'item', item }); - await persistPlan(resource, plan); - if (author) await queueItemAuthor(resource, session, plan.itemId, author, threadId, ts); + await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, + eventKind: event?.kind ?? 'chat.create', ...(event?.targetItemId ? { targetItemId: event.targetItemId } : {}) }); + await queueLocation(resourceId, item.id, threadId); const uploaded = await flushOutbox(resource, session); publishWorkspace(resourceId, { t: 'item', item: { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } } }); if (!uploaded) publishWorkspace(resourceId, { t: 'connection', state: 'offline' }); return { itemId: plan.itemId, uploaded }; } -export async function sendFiles(resourceId: string, files: File[], options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId?: string, folderId?: string): Promise { +export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string): Promise { if (!threadId) throw new Error('请先选择频道'); const { session, resource } = await getSession(resourceId); - if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限'); + const options = suppliedOptions ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 }; + if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限'); const identity = await deviceIdentity(session.endpointSecret); const device = { ...identity, name: resource.deviceLabel ?? identity.name }; session.deviceName = device.name; @@ -50,7 +55,9 @@ export async function sendFiles(resourceId: string, files: File[], options: File const ts = Date.now(); let item: FileItem | undefined; try { + const prepared = await prepareObject(resource, session, threadId, 'files'); const plan = session.encryptFile(file, {}, { + itemId: prepared.itemIdBytes, ...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }), dev: device.name, ts, maxFrameData, threadId, folderId, }); @@ -60,7 +67,7 @@ export async function sendFiles(resourceId: string, files: File[], options: File ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), name: file.name, mime: file.type || 'application/octet-stream', size: file.size, remaining: plan.gated - ? (options.directPreview ? resource.maxDownloads ?? 10 : options.maxDownloads) || null + ? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null : null, preview: previewKindOf(file.type, file.name), gated: plan.gated, }; @@ -84,9 +91,10 @@ export async function sendFiles(resourceId: string, files: File[], options: File const planned = await persistPlan(resource, plan, (_frames) => { const processed = Math.min(file.size, _frames * MAX_FRAME_DATA); updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting'); - }); + }, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.publish' }); const uploadedFrames = new Set(); await queueFileCatalog(resource, session, item); + await queueLocation(resourceId, item.id, threadId, folderId); let uploadedBytes = 0; const uploadStartedAt = performance.now(); const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => { @@ -120,13 +128,16 @@ export async function sendFiles(resourceId: string, files: File[], options: File export async function updateSnapshot(resourceId: string, text: string): Promise { const { session, resource } = await getSession(resourceId); const name = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name; - const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name }); - await persistPlan(resource, plan); + const { structure } = await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId)); + if (!structure.defaultChannelId) throw new Error('请先创建默认频道'); + const prepared = await prepareObject(resource, session, structure.defaultChannelId, 'chat'); + const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name, itemId: prepared.itemIdBytes }); + await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'chat.snapshot' }); await flushOutbox(resource, session); } export async function editChannelText(resourceId: string, itemId: string, text: string, threadId?: string): Promise { - const created = await sendText(resourceId, text, threadId); + const created = await sendText(resourceId, text, threadId, { kind: 'chat.edit', targetItemId: itemId }); if (!created.uploaded) throw new Error('新版本已排队,网络恢复前保留旧内容。'); await removeChannelItem(resourceId, itemId); } diff --git a/web/src/core-bridge/worker/channelThreads.ts b/web/src/core-bridge/worker/channelThreads.ts index 6d17043..17f6904 100644 --- a/web/src/core-bridge/worker/channelThreads.ts +++ b/web/src/core-bridge/worker/channelThreads.ts @@ -1,172 +1,55 @@ -import type { ChannelSession, ReceivedItem } from '@webdrop/core'; +import type { ChannelSession } from '@webdrop/core'; import type { SavedResource } from '../database'; -import type { FileItem, FolderInfo, SenderInfo, ThreadInfo, WorkspaceEvent } from '../types'; -import { readSender } from '@/lib/sender'; -import { previewKindOf } from '@/lib/fileKind'; -import { publishWorkspace } from './presentation'; +import type { FileItem } from '../types'; +import { persistPlan } from './transfer'; +import { changeWorkspace, createWorkspaceChannel, createWorkspaceGroup } from './workspaceStructure'; import { getSession } from './sessions'; -import { deviceIdentity } from './presentation'; -import { flushOutbox, persistPlan } from './transfer'; +import { initializeChannelKeys, prepareObject } from './channelDataKeys'; +import { toPublishedFile } from './fileCatalog'; +import { apiFetch } from './http'; +import { WORKSPACE_APP, type ResourceControl } from '../protocol/workspace'; +import type { WorkspaceChannelRequest, WorkspaceGroupRequest, WorkspaceNameRequest, WorkspaceFolderRequest, WorkspaceMoveRequest } from '../generated/api'; -const CONTROL_APP = 'webdrop.workspace'; const encoder = new TextEncoder(); -type Control = - | { op: 'item.author'; itemId: string; author: SenderInfo } - | { op: 'file.published'; item: FileItem } - | { op: 'thread.create'; id: string; name: string; groupId?: string } - | { op: 'group.create'; id: string; name: string } - | { op: 'group.rename'; id: string; name: string } - | { op: 'thread.group'; id: string; groupId: string | null } - | { op: 'thread.rename'; id: string; name: string } - | { op: 'folder.create'; id: string; threadId: string; parentId?: string; name: string } - | { op: 'folder.rename'; id: string; name: string } - | { op: 'items.move'; itemIds: string[]; threadId: string; folderId: string | null }; -function cleanName(input: string): string { - const name = input.trim(); - if (!name || name.length > 80 || /[\u0000-\u001f]/.test(name)) throw new Error('名称需为 1–80 个字符,不能包含控制字符'); - return name; -} - -async function sendControl(resourceId: string, control: Control, threadId?: string): Promise { - const { session, resource } = await getSession(resourceId); - if (resource.kind !== 'channel') throw new Error('资源类型不匹配'); - if (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload)) throw new Error('此链接没有上传权限'); - const device = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name; - const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, ...control })), { - kind: 'other', threadId, dev: device, - }, { ts: Date.now(), dev: device, control: true }); - await persistPlan(resource, plan); - const event = controlEvent(control, Date.now(), device); - if (event) publishWorkspace(resourceId, event); - await flushOutbox(resource, session); -} - -export async function createThread(resourceId: string, input: string, groupId?: string): Promise { - if (groupId !== undefined && !isId(groupId)) throw new Error('频道分组格式无效'); - const id = crypto.randomUUID(); - await sendControl(resourceId, { op: 'thread.create', id, name: cleanName(input), ...(groupId ? { groupId } : {}) }); +export async function createThread(resourceId: string, name: string, groupId?: string): Promise { + const id = await createWorkspaceChannel(resourceId, { name, groupId } satisfies WorkspaceChannelRequest); + const { resource, session } = await getSession(resourceId); await initializeChannelKeys(resource, session, id); return id; } - -export async function createGroup(resourceId: string, input: string): Promise { - const id = crypto.randomUUID(); await sendControl(resourceId, { op: 'group.create', id, name: cleanName(input) }); return id; +export async function createGroup(resourceId: string, name: string): Promise { + return createWorkspaceGroup(resourceId, { name } satisfies WorkspaceNameRequest); } -export async function renameGroup(resourceId: string, id: string, input: string): Promise { - if (!isId(id)) throw new Error('频道分组格式无效'); - await sendControl(resourceId, { op: 'group.rename', id, name: cleanName(input) }); +export async function renameGroup(resourceId: string, id: string, name: string): Promise { + await changeWorkspace(resourceId, 'groups/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest); } export async function groupThread(resourceId: string, id: string, groupId?: string): Promise { - if (!isId(id) || groupId !== undefined && !isId(groupId)) throw new Error('频道分组格式无效'); - await sendControl(resourceId, { op: 'thread.group', id, groupId: groupId ?? null }, id); + await changeWorkspace(resourceId, 'channels/' + id + '/group', 'PUT', { groupId: groupId ?? null } satisfies WorkspaceGroupRequest); } - -export async function renameThread(resourceId: string, id: string, input: string): Promise { - await sendControl(resourceId, { op: 'thread.rename', id, name: cleanName(input) }, id); +export async function renameThread(resourceId: string, id: string, name: string): Promise { + await changeWorkspace(resourceId, 'channels/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest); } - -export async function createFolder(resourceId: string, threadId: string, input: string, parentId?: string): Promise { - await sendControl(resourceId, { op: 'folder.create', id: crypto.randomUUID(), threadId, ...(parentId ? { parentId } : {}), name: cleanName(input) }, threadId); +export async function setChannelHistory(resourceId: string, id: string, chatHistory: boolean, filesHistory: boolean): Promise { + const { session } = await getSession(resourceId); + await apiFetch(`/spaces/${resourceId}/channels/${id}/history`, { method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ chatHistory, filesHistory }) }); + await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId)); } - -export async function renameFolder(resourceId: string, id: string, input: string, threadId: string): Promise { - await sendControl(resourceId, { op: 'folder.rename', id, name: cleanName(input) }, threadId); +export async function createFolder(resourceId: string, threadId: string, name: string, parentId?: string): Promise { + await changeWorkspace(resourceId, 'folders', 'POST', { threadId, name, parentId } satisfies WorkspaceFolderRequest); +} +export async function renameFolder(resourceId: string, id: string, name: string, _threadId: string): Promise { + await changeWorkspace(resourceId, 'folders/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest); } - export async function moveItems(resourceId: string, itemIds: string[], threadId: string, folderId?: string): Promise { - if (itemIds.length < 1 || itemIds.length > 100) throw new Error('一次最多移动 100 个文件'); - await sendControl(resourceId, { op: 'items.move', itemIds, threadId, folderId: folderId ?? null }, threadId); + await changeWorkspace(resourceId, 'locations', 'PUT', { itemIds, threadId, folderId } satisfies WorkspaceMoveRequest); } export async function queueFileCatalog(resource: SavedResource, session: ChannelSession, item: FileItem): Promise { - const control: Control = { op: 'file.published', item: { ...item, state: { kind: 'complete' } } }; - const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, ...control })), - { kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true }); - await persistPlan(resource, plan); -} - -export async function queueItemAuthor(resource: SavedResource, session: ChannelSession, itemId: string, author: SenderInfo, threadId: string, ts: number): Promise { - const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, op: 'item.author', itemId, author })), - { kind: 'other', threadId }, { ts, control: true }); - await persistPlan(resource, plan); -} - -function isId(value: unknown): value is string { - return typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value); -} -function isItemId(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value); } -function validName(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0 && value.length <= 80 && !/[\u0000-\u001f]/.test(value); -} - -function controlEvent(control: Control, ts: number, author: string): WorkspaceEvent | null { - switch (control.op) { - case 'item.author': return { t: 'item-author', id: control.itemId, author: control.author }; - case 'file.published': return { t: 'file-catalog', item: control.item }; - case 'thread.create': return { t: 'thread-created', thread: { id: control.id, name: control.name, createdAt: ts, createdBy: author, ...(control.groupId ? { groupId: control.groupId } : {}) } }; - case 'group.create': return { t: 'group-created', group: { id: control.id, name: control.name, createdAt: ts } }; - case 'group.rename': return { t: 'group-renamed', id: control.id, name: control.name }; - case 'thread.group': return { t: 'thread-grouped', id: control.id, ...(control.groupId ? { groupId: control.groupId } : {}) }; - case 'thread.rename': return { t: 'thread-renamed', id: control.id, name: control.name }; - case 'folder.create': return { t: 'folder-created', folder: { id: control.id, threadId: control.threadId, ...(control.parentId ? { parentId: control.parentId } : {}), name: control.name } }; - case 'folder.rename': return { t: 'folder-renamed', id: control.id, name: control.name }; - case 'items.move': return { t: 'items-moved', itemIds: control.itemIds, threadId: control.threadId, ...(control.folderId ? { folderId: control.folderId } : {}) }; - } -} - -export function applyControl(item: ReceivedItem): WorkspaceEvent | false { - if (item.meta?.kind !== 'other') return false; - let value: unknown; - try { value = JSON.parse(new TextDecoder().decode(item.content)); } catch { return false; } - if (!value || typeof value !== 'object' || (value as { app?: unknown }).app !== CONTROL_APP) return false; - const row = value as Record; - if (row.v !== 1 || typeof row.op !== 'string') return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - const author = item.meta.dev ?? '未知设备'; - switch (row.op) { - case 'item.author': { - const sender = readSender(row.author); - return isItemId(row.itemId) && sender ? { t: 'item-author', id: row.itemId, author: sender } : { t: 'error', message: '发送者资料格式无效,已拒绝应用。' }; - } - case 'file.published': { - const file = row.item as FileItem | undefined; - const sender = readSender(file?.author); - if (!file || !isItemId(file.id) || !isId(file.threadId) || (file.folderId !== undefined && !isId(file.folderId)) - || file.kind !== 'file' || typeof file.name !== 'string' || file.name.length > 1024 || typeof file.mime !== 'string' || file.mime.length > 256 - || !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean' || file.author !== undefined && !sender) return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' }; - return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, ...(file.folderId ? { folderId: file.folderId } : {}), - name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, deviceId: item.meta.dev ?? '未知设备', - ...(sender ? { author: sender } : {}), - gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } }; - } - case 'thread.create': - return isId(row.id) && validName(row.name) && (row.groupId === undefined || isId(row.groupId)) - ? { t: 'thread-created', thread: { id: row.id, name: row.name, createdAt: item.meta.ts, createdBy: author, - ...(row.groupId ? { groupId: row.groupId as string } : {}) } satisfies ThreadInfo } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - case 'group.create': - return isId(row.id) && validName(row.name) ? { t: 'group-created', group: { id: row.id, name: row.name, createdAt: item.meta.ts } } - : { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' }; - case 'group.rename': - return isId(row.id) && validName(row.name) ? { t: 'group-renamed', id: row.id, name: row.name } - : { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' }; - case 'thread.group': - return isId(row.id) && (row.groupId === null || isId(row.groupId)) ? { t: 'thread-grouped', id: row.id, ...(row.groupId ? { groupId: row.groupId as string } : {}) } - : { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' }; - case 'thread.rename': - return isId(row.id) && typeof row.name === 'string' && row.name.length > 0 && row.name.length <= 80 - ? { t: 'thread-renamed', id: row.id, name: row.name } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - case 'folder.create': - if (!isId(row.id) || !isId(row.threadId) || (row.parentId !== undefined && !isId(row.parentId)) - || typeof row.name !== 'string' || row.name.length < 1 || row.name.length > 80) return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - return { t: 'folder-created', folder: { id: row.id, threadId: row.threadId, ...(row.parentId ? { parentId: row.parentId as string } : {}), name: row.name } satisfies FolderInfo }; - case 'folder.rename': - return isId(row.id) && typeof row.name === 'string' && row.name.length > 0 && row.name.length <= 80 - ? { t: 'folder-renamed', id: row.id, name: row.name } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - case 'items.move': - if (!Array.isArray(row.itemIds) || row.itemIds.length > 100 || !row.itemIds.every(isItemId) || !isId(row.threadId) - || (row.folderId !== null && !isId(row.folderId))) return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - return { t: 'items-moved', itemIds: row.itemIds, threadId: row.threadId, ...(row.folderId ? { folderId: row.folderId as string } : {}) }; - default: - return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' }; - } + if (!item.threadId) throw new Error('请先选择频道'); + const prepared = await prepareObject(resource, session, item.threadId, 'files'); + const control: ResourceControl = { op: 'file.published', item: toPublishedFile(item) }; + const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: WORKSPACE_APP, v: 1, ...control })), + { kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true, itemId: prepared.itemIdBytes }); + await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.catalog' }); } diff --git a/web/src/core-bridge/worker/domainCrypto.test.ts b/web/src/core-bridge/worker/domainCrypto.test.ts new file mode 100644 index 0000000..c531cd8 --- /dev/null +++ b/web/src/core-bridge/worker/domainCrypto.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto'; +import { openJson, sealJson } from './accountCrypto'; + +describe('channel data-domain object keys', () => { + it('uses random independent file keys and binds envelopes to channel, domain, epoch and object', async () => { + const domain = randomDomainKey(); + const context = { spaceId: 'space', channelId: 'channel-a', domain: 'files' as const, epoch: 1, itemId: 'file-a' }; + const a = await newObjectKey(domain, context); const b = await newObjectKey(domain, context); + expect(a.key.extractable).toBe(false); expect(a.envelope).not.toBe(b.envelope); + const ciphertext = await sealJson({ data: 'private file' }, a.key); + const recovered = await unwrapObjectKey(domain, a.envelope, context); + expect(await openJson(ciphertext, recovered)).toEqual({ data: 'private file' }); + await expect(openJson(ciphertext, b.key)).rejects.toThrow(); + await expect(unwrapObjectKey(randomDomainKey(), a.envelope, context)).rejects.toThrow(); + for (const changed of [{ ...context, spaceId: 'other-space' }, { ...context, channelId: 'channel-b' }, { ...context, domain: 'chat' as const }, { ...context, epoch: 2 }, { ...context, itemId: 'file-b' }]) + await expect(unwrapObjectKey(domain, a.envelope, changed)).rejects.toThrow(); + }); +}); diff --git a/web/src/core-bridge/worker/domainCrypto.ts b/web/src/core-bridge/worker/domainCrypto.ts new file mode 100644 index 0000000..6e0d8e1 --- /dev/null +++ b/web/src/core-bridge/worker/domainCrypto.ts @@ -0,0 +1,22 @@ +import { bytes, b64, unb64, sealJson, openJson } from './accountCrypto'; + +export interface ObjectContext { spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string } +const label = (c: ObjectContext) => JSON.stringify(['webdrop/object-key/v1', c.spaceId, c.channelId, c.domain, c.epoch, c.itemId]); +export const randomDomainKey = () => b64(crypto.getRandomValues(new Uint8Array(32))); +async function aes(value: string): Promise { + const material = unb64(value); + try { if (material.length !== 32) throw new Error('数据域密钥格式无效'); return await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']); } + finally { material.fill(0); } +} +export async function newObjectKey(domainKey: string, context: ObjectContext): Promise<{ key: CryptoKey; envelope: string }> { + const raw = crypto.getRandomValues(new Uint8Array(32)); + try { + const key = await crypto.subtle.importKey('raw', bytes(raw), 'AES-GCM', false, ['encrypt', 'decrypt']); + return { key, envelope: await sealJson({ key: b64(raw) }, await aes(domainKey), label(context)) }; + } finally { raw.fill(0); } +} +export async function unwrapObjectKey(domainKey: string, envelope: string, context: ObjectContext): Promise { + const value = await openJson(envelope, await aes(domainKey), label(context)) as { key?: unknown }; + if (typeof value.key !== 'string') throw new Error('对象密钥封装格式无效'); + return aes(value.key); +} diff --git a/web/src/core-bridge/worker/downloadStorage.ts b/web/src/core-bridge/worker/downloadStorage.ts index 8389e4f..5974efa 100644 --- a/web/src/core-bridge/worker/downloadStorage.ts +++ b/web/src/core-bridge/worker/downloadStorage.ts @@ -40,7 +40,8 @@ export async function storeVerifiedDownload( decoded: { content: ReadableStream; result: Promise }, expectedSize?: number, createTemporary: () => Promise = temporaryFile, -): Promise<{ blob: Blob; meta: ItemDecodeResult['meta'] }> { + verifyResult?: (result: ItemDecodeResult) => Promise, +): Promise<{ blob: Blob; meta: ItemDecodeResult['meta']; ciphertextManifestHash?: Uint8Array }> { const reader = decoded.content.getReader(); let temporary: TemporaryFile | undefined; const parts: ArrayBuffer[] = []; @@ -64,8 +65,9 @@ export async function storeVerifiedDownload( const result = await decoded.result; if (!result.complete) throw new Error('文件传输不完整'); if (expectedSize !== undefined && size !== expectedSize || result.meta?.size !== undefined && size !== result.meta.size) throw new IntegrityError('bad_meta'); + await verifyResult?.(result); if (temporary) { await temporary.close(); closed = true; } - return { blob: temporary ? await temporary.file() : new Blob(parts), meta: result.meta }; + return { blob: temporary ? await temporary.file() : new Blob(parts), meta: result.meta, ciphertextManifestHash: result.ciphertextManifestHash }; } catch (error) { await reader.cancel(error).catch(() => {}); if (temporary) { diff --git a/web/src/core-bridge/worker/fileCatalog.ts b/web/src/core-bridge/worker/fileCatalog.ts index 527dab5..cec15d7 100644 --- a/web/src/core-bridge/worker/fileCatalog.ts +++ b/web/src/core-bridge/worker/fileCatalog.ts @@ -1,5 +1,13 @@ import type { FileItem } from '../types'; import { IntegrityError, type ItemMeta } from '@webdrop/core'; +import type { PublishedFile } from '../protocol/workspace'; + +export function toPublishedFile(item: FileItem): PublishedFile { + if (!item.threadId) throw new Error('请先选择频道'); + return { id: item.id, kind: 'file', threadId: item.threadId, folderId: item.folderId, + name: item.name, mime: item.mime, size: item.size, ts: item.ts, deviceId: item.deviceId, + gated: item.gated ?? false }; +} export const fileCatalogs = new Map(); export const visibleFiles = new Set(); diff --git a/web/src/core-bridge/worker/groupControl.test.ts b/web/src/core-bridge/worker/groupControl.test.ts deleted file mode 100644 index a998a22..0000000 --- a/web/src/core-bridge/worker/groupControl.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ChannelSession, collectFrames } from '@webdrop/core'; -import { applyControl } from './channelThreads'; - -const groupId = 'd7a19ad2-690c-44f2-bd0a-1b30b309bf92'; -const threadId = '15e7fb09-984d-47d4-8e74-477c7d69c58c'; -async function decode(control: Record) { - const { session } = await ChannelSession.create(); - try { - const frames = await collectFrames(session.encryptBytes(new TextEncoder().encode(JSON.stringify({ app: 'webdrop.workspace', v: 1, ...control })), - { kind: 'other' }, { control: true, ts: 1 })); - const event = (await session.createItemReader().pushFrame(frames[0]!.bytes)).find((row) => row.type === 'item'); - if (!event || event.type !== 'item') throw new Error('missing decoded control'); - return applyControl(event.item); - } finally { session.dispose(); } -} -describe('encrypted channel groups', () => { - it('replays the sender profile from an encrypted identity record', async () => { - const author = { username: 'alice', displayName: '小白', avatar: 'data:image/png;base64,AA==' }; - expect(await decode({ op: 'item.author', itemId: 'AAAAAAAAAAAAAAAAAAAAAA', author })).toEqual({ t: 'item-author', id: 'AAAAAAAAAAAAAAAAAAAAAA', author }); - expect(await decode({ op: 'item.author', itemId: 'AAAAAAAAAAAAAAAAAAAAAA', author: { ...author, avatar: 'https://tracker.test/avatar' } })).toMatchObject({ t: 'error' }); - }); - it('replays a group and a channel created inside it', async () => { - expect(await decode({ op: 'group.create', id: groupId, name: '项目' })).toEqual({ t: 'group-created', group: { id: groupId, name: '项目', createdAt: 1 } }); - expect(await decode({ op: 'thread.create', id: threadId, name: '讨论', groupId })).toMatchObject({ t: 'thread-created', thread: { id: threadId, groupId } }); - }); - it('replays moves and returning to ungrouped', async () => { - expect(await decode({ op: 'thread.group', id: threadId, groupId })).toEqual({ t: 'thread-grouped', id: threadId, groupId }); - expect(await decode({ op: 'thread.group', id: threadId, groupId: null })).toEqual({ t: 'thread-grouped', id: threadId }); - expect(await decode({ op: 'group.rename', id: groupId, name: '资料' })).toEqual({ t: 'group-renamed', id: groupId, name: '资料' }); - }); - it.each([ - { op: 'group.create', id: 'invalid', name: '项目' }, - { op: 'group.rename', id: groupId, name: ' ' }, - { op: 'group.create', id: groupId, name: '\u0000project' }, - { op: 'thread.group', id: threadId, groupId: 'invalid' }, - { op: 'thread.group', id: threadId }, - { op: 'thread.create', id: threadId, name: '讨论', groupId: 'invalid' }, - ])('rejects malformed control %#', async (control) => { - expect(await decode(control)).toMatchObject({ t: 'error' }); - }); -}); diff --git a/web/src/core-bridge/worker/http.ts b/web/src/core-bridge/worker/http.ts index fdada35..cbaf0d4 100644 --- a/web/src/core-bridge/worker/http.ts +++ b/web/src/core-bridge/worker/http.ts @@ -1,9 +1,10 @@ import { supportsApiContract } from '../contract'; import { accountToken } from './accountSession'; +import type { ServerInfoBody } from '../generated/api'; export type ApiError = Error & { status?: number; code?: string }; let apiBase = '/api/v1'; -let contractCheck: Promise | undefined; +let contractCheck: Promise | undefined; export function setApiBase(value: string): void { if (apiBase === value) return; @@ -20,15 +21,16 @@ export const apiPath = (path: string): string => new URL( currentOrigin(), ).toString(); -function ensureApiContract(): Promise { +export function getServerInfo(): Promise { contractCheck ??= fetch(apiPath('/info'), { cache: 'no-store', credentials: 'omit', referrerPolicy: 'no-referrer', }).then(async (response) => { if (!response.ok) throw new Error('api_info_unavailable'); - const info = await response.json() as { contract_version?: unknown }; + const info = await response.json() as ServerInfoBody; if (!supportsApiContract(info.contract_version)) throw new Error('api_contract_mismatch'); + return info; }).catch((error: unknown) => { contractCheck = undefined; throw error; @@ -38,7 +40,7 @@ function ensureApiContract(): Promise { export async function apiFetch(path: string, init: RequestInit = {}): Promise { try { - if (path !== '/info') await ensureApiContract(); + if (path !== '/info') await getServerInfo(); const headers = new Headers(init.headers); const token = accountToken(); if (token && !headers.has('X-WD-Account')) headers.set('X-WD-Account', token); @@ -71,7 +73,7 @@ export async function apiUpload( onProgress: (loadedBytes: number, totalBytes: number) => void, ): Promise { try { - await ensureApiContract(); + await getServerInfo(); const response = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', apiPath(path)); diff --git a/web/src/core-bridge/worker/passkeyCrypto.test.ts b/web/src/core-bridge/worker/passkeyCrypto.test.ts new file mode 100644 index 0000000..138ad79 --- /dev/null +++ b/web/src/core-bridge/worker/passkeyCrypto.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { openPasskeyKey, sealPasskeyKey } from './passkeyCrypto'; +import { bytes, openJson, sealJson } from './accountCrypto'; + +describe('Passkey recovery key envelopes', () => { + it('recovers the same non-exportable vault key without exposing plaintext to the backend', async () => { + const material = crypto.getRandomValues(new Uint8Array(32)); const prf = crypto.getRandomValues(new Uint8Array(32)); + const key = await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']); + const vault = await sealJson({ private: 'test-private-identity' }, key); + const envelope = await sealPasskeyKey(material, bytes(prf), 'alice', 'credential-a'); + expect(envelope).not.toContain('test-private-identity'); + const recovered = await openPasskeyKey(envelope, bytes(prf), 'alice', 'credential-a'); + expect(recovered.extractable).toBe(false); expect(await openJson(vault, recovered)).toEqual({ private: 'test-private-identity' }); + await expect(openPasskeyKey(envelope, bytes(prf), 'bob', 'credential-a')).rejects.toThrow(); + await expect(openPasskeyKey(envelope, bytes(prf), 'alice', 'credential-b')).rejects.toThrow(); + await expect(openPasskeyKey(envelope, bytes(crypto.getRandomValues(new Uint8Array(32))), 'alice', 'credential-a')).rejects.toThrow(); + }); + it('rejects malformed PRF outputs', async () => { + await expect(sealPasskeyKey(new Uint8Array(32), new ArrayBuffer(31), 'alice', 'credential')).rejects.toThrow(); + }); +}); diff --git a/web/src/core-bridge/worker/passkeyCrypto.ts b/web/src/core-bridge/worker/passkeyCrypto.ts new file mode 100644 index 0000000..3314399 --- /dev/null +++ b/web/src/core-bridge/worker/passkeyCrypto.ts @@ -0,0 +1,23 @@ +import { bytes, sealJson, openJson, b64, unb64 } from './accountCrypto'; + +const label = 'webdrop/passkey-recovery/v1'; +async function prfKey(result: ArrayBuffer): Promise { + if (result.byteLength !== 32) throw new Error('Passkey恢复输出格式无效'); + const base = await crypto.subtle.importKey('raw', result, 'HKDF', false, ['deriveKey']); + return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: new TextEncoder().encode(label) }, base, + { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); +} +const context = (username: string, credentialId: string) => `${label}:${username}:${credentialId}`; +export async function sealPasskeyKey(material: Uint8Array, result: ArrayBuffer, username: string, credentialId: string): Promise { + if (material.byteLength !== 32) throw new Error('账号恢复密钥格式无效'); + return sealJson({ key: b64(material) }, await prfKey(result), context(username, credentialId)); +} +export async function openPasskeyKey(envelope: string, result: ArrayBuffer, username: string, credentialId: string): Promise { + const value = await openJson(envelope, await prfKey(result), context(username, credentialId)) as { key?: unknown }; + if (typeof value.key !== 'string') throw new Error('账号恢复密钥格式无效'); + const material = unb64(value.key); + try { + if (material.length !== 32) throw new Error('账号恢复密钥格式无效'); + return await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']); + } finally { material.fill(0); } +} diff --git a/web/src/core-bridge/worker/passkeys.ts b/web/src/core-bridge/worker/passkeys.ts new file mode 100644 index 0000000..9742c34 --- /dev/null +++ b/web/src/core-bridge/worker/passkeys.ts @@ -0,0 +1,85 @@ +import type { AccountSaltBody, AccountVaultBody, PasskeyFinishRequest, PasskeyInfoBody, PasskeyOptionsBody, PasskeySessionBody } from '../generated/api'; +import { apiFetch } from './http'; +import { b64, unb64, deriveAccountKeys, openJson } from './accountCrypto'; +import { unlockedAccount, usernameOf, unlockAuthenticatedAccount, accountStatus } from './account'; +import { localAccountDevice } from './accountPersistence'; +import { sealPasskeyKey, openPasskeyKey } from './passkeyCrypto'; +import { validateVault } from './accountVault'; + +interface Binding { username: string; material: Uint8Array; options: PasskeyOptionsBody; expiry: number; timer: ReturnType } +let binding: Binding | undefined; +let pendingLogin: PasskeySessionBody | undefined; +export function passkeyUnlockUsername(): string | undefined { return pendingLogin?.username; } +export function clearPasskeyState(): void { + if (binding) { binding.material.fill(0); clearTimeout(binding.timer); } + binding = undefined; + const identity = pendingLogin; pendingLogin = undefined; + if (identity) void apiFetch('/accounts/session', { method: 'DELETE', headers: { 'X-WD-Account': identity.token } }).catch(() => {}); +} +export async function listPasskeys(): Promise { + unlockedAccount(); return apiFetch('/accounts/passkeys').then(r => r.json() as Promise); +} +export async function removePasskey(id: string): Promise { unlockedAccount(); await apiFetch(`/accounts/passkeys/${encodeURIComponent(id)}`, { method: 'DELETE' }); } +export async function beginPasskeyRegistration(password: string): Promise { + clearPasskeyState(); + const account = unlockedAccount(); + const salt = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: account.username }) }).then(r => r.json() as Promise); + const { verifier, key, material } = await deriveAccountKeys(password, unb64(salt.salt), true); + try { + const vault = await apiFetch('/accounts/vault').then(r => r.json() as Promise); + const profile = validateVault(await openJson(vault.vault, key)); + if (profile.keys.publicKey.x !== account.profile.keys.publicKey.x || profile.keys.publicKey.y !== account.profile.keys.publicKey.y) throw new Error('账号解锁信息不匹配'); + const options = await apiFetch('/accounts/passkeys/register/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: account.username, verifier: b64(verifier) }) }).then(r => r.json() as Promise); + binding = { username: account.username, material: material!, options, expiry: Date.now() + 120_000, timer: setTimeout(clearPasskeyState, 120_000) }; + return options; + } catch (error) { material?.fill(0); throw error; } + finally { verifier.fill(0); } +} +export async function finishPasskeyRegistration(id: string, credentialId: string, credential: string, name: string, prf?: ArrayBuffer): Promise { + const account = unlockedAccount(); + if (!binding || binding.username !== account.username || binding.options.id !== id || binding.expiry < Date.now()) throw new Error('Passkey绑定已过期,请重试'); + const keyEnvelope = prf ? await sealPasskeyKey(binding.material, prf, account.username, credentialId) : undefined; + const result = await apiFetch('/accounts/passkeys/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: account.username, id, credential, name, key_envelope: keyEnvelope } satisfies PasskeyFinishRequest) }).then(r => r.json() as Promise); + if (prf) clearPasskeyState(); + return result; +} +export async function beginPasskeyRecovery(): Promise { + unlockedAccount(); return apiFetch('/accounts/passkeys/recovery/options', { method: 'POST' }).then(r => r.json() as Promise); +} +export async function finishPasskeyRecovery(id: string, credentialId: string, credential: string, prf: ArrayBuffer): Promise { + const account = unlockedAccount(); + if (!binding || binding.username !== account.username || binding.expiry < Date.now()) throw new Error('Passkey绑定已过期,请重试'); + try { + const envelope = await sealPasskeyKey(binding.material, prf, account.username, credentialId); + await apiFetch('/accounts/passkeys/recovery', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: account.username, id, credential, key_envelope: envelope }) }); + } finally { clearPasskeyState(); } +} +export async function beginPasskeyLogin(input: string): Promise { + const username = usernameOf(input); + clearPasskeyState(); + return apiFetch('/accounts/passkeys/login/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }).then(r => r.json() as Promise); +} +export async function finishPasskeyLogin(input: string, id: string, credential: string, prf?: ArrayBuffer, remember = true) { + const username = usernameOf(input); const device = await localAccountDevice(); + const identity = await apiFetch('/accounts/passkeys/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, id, credential, device_id: device.id, device_name: device.name } satisfies PasskeyFinishRequest) }).then(r => r.json() as Promise); + if (identity.username !== username) throw new Error('Passkey账号不匹配'); + pendingLogin = identity; + if (!prf || !identity.key_envelope) return accountStatus(); + try { + const key = await openPasskeyKey(identity.key_envelope, prf, username, identity.credential_id); + const status = await unlockAuthenticatedAccount(identity, key, remember); pendingLogin = undefined; + return { ...status, unlockUsername: undefined }; + } catch (error) { clearPasskeyState(); throw error; } +} +export async function unlockPasskeyLogin(password: string, remember = true) { + const identity = pendingLogin; + if (!identity) throw new Error('请先使用Passkey登录'); + const salt = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: identity.username }) }).then(r => r.json() as Promise); + const { verifier, key } = await deriveAccountKeys(password, unb64(salt.salt)); verifier.fill(0); + try { const status = await unlockAuthenticatedAccount(identity, key, remember); pendingLogin = undefined; return { ...status, unlockUsername: undefined }; } + catch (error) { clearPasskeyState(); throw error; } +} diff --git a/web/src/core-bridge/worker/resourceMetadata.test.ts b/web/src/core-bridge/worker/resourceMetadata.test.ts new file mode 100644 index 0000000..681e0f6 --- /dev/null +++ b/web/src/core-bridge/worker/resourceMetadata.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { isOfflineFailure } from './resourceMetadata'; +import { toPublishedFile } from './fileCatalog'; +import { exportResource } from './accountVault'; +import type { FileItem } from '../types'; +import type { SavedResource } from '../database'; + +describe('resource and rendering boundaries', () => { + it('does not treat authorization, tampering or contract errors as offline cache hits', () => { + expect(isOfflineFailure(new Error('网络连接失败'))).toBe(true); + expect(isOfflineFailure(Object.assign(new Error('forbidden'), { status: 403 }))).toBe(false); + expect(isOfflineFailure(new Error('api_contract_mismatch'))).toBe(false); + expect(isOfflineFailure(new Error('bad_meta'))).toBe(false); + }); + it('publishes only resource metadata, excluding UI state and download counters', () => { + const item: FileItem = { id: 'a', threadId: 'channel', kind: 'file', name: 'secret.txt', mime: 'text/plain', size: 10, ts: 1, deviceId: 'device', + gated: true, remaining: 5, preview: 'text', state: { kind: 'receiving', progress: 0.2 }, unseen: true, versions: [] }; + const wire = toPublishedFile(item); + expect(wire.name).toBe('secret.txt'); + for (const field of ['state', 'preview', 'remaining', 'versions', 'unseen']) expect(wire).not.toHaveProperty(field); + }); + it('never synchronizes draft content or server structure snapshots inside the key vault', () => { + const resource: SavedResource = { id: 'a', kind: 'share', mode: 'enc', role: 'owner', link: 'key', createdAt: 1, expiresAt: 2, + endpointSecret: new Uint8Array(32), seats: 0, draftItems: [], manifestEntries: [], + workspaceMetadata: { structure: { revision: 1, channels: [], groups: [], folders: [], locations: [] }, people: [] } }; + const exported = exportResource(resource); + for (const field of ['draftItems', 'manifestEntries', 'workspaceMetadata', 'endpointSecret']) expect(exported).not.toHaveProperty(field); + }); +}); diff --git a/web/src/core-bridge/worker/resourceMetadata.ts b/web/src/core-bridge/worker/resourceMetadata.ts new file mode 100644 index 0000000..f502e81 --- /dev/null +++ b/web/src/core-bridge/worker/resourceMetadata.ts @@ -0,0 +1,19 @@ +import type { ChannelInfo, ShareInfo } from '../generated/api'; +import type { SavedResource } from '../database'; + +/** Server-owned fields only: preserve local credentials, drafts, and user edit timestamps. */ +export function channelCache(raw: ChannelInfo): Partial { + return { name: raw.settings.name ?? undefined, creatorAccount: raw.creator_account ?? undefined, + createdAt: raw.created_at, expiresAt: raw.expires_at, seats: raw.settings.seats, + maxDownloads: raw.settings.file_max_downloads, + ttlSeconds: raw.settings.ttl_seconds, channelMetadata: raw }; +} +export function shareCache(raw: ShareInfo): Partial { + if (raw.state !== 'draft' && raw.state !== 'sealed') throw new Error('分享状态无效'); + return { createdAt: raw.sealed_at ?? raw.created_at, expiresAt: raw.expires_at, state: raw.state, + ttlSeconds: raw.settings.ttl_seconds, maxVisitors: raw.settings.max_visitors, + maxDownloads: raw.settings.file_max_downloads || null, shareMetadata: raw }; +} +export function isOfflineFailure(error: unknown): boolean { + return error instanceof Error && error.message === '网络连接失败'; +} diff --git a/web/src/core-bridge/worker/senderSnapshot.ts b/web/src/core-bridge/worker/senderSnapshot.ts index 48a681c..98c5b27 100644 --- a/web/src/core-bridge/worker/senderSnapshot.ts +++ b/web/src/core-bridge/worker/senderSnapshot.ts @@ -1,8 +1,7 @@ import type { SenderInfo } from '../types'; import { accountDisplay } from './accountSession'; -let lastAvatar: string | undefined; -let thumbnail: Promise | undefined; +const thumbnails = new Map>(); async function compactAvatar(value: string): Promise { if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') return value.length <= 8000 ? value : undefined; try { @@ -23,7 +22,15 @@ async function compactAvatar(value: string): Promise { export async function senderSnapshot(): Promise { const sender = accountDisplay(); if (!sender) return undefined; if (!sender.avatar) return { username: sender.username, displayName: sender.displayName }; - if (lastAvatar !== sender.avatar || !thumbnail) { lastAvatar = sender.avatar; thumbnail = compactAvatar(sender.avatar); } - const avatar = await thumbnail; + const avatar = await avatarThumbnail(sender.avatar); return { username: sender.username, displayName: sender.displayName, ...(avatar ? { avatar } : {}) }; } + +export function avatarThumbnail(value: string): Promise { + let thumbnail = thumbnails.get(value); + if (!thumbnail) { + if (thumbnails.size >= 32) thumbnails.clear(); + thumbnail = compactAvatar(value); thumbnails.set(value, thumbnail); + } + return thumbnail; +} diff --git a/web/src/core-bridge/worker/sessions.ts b/web/src/core-bridge/worker/sessions.ts index ee4f469..f9baeb6 100644 --- a/web/src/core-bridge/worker/sessions.ts +++ b/web/src/core-bridge/worker/sessions.ts @@ -4,12 +4,16 @@ import { fail } from './http'; import { toCoreLink } from './links'; import { clearFileCatalog } from './fileCatalog'; import { clearTemporaryDownloads } from './downloadStorage'; +import { clearWorkspaceStructures } from './workspaceStructure'; +import { attachDataKeys, clearDataKeys } from './channelDataKeys'; export const sessions = new Map(); export const readers = new Map>(); export const active = new Map(); export const blobs = new Map(); const opening = new Map>(); export function clearResourceSessions(): void { + clearDataKeys(); + clearWorkspaceStructures(); clearFileCatalog(); void clearTemporaryDownloads().catch(() => {}); for (const controller of active.values()) controller.abort(); @@ -31,5 +35,6 @@ export async function getSession(id: string): Promise<{ session: ChannelSession; if (resource.localScope !== currentResourceScope()) { session.dispose(); throw new Error('账号已切换'); } sessions.set(id, session); } + attachDataKeys(session, resource); return { session, resource }; } diff --git a/web/src/core-bridge/worker/shareRead.ts b/web/src/core-bridge/worker/shareRead.ts index 7bc9e7e..eb31353 100644 --- a/web/src/core-bridge/worker/shareRead.ts +++ b/web/src/core-bridge/worker/shareRead.ts @@ -26,7 +26,7 @@ export async function connectShare(id: string): Promise { session = (await getSession(id)).session; if (controller.signal.aborted) return; const share = toShareInfo(raw, resource); - resource.state = raw.state; + resource.state = share.state; resource.expiresAt = raw.expires_at; await saveResource(resource); publishShare(id, { t: 'share', share }); diff --git a/web/src/core-bridge/worker/shareResources.ts b/web/src/core-bridge/worker/shareResources.ts index 513d08b..5924673 100644 --- a/web/src/core-bridge/worker/shareResources.ts +++ b/web/src/core-bridge/worker/shareResources.ts @@ -1,5 +1,8 @@ import { ChannelSession } from '@webdrop/core'; -import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; +import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; +import { shareCache, isOfflineFailure } from './resourceMetadata'; +import type { ShareSettingsPatch } from '../generated/api'; +import { readShareInfo } from './shareState'; import type { Share, ShareSettings } from '../types'; import { active, blobs, getSession, sessions } from './sessions'; @@ -29,11 +32,12 @@ export async function createShare(settings: ShareSettings, captchaToken?: string }); const raw = await response.json() as ShareWire; const resource: SavedResource = { + ...shareCache(raw), id: raw.id, kind: 'share', mode: 'enc', role: 'owner', mine: true, link: toShareLink(session.link(self.location.origin)), ownerLink: toShareLink(session.link(self.location.origin, 'owner')), endpointSecret: Uint8Array.from(session.endpointSecret), - createdAt: raw.created_at, expiresAt: raw.expires_at, seats: 0, membersCanUpload: false, + createdAt: raw.created_at, expiresAt: raw.expires_at, seats: 0, state: 'draft', maxVisitors: raw.settings.max_visitors, ttlSeconds: raw.settings.ttl_seconds, maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads, @@ -56,10 +60,11 @@ export async function joinShare(input: { link: { id: string; key: string } }): P const raw = await readJson(`/shares/${input.link.id}`, session.authHeaders('read')); if (raw.state !== 'sealed') throw fail(404, 'not_found'); const resource: SavedResource = { + ...shareCache(raw), id: input.link.id, kind: 'share', mode: 'enc', role: 'member', mine: false, link: toShareLink(makeLink(self.location.origin, input.link.id, { k: input.link.key })), endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: raw.created_at, expiresAt: raw.expires_at, - seats: 0, membersCanUpload: false, state: raw.state, + seats: 0, state: raw.state, ttlSeconds: raw.settings.ttl_seconds, maxVisitors: raw.settings.max_visitors, maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads, }; @@ -75,19 +80,15 @@ export async function joinShare(input: { link: { id: string; key: string } }): P export async function updateShareSettings(id: string, patch: Partial): Promise { const { session, resource } = await getSession(id); if (resource.kind !== 'share' || !session.can('owner')) throw new Error('此分享不再可修改'); - const body: Record = {}; + const body: ShareSettingsPatch = {}; if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds; if (patch.maxVisitors !== undefined) body.max_visitors = patch.maxVisitors; if (patch.maxDownloads !== undefined) body.file_max_downloads = patch.maxDownloads ?? 0; const raw = await apiFetch(`/shares/${id}/settings`, { method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then((response) => response.json() as Promise); - resource.expiresAt = raw.expires_at; - resource.ttlSeconds = raw.settings.ttl_seconds; - resource.maxVisitors = raw.settings.max_visitors; - resource.maxDownloads = raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads; - await saveResource(resource); - return toShareInfo(raw, resource); + const updated = await cacheResource(resource, shareCache(raw)); + return toShareInfo(raw, updated); } export async function removeShare(id: string): Promise { @@ -101,10 +102,25 @@ export async function removeShare(id: string): Promise { export async function listSavedShares(): Promise { const saved = await listResources('share'); - return saved.map((resource) => ({ + const rows = await Promise.all(saved.map(async (resource): Promise => { + try { + const { session } = await getSession(resource.id); + const raw = await readShareInfo(session, resource); + const info = toShareInfo(raw, resource); + await cacheResource(resource, shareCache(raw)); + return { ...info, dataSource: 'server' }; + } catch (error) { + if (error instanceof Error && 'status' in error && (error.status === 404 || error.status === 410)) return null; + if (!isOfflineFailure(error)) throw error; + if (resource.shareMetadata) return { ...toShareInfo(resource.shareMetadata, resource), dataSource: 'cache' }; + return { id: resource.id, state: resource.state ?? 'draft', mine: resource.mine ?? false, link: resource.link, createdAt: resource.createdAt, expiresAt: resource.expiresAt, - ttlSeconds: resource.ttlSeconds ?? 86400, maxVisitors: resource.maxVisitors ?? 0, + ttlSeconds: resource.ttlSeconds ?? Math.max(0, (resource.expiresAt - resource.createdAt) / 1000), maxVisitors: resource.maxVisitors ?? 0, maxDownloads: resource.maxDownloads ?? null, + dataSource: 'cache', + }; + } })); + return rows.filter((row): row is Share => row !== null); } diff --git a/web/src/core-bridge/worker/transfer.ts b/web/src/core-bridge/worker/transfer.ts index e9d759c..85d910f 100644 --- a/web/src/core-bridge/worker/transfer.ts +++ b/web/src/core-bridge/worker/transfer.ts @@ -1,6 +1,10 @@ import { fromBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core'; -import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, type SavedResource } from '../database'; +import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, nextQueuedLocation, dequeueLocation, type SavedResource } from '../database'; +import { registerItemLocation } from './workspaceStructure'; +import { verifyObjectEvent } from './channelDataKeys'; import { apiFetch, apiUpload, type ApiError } from './http'; +import { signContentEvent } from './accountSigning'; +import { accountToken } from './accountSession'; import { serial } from './serial'; import { storeVerifiedDownload } from './downloadStorage'; @@ -13,23 +17,40 @@ export async function persistPlan( resource: SavedResource, plan: ItemPlan, onProgress?: (frames: number, bytes: number) => void, + signedEvent?: { channelId: string; domain: 'chat' | 'files'; epoch: number; eventKind: string; targetItemId?: string }, ): Promise<{ frames: number; bytes: number }> { const createdAt = Math.max(Date.now(), (await lastQueuedAt(resource.id)) + 1); let frames = 0; let bytes = 0; - for await (const frame of plan.frames) { + const frameDigests: Uint8Array[] = []; + let pending: { index: number; bytes: Uint8Array; extraHeaders?: Record } | undefined; + const persistFrame = async (frame: NonNullable, extraHeaders: Record = {}) => { await queueFrame({ key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`, resourceId: resource.id, itemId: plan.itemId, index: frame.index, bytes: frame.bytes, - extraHeaders: frame.index === 0 ? { ...plan.frame0Headers } : {}, + extraHeaders: { ...(frame.index === 0 ? plan.frame0Headers : {}), ...frame.extraHeaders, ...extraHeaders }, createdAt: createdAt + frame.index, }); frames += 1; bytes += frame.bytes.byteLength; onProgress?.(frames, bytes); + }; + for await (const frame of plan.frames) { + if (pending) await persistFrame(pending); + const frameBuffer = frame.bytes.slice().buffer as ArrayBuffer; + frameDigests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', frameBuffer))); + pending = frame; + } + if (pending) { + const headers = signedEvent && resource.kind === 'channel' && accountToken() + ? await signContentEvent({ spaceId: resource.id, channelId: signedEvent.channelId, domain: signedEvent.domain, + epoch: signedEvent.epoch, itemId: plan.itemId, eventKind: signedEvent.eventKind, + ...(signedEvent.targetItemId ? { targetItemId: signedEvent.targetItemId } : {}), frameDigests }) + : {}; + await persistFrame(pending, headers); } return { frames, bytes }; } @@ -56,7 +77,20 @@ async function flush( ): Promise { for (;;) { const frame = await nextQueuedFrame(resource.id); - if (!frame) return true; + if (!frame) { + if (resource.kind !== 'channel') return true; + const location = await nextQueuedLocation(resource.id); + if (!location) return true; + try { + await registerItemLocation(resource.id, { id: location.itemId, threadId: location.threadId, folderId: location.folderId }); + await dequeueLocation(location.key); + } catch (error) { + if (error instanceof Error && error.message === '网络连接失败') return false; + if ((error as ApiError).status === 404 || (error as ApiError).status === 410) await dequeueLocation(location.key); + throw error; + } + continue; + } const role = resource.kind === 'share' ? 'owner' : writeRole(session); const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`; let maxLoadedBytes = 0; @@ -104,5 +138,9 @@ export async function downloadDecrypted( const expectedId = fromBase64Url(itemId, 16); if (!expectedId) throw new IntegrityError('bad_meta'); const decoded = session.decryptItemStream(response.body, { itemId: expectedId }); - return storeVerifiedDownload(decoded, expectedSize); + return storeVerifiedDownload(decoded, expectedSize, undefined, async result => { + if (resource.kind === 'channel' && (!result.ciphertextManifestHash + || !await verifyObjectEvent(resource, session, itemId, result.ciphertextManifestHash))) + throw new IntegrityError('auth_failed'); + }); } diff --git a/web/src/core-bridge/worker/wire.ts b/web/src/core-bridge/worker/wire.ts index 6ba4f9c..ca55318 100644 --- a/web/src/core-bridge/worker/wire.ts +++ b/web/src/core-bridge/worker/wire.ts @@ -1,40 +1,21 @@ import type { SavedResource } from '../database'; import type { ChannelInfo, Share, ShareSettings } from '../types'; -export interface ChannelWire { - creator_account?: string; - password_required?: boolean; - id: string; - mode: 'enc' | 'raw'; - settings: { name?: string; join_policy?: 'open' | 'invite'; allow_guests?: boolean; seats: number; members_can_upload: boolean; delete_policy: 'owner_only' | 'own' | 'anyone'; ttl_seconds: number; max_items: number; file_max_downloads: number }; - seats_used: number; - oldest_seq: number; - last_seq: number; - created_at: number; - expires_at: number; -} - -export interface ShareWireItem { item_id: string; bytes: number; frames: number; complete?: boolean; max_downloads: number; downloads_left: number | null } -export interface ShareWire { - id: string; - state: 'draft' | 'sealed'; - expires_at: number; - created_at: number; - sealed_at?: number; - manifest_item_id?: string; - settings: { ttl_seconds: number; max_visitors: number; file_max_downloads: number }; - items: ShareWireItem[]; -} +import type { ChannelInfo as ChannelWire, ShareInfo as ShareWire, ShareItemInfo as ShareWireItem } from '../generated/api'; +export type { ChannelWire, ShareWire, ShareWireItem }; export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelInfo { + const joinPolicy = raw.settings.join_policy; + if (joinPolicy !== 'open' && joinPolicy !== 'invite') throw new Error('空间加入规则无效'); return { id: raw.id, - creatorAccount: raw.creator_account, - joinPolicy: raw.settings.join_policy ?? 'open', allowGuests: raw.settings.allow_guests ?? true, passwordRequired: raw.password_required ?? false, - name: raw.settings.name, + creatorAccount: raw.creator_account ?? undefined, + joinPolicy, allowGuests: raw.settings.allow_guests, passwordRequired: raw.password_required, + name: raw.settings.name ?? undefined, mode: raw.mode, - role: saved.role, - canUpload: saved.role === 'owner' || raw.settings.members_can_upload, + role: raw.can_manage_structure ? 'owner' : 'member', + canUpload: raw.can_upload, + canManageStructure: raw.can_manage_structure, seats: raw.settings.seats, - seatsUsed: raw.seats_used, + membersUsed: raw.seats_used, membersCanUpload: raw.settings.members_can_upload, ttlSeconds: raw.settings.ttl_seconds, maxDownloads: raw.settings.file_max_downloads, @@ -46,6 +27,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn } export function toShareInfo(raw: ShareWire, saved: SavedResource): Share { + if (raw.state !== 'draft' && raw.state !== 'sealed') throw new Error('分享状态无效'); return { id: raw.id, state: raw.state, diff --git a/web/src/core-bridge/worker/workspaceRecords.test.ts b/web/src/core-bridge/worker/workspaceRecords.test.ts new file mode 100644 index 0000000..d686383 --- /dev/null +++ b/web/src/core-bridge/worker/workspaceRecords.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { ChannelSession, collectFrames } from '@webdrop/core'; +import { readResourceRecord } from './workspaceRecords'; + +const threadId = '15e7fb09-984d-47d4-8e74-477c7d69c58c'; +async function decode(record: Record) { + const { session } = await ChannelSession.create(); + try { + const frames = await collectFrames(session.encryptBytes(new TextEncoder().encode(JSON.stringify({ app: 'webdrop.workspace', v: 1, ...record })), + { kind: 'other' }, { control: true, ts: 1 })); + const event = (await session.createItemReader().pushFrame(frames[0]!.bytes)).find(row => row.type === 'item'); + if (!event || event.type !== 'item') throw new Error('Missing decrypted record'); + return readResourceRecord(event.item); + } finally { session.dispose(); } +} +describe('encrypted resource records', () => { + it('ignores client-defined account and structure operations', async () => { + for (const op of ['thread.create', 'group.create', 'folder.create', 'items.move', 'item.author']) expect(await decode({ op })).toBeNull(); + }); + it('renders authenticated file metadata without accepting client-supplied identity or UI state', async () => { + const value = await decode({ op: 'file.published', item: { id: 'AAAAAAAAAAAAAAAAAAAAAA', threadId, kind: 'file', name: 'private.txt', mime: 'text/plain', size: 10, gated: true, + author: { username: 'fake', displayName: '伪造资料' }, state: { kind: 'tampered' } } }); + expect(value).toMatchObject({ t: 'file-catalog', item: { name: 'private.txt', state: { kind: 'complete' } } }); + if (value && value.t === 'file-catalog') expect(value.item).not.toHaveProperty('author'); + }); + it('rejects malformed encrypted file metadata', async () => { + expect(await decode({ op: 'file.published', item: { id: 'invalid', threadId, kind: 'file', size: -1 } })).toMatchObject({ t: 'error' }); + }); +}); diff --git a/web/src/core-bridge/worker/workspaceRecords.ts b/web/src/core-bridge/worker/workspaceRecords.ts new file mode 100644 index 0000000..7767319 --- /dev/null +++ b/web/src/core-bridge/worker/workspaceRecords.ts @@ -0,0 +1,26 @@ +import type { ReceivedItem } from '@webdrop/core'; +import type { WorkspaceEvent } from '../types'; +import { WORKSPACE_APP, type PublishedFile } from '../protocol/workspace'; +import { previewKindOf } from '@/lib/fileKind'; + +const isId = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value); +const isItemId = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value); + +/** Decode encrypted resource metadata only; account and structure data come from the backend. */ +export function readResourceRecord(item: ReceivedItem): WorkspaceEvent | false | null { + if (item.meta?.kind !== 'other') return false; + let value: unknown; + try { value = JSON.parse(new TextDecoder().decode(item.content)); } catch { return false; } + if (!value || typeof value !== 'object' || (value as { app?: unknown }).app !== WORKSPACE_APP) return false; + const row = value as Record; + if (row.v !== 1 || typeof row.op !== 'string') return { t: 'error', message: '资源记录格式无效,已拒绝应用。' }; + if (row.op !== 'file.published') return null; + const file = row.item as PublishedFile | undefined; + if (!file || !isItemId(file.id) || !isId(file.threadId) || (file.folderId !== undefined && !isId(file.folderId)) + || file.kind !== 'file' || typeof file.name !== 'string' || file.name.length > 1024 || typeof file.mime !== 'string' || file.mime.length > 256 + || !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean') + return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' }; + return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, folderId: file.folderId, + name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, deviceId: item.meta.dev ?? '未知设备', + gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } }; +} diff --git a/web/src/core-bridge/worker/workspaceStructure.ts b/web/src/core-bridge/worker/workspaceStructure.ts new file mode 100644 index 0000000..f5a1902 --- /dev/null +++ b/web/src/core-bridge/worker/workspaceStructure.ts @@ -0,0 +1,57 @@ +import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest } from '../generated/api'; +import type { FileItem } from '../types'; +import { getResource, cacheResource } from '../database'; +import { getSession } from './sessions'; +import { apiFetch } from './http'; +import { publishWorkspace } from './presentation'; +import { avatarThumbnail } from './senderSnapshot'; + +const current = new Map(); +export function clearWorkspaceStructures(): void { current.clear(); } +async function publish(id: string, body: WorkspaceBody): Promise { + const previous = current.get(id); + if (previous && previous.structure.revision > body.structure.revision) return previous; + const previousPeople = new Map(previous?.people.map(person => [person.username, person])); + body = { ...body, people: body.people.map(person => { + const newer = previousPeople.get(person.username); + return newer && newer.modifiedAt > person.modifiedAt ? newer : person; + }) }; + current.set(id, body); + const resource = await getResource(id); + if (resource) await cacheResource(resource, { workspaceMetadata: body }); + const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await avatarThumbnail(person.avatar) : undefined }))); + if (current.get(id) !== body) return current.get(id) ?? body; + publishWorkspace(id, { t: 'structure', state: body.structure, people }); + return body; +} +export async function loadWorkspace(id: string): Promise { + const { session } = await getSession(id); + const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise); + return publish(id, body); +} +export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT', value: unknown): Promise { + const { session } = await getSession(id); + const body = await apiFetch(`/spaces/${id}/${path}`, { + method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, body: JSON.stringify(value), + }).then(r => r.json() as Promise); + await publish(id, body); + return body; +} +export async function createWorkspaceChannel(id: string, value: WorkspaceChannelRequest): Promise { + const { session } = await getSession(id); + const body = await apiFetch(`/spaces/${id}/channels`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(value) }) + .then(r => r.json() as Promise); + await publish(id, body.workspace); + return body.channel.id; +} +export async function createWorkspaceGroup(id: string, value: WorkspaceNameRequest): Promise { + const { session } = await getSession(id); + const body = await apiFetch(`/spaces/${id}/groups`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(value) }) + .then(r => r.json() as Promise); + await publish(id, body.workspace); + return body.group.id; +} +export async function registerItemLocation(id: string, item: Pick): Promise { + if (!item.threadId) return; + await changeWorkspace(id, 'locations', 'PUT', { itemIds: [item.id], threadId: item.threadId, folderId: item.folderId } satisfies WorkspaceMoveRequest); +} diff --git a/web/src/features/channel/ChannelSettingsSheet.tsx b/web/src/features/channel/ChannelSettingsSheet.tsx index 1d43952..11e1c13 100644 --- a/web/src/features/channel/ChannelSettingsSheet.tsx +++ b/web/src/features/channel/ChannelSettingsSheet.tsx @@ -5,12 +5,9 @@ import { Sheet } from '@/ui/Sheet'; import styles from './ChannelSettingsSheet.module.css'; import { ChannelInvitations } from './ChannelInvitations'; -const TTL_OPTIONS = [ - { label: '1 小时', value: 3600 }, - { label: '24 小时', value: 86400 }, - { label: '7 天', value: 604800 }, -]; -const DOWNLOAD_OPTIONS = [0, 1, 3, 5, 10, 20]; +import { useServerSettings } from '@/store/server'; +import type { ServerInfoBody } from '@/core-bridge/generated/api'; +import { ttlLabel, withCurrent } from '@/lib/settingOptions'; interface Props { channel: ChannelInfo; @@ -18,23 +15,28 @@ interface Props { onSave: (patch: ChannelSettingsPatch) => Promise; } -export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) { +export function ChannelSettingsSheet(props: Props) { + const { info, error, load } = useServerSettings(); + if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined || props.channel.allowGuests === undefined) + return

{error ?? '正在读取空间设置…'}

+ {error ? : null}
; + return ; +} +function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) { const owner = channel.role === 'owner'; const [name, setName] = useState(channel.name ?? ''); const [seats, setSeats] = useState(channel.seats); - const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload ?? channel.canUpload ?? true); - const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds ?? 86400); - const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads ?? 10); - const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy ?? 'open'); - const [allowGuests, setAllowGuests] = useState(channel.allowGuests ?? true); + const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!); + const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!); + const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!); + const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy!); + const [allowGuests, setAllowGuests] = useState(channel.allowGuests!); const [joinPassword, setJoinPassword] = useState(''); const [clearPassword, setClearPassword] = useState(false); const [saving, setSaving] = useState(false); const [failed, setFailed] = useState(false); - const ttlOptions = TTL_OPTIONS.some((option) => option.value === ttlSeconds) - ? TTL_OPTIONS - : [{ label: `当前设置 (${ttlSeconds} 秒)`, value: ttlSeconds }, ...TTL_OPTIONS]; - const downloadOptions = [...new Set([...DOWNLOAD_OPTIONS, fileMaxDownloads])].sort((a, b) => a - b); + const ttlOptions = withCurrent(info.choices.ttl_seconds, ttlSeconds); + const downloadOptions = withCurrent(info.choices.file_max_downloads, fileMaxDownloads); async function save() { if (!owner || saving) return; @@ -61,14 +63,14 @@ export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) { @@ -99,7 +101,7 @@ export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) { {owner ? ( <> {failed ?

保存失败,请检查名称和设置后重试。

: null} - + ) :

只有所有者可以修改空间设置。

} diff --git a/web/src/features/channel/ChannelSwitcher.tsx b/web/src/features/channel/ChannelSwitcher.tsx index 2af53b7..6c6e09e 100644 --- a/web/src/features/channel/ChannelSwitcher.tsx +++ b/web/src/features/channel/ChannelSwitcher.tsx @@ -8,12 +8,7 @@ import styles from './ThreadSidebar.module.css'; export function ChannelSwitcher({ id, onCreate }: { id?: string; onCreate: () => void }) { const channels = useChannels((s) => s.list); const username = useAccount((s) => s.username); - const personal = useAccount((s) => s.personal); return
-
空间
@@ -139,16 +83,6 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) { : null}
- {import.meta.env.DEV ? ( -
-

演示

-
- - - -
-
- ) : null} ); } diff --git a/web/src/features/channel/FileExplorer.tsx b/web/src/features/channel/FileExplorer.tsx index ac287ed..0b1eac7 100644 --- a/web/src/features/channel/FileExplorer.tsx +++ b/web/src/features/channel/FileExplorer.tsx @@ -21,6 +21,7 @@ interface Props { selectedId?: string; checked: ReadonlySet; enabled: boolean; + structureEnabled: boolean; onFolder: (id?: string) => void; onSelect: (id?: string) => void; onOpen: (id: string) => void; @@ -71,7 +72,7 @@ export function FileExplorer(props: Props) {
{ if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} /> - +