diff --git a/web/src/app/AccountChannelAccess.tsx b/web/src/app/AccountChannelAccess.tsx new file mode 100644 index 0000000..857026b --- /dev/null +++ b/web/src/app/AccountChannelAccess.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; +import { accountApi } from '@/core-bridge/account'; +import { useAccount } from '@/store/account'; +import { useChannels } from '@/store/channels'; +import { AccountScreen } from '@/features/home/AccountScreen'; +import { MissingAccess } from '@/features/scan/JoinScreen'; +import { Button } from '@/ui/Button'; +import styles from '@/features/scan/JoinScreen.module.css'; + +export function AccountChannelAccess({ id, onHome }: { id: string; onHome: () => void }) { + const username = useAccount((s) => s.username); + const [invited, setInvited] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + useEffect(() => { + let active = true; + setInvited(false); setError(''); + if (username) void accountApi.invitations().then((rows) => { + if (active) setInvited(rows.some((row) => row.channel_id === id)); + }).catch(() => { if (active) setError('邀请读取失败,请重试同步。'); }); + return () => { active = false; }; + }, [id, username]); + async function accept() { + setBusy(true); setError(''); + try { await accountApi.acceptInvitation(id); await useChannels.getState().refresh(); } + catch (cause) { setError(cause instanceof Error ? cause.message : '加入失败'); } + finally { setBusy(false); } + } + if (!username) return ; + if (!invited) return ; + return

空间邀请

接受邀请后,使用你的账号密钥自动解锁此空间,无需复制密钥链接。

+ + + {error ?

{error}

: null}
; +} diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 6ffc8c9..8fadfee 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Home } from '@/features/home/Home'; -import { JoinScreen, MissingAccess } from '@/features/scan/JoinScreen'; +import { JoinScreen } from '@/features/scan/JoinScreen'; +import { AccountChannelAccess } from './AccountChannelAccess'; import { useChannels } from '@/store/channels'; import { useShares } from '@/store/shares'; import { useWorkspace } from '@/store/workspace'; @@ -8,6 +9,8 @@ import { ChannelScreen } from './ChannelScreen'; import { ShareScreen } from './ShareScreen'; import { channelPath, navigate, useRoute } from './navigation'; import { Splash } from './Splash'; +import { AccountScreen } from '@/features/home/AccountScreen'; +import { useAccount } from '@/store/account'; const SEEN_KEY = 'wd.splash'; @@ -20,6 +23,13 @@ function splashSeen(): boolean { } export function App() { + const username = useAccount((s) => s.username); + const accountHydrated = useAccount((s) => s.hydrated); + const refreshAccount = useAccount((s) => s.refresh); + useEffect(() => { + const timer = window.setInterval(() => { void refreshAccount(); }, 5000); + return () => window.clearInterval(timer); + }, [refreshAccount]); const route = useRoute(); const list = useChannels((s) => s.list); const channelsHydrated = useChannels((s) => s.hydrated); @@ -31,12 +41,12 @@ export function App() { const [splash, setSplash] = useState(() => !splashSeen()); const saved = route.name === 'channel' && list.some((c) => c.id === route.id); - // 只有打开已保存的频道才需要等数据;首页与加入页没有加载过程 + // 只有打开已保存的空间才需要等数据;首页与加入页没有加载过程 const waitingOnSavedChannel = route.name === 'channel' && !pendingLink && !channelsHydrated; const waitingOnSavedShare = route.name === 'share' && pendingShareLink?.id !== route.id && !sharesHydrated; - const ready = route.name === 'home' || (route.name === 'channel' + const ready = accountHydrated && (route.name === 'home' || (route.name === 'channel' ? pendingLink?.id === route.id || (channelsHydrated && (!saved || wsReady)) - : pendingShareLink?.id === route.id || sharesHydrated); + : pendingShareLink?.id === route.id || sharesHydrated)); function onSplashDone() { try { @@ -48,7 +58,8 @@ export function App() { } let screen; - if (route.name === 'home') screen = ; + if (!accountHydrated) screen = null; + else if (route.name === 'home') screen = username ? : ; else if (route.name === 'share' && waitingOnSavedShare) screen = null; else if (route.name === 'share') screen = ; else if (route.name === 'channel' && waitingOnSavedChannel) screen = null; @@ -64,7 +75,7 @@ export function App() { }} /> ); - } else screen = navigate('/')} />; + } else screen = navigate('/')} />; return ( <> diff --git a/web/src/app/ChannelScreen.tsx b/web/src/app/ChannelScreen.tsx index 2fcd195..b4d6f5f 100644 --- a/web/src/app/ChannelScreen.tsx +++ b/web/src/app/ChannelScreen.tsx @@ -1,13 +1,17 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Workspace } from '@/features/channel/Workspace'; import { useWorkspace } from '@/store/workspace'; +import { CreateSheet } from '@/features/home/CreateSheet'; +import { channelPath, navigate } from './navigation'; -/** 打开(订阅)频道,数据就绪后显示暂存区 */ +/** 打开(订阅)空间,数据就绪后显示暂存区 */ export function ChannelScreen({ id }: { id: string }) { const open = useWorkspace((s) => s.open); const ready = useWorkspace((s) => s.ready); + const [creating, setCreating] = useState(false); useEffect(() => open(id), [open, id]); - return ready ? : null; + return ready ? <> setCreating(true)} /> + {creating ? setCreating(false)} onCreated={(channel) => { setCreating(false); navigate(channelPath(channel.id)); }} /> : null} : null; } diff --git a/web/src/core-bridge/account.ts b/web/src/core-bridge/account.ts new file mode 100644 index 0000000..94bef6e --- /dev/null +++ b/web/src/core-bridge/account.ts @@ -0,0 +1,21 @@ +import { callWorker } from './workerClient'; + +const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; + +export interface AccountStatus { username: string | null; revision: number; 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 const accountApi = { + register: (username: string, password: string) => callWorker('registerAccount', { username, password, apiBase: API_BASE }), + login: (username: string, password: string) => callWorker('loginAccount', { username, password, apiBase: API_BASE }), + status: () => callWorker('accountStatus', { apiBase: API_BASE }), + logout: () => callWorker('logoutAccount', { apiBase: API_BASE }), + sync: () => callWorker('syncAccount', { apiBase: API_BASE }), + invitations: () => callWorker('accountInvitations', { apiBase: API_BASE }), + acceptInvitation: (id: string) => callWorker('acceptInvitation', { id, apiBase: API_BASE }), + updatePersonal: (displayName: string, avatar?: string) => callWorker('updatePersonal', { displayName, avatar, apiBase: API_BASE }), + devices: () => callWorker('accountDevices', { apiBase: API_BASE }), + renameDevice: (id: string, name: string) => callWorker('renameAccountDevice', { id, name, apiBase: API_BASE }), + revokeDevice: (id: string) => callWorker('revokeAccountDevice', { id, apiBase: API_BASE }), +}; diff --git a/web/src/core-bridge/api.ts b/web/src/core-bridge/api.ts index 8a3487a..7807369 100644 --- a/web/src/core-bridge/api.ts +++ b/web/src/core-bridge/api.ts @@ -1,27 +1,43 @@ import type { ParsedChannelLink } from '@/lib/channelLink'; -import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, WorkspaceEvent } from './types'; +import type { AccountInvitation } from './account'; +import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types'; /** - * 常驻频道的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。 + * 常驻空间的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。 * 便捷分享有自己的边界,见 shareApi.ts。 */ export interface WorkspaceApi { - /** 新建频道:本地生成 id 与密钥、算 PoW、POST channels(api.md) */ + /** 新建空间:本地生成 id 与密钥、算 PoW、POST channels(api.md) */ createChannel(opts: CreateOptions, captchaToken?: string): Promise; - /** 通过链接加入:先 GET 频道信息预览,再 POST seats */ - joinChannel(link: ParsedChannelLink): Promise; + /** 使用现有空间 settings 接口更新空间设置。 */ + updateChannelSettings(patch: ChannelSettingsPatch): Promise; + renameDevice(endpointHash: string, name: string): Promise; + releaseDevice(endpointHash: string): Promise; + /** 通过链接加入:先 GET 空间信息预览,再 POST seats */ + joinChannel(link: ParsedChannelLink, password?: string): Promise; + inviteUser(username: string): Promise; + listInvitations(): Promise; + revokeInvitation(username: string): Promise; listSaved(): Promise; - /** 仅从本机删除保存的链接和终端密钥,不会删服务端频道 */ + /** 仅从本机删除保存的链接和终端密钥,不会删服务端空间 */ forget(id: string): Promise; - /** 仅假实现提供:载入演示用的频道 */ + /** 仅假实现提供:载入演示用的空间 */ loadDemo?(): ChannelInfo[]; - /** 订阅某个频道的事件流(对应追加日志的推送),返回取消订阅函数 */ + /** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */ connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void; - sendText(text: string): Promise; - sendFiles(files: File[]): Promise; + sendText(text: string, threadId?: string): Promise; + sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string): Promise; + createThread(name: string, groupId?: string): Promise; + createGroup(name: string): Promise; + renameGroup(id: string, name: string): Promise; + groupThread(id: string, groupId?: string): Promise; + renameThread(threadId: string, name: string): 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; /** 修改 = 追加一个替代旧条目的新版本,后写者胜 */ - editText(id: string, text: string): Promise; + editText(id: string, text: string, threadId?: string): Promise; resolveConflict(id: string, choice: 'mine' | 'theirs'): Promise; remove(id: string): Promise; setSnapshot(text: string): Promise; diff --git a/web/src/core-bridge/channelAccess.ts b/web/src/core-bridge/channelAccess.ts new file mode 100644 index 0000000..aa9e143 --- /dev/null +++ b/web/src/core-bridge/channelAccess.ts @@ -0,0 +1,9 @@ +import { callWorker } from './workerClient'; +import type { ChannelInfo } from './types'; +import type { ParsedChannelLink } from '@/lib/channelLink'; +const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; +export interface ChannelMember { username: string; role: 'owner' | 'member'; joined_at: number } +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 c683a05..ebb45f0 100644 --- a/web/src/core-bridge/database.ts +++ b/web/src/core-bridge/database.ts @@ -6,8 +6,16 @@ export type ManifestEntry = | { t: 'text'; localId: string; name?: string; text: string; ts: number }; export interface SavedResource { + creatorAccount?: string; + localScope?: string; + modifiedAt?: number; + needsJoin?: boolean; id: string; kind: 'channel' | 'share'; + /** 服务端空间设置名称的本地缓存,打开空间时会用最新设置刷新。 */ + name?: string; + /** 本终端在席位列表中的自定义显示名称。 */ + deviceLabel?: string; mode: 'enc' | 'raw'; /** Keys stay in IndexedDB. The page URL and localStorage never retain them. */ link: string; @@ -47,32 +55,88 @@ interface DraftFile { } interface WebDropDb extends DBSchema { + resourceDeletes: { key: string; value: { id: string; modifiedAt: number } }; resources: { key: string; value: SavedResource }; - outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string } }; + outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string; 'by-resource-created': [string, number] } }; draftFiles: { key: string; value: DraftFile; indexes: { 'by-resource': string } }; } -let dbPromise: Promise> | undefined; +const databases = new Map>>(); +let resourceScope = 'guest'; +export function currentResourceScope(): string { return resourceScope; } +export function setResourceScope(username: string | null): void { resourceScope = username ? `account:${username}` : 'guest'; } +let resourceChangeListener: (() => void) | undefined; + +export function setResourceChangeListener(listener: (() => void) | undefined): void { + resourceChangeListener = listener; +} function db(): Promise> { - dbPromise ??= openDB('webdrop-local', 2, { - upgrade(database, oldVersion) { + let dbPromise = databases.get(resourceScope); + dbPromise ??= openDB(`webdrop-2-${resourceScope}`, 2, { + upgrade(database, oldVersion, _newVersion, tx) { if (oldVersion < 1) { - database.createObjectStore('resources', { keyPath: 'id' }); - const outbox = database.createObjectStore('outbox', { keyPath: 'key' }); - outbox.createIndex('by-resource', 'resourceId'); - } - if (oldVersion < 2) { - const draftFiles = database.createObjectStore('draftFiles', { keyPath: 'key' }); - draftFiles.createIndex('by-resource', 'resourceId'); + database.createObjectStore('resources', { keyPath: 'id' }); + const outbox = database.createObjectStore('outbox', { keyPath: 'key' }); + outbox.createIndex('by-resource', 'resourceId'); + const draftFiles = database.createObjectStore('draftFiles', { keyPath: 'key' }); + draftFiles.createIndex('by-resource', 'resourceId'); + database.createObjectStore('resourceDeletes', { keyPath: 'id' }); } + if (oldVersion < 2) tx.objectStore('outbox').createIndex('by-resource-created', ['resourceId', 'createdAt']); }, }); + databases.set(resourceScope, dbPromise); return dbPromise; } export async function saveResource(resource: SavedResource): Promise { - await (await db()).put('resources', resource); + if (resource.localScope && resource.localScope !== resourceScope) throw new Error('账号已切换,操作已停止'); + resource.localScope = resourceScope; + resource.modifiedAt = Date.now(); + const database = await db(); + const tx = database.transaction(['resources', 'resourceDeletes'], 'readwrite'); + await tx.objectStore('resources').put(resource); + await tx.objectStore('resourceDeletes').delete(resource.id); + await tx.done; + resourceChangeListener?.(); +} + +export async function listAllResources(): Promise { + return (await db()).getAll('resources'); +} + +export async function listResourceDeletes(): Promise> { + return (await db()).getAll('resourceDeletes'); +} + +export async function importResourceDeletes(deletes: Array<{ id: string; modifiedAt: number }>): Promise { + const database = await db(); + const tx = database.transaction(['resources', 'resourceDeletes', 'outbox', 'draftFiles'], 'readwrite'); + for (const entry of deletes) { + const resource = await tx.objectStore('resources').get(entry.id); + if (resource && (resource.modifiedAt ?? resource.createdAt) > entry.modifiedAt) continue; + await tx.objectStore('resourceDeletes').put(entry); + await tx.objectStore('resources').delete(entry.id); + for (const key of await tx.objectStore('outbox').index('by-resource').getAllKeys(entry.id)) await tx.objectStore('outbox').delete(key); + for (const key of await tx.objectStore('draftFiles').index('by-resource').getAllKeys(entry.id)) await tx.objectStore('draftFiles').delete(key); + } + await tx.done; +} + +export async function importResources(resources: SavedResource[]): Promise { + const database = await db(); + const tx = database.transaction('resources', 'readwrite'); + for (const resource of resources) { + const existing = await tx.store.get(resource.id); + await tx.store.put({ ...resource, localScope: resourceScope, + endpointSecret: existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32)), + deviceLabel: existing?.deviceLabel, localItems: existing?.localItems, + pendingManifestId: existing?.pendingManifestId, + needsJoin: existing?.needsJoin ?? (!existing && resource.kind === 'channel'), + }); + } + await tx.done; } export async function getResource(id: string): Promise { @@ -86,13 +150,15 @@ export async function listResources(kind: SavedResource['kind']): Promise { const database = await db(); - const tx = database.transaction(['resources', 'outbox', 'draftFiles'], 'readwrite'); + const tx = database.transaction(['resources', 'outbox', 'draftFiles', 'resourceDeletes'], 'readwrite'); 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); await Promise.all(keys.map((key) => tx.objectStore('outbox').delete(key))); const fileKeys = await tx.objectStore('draftFiles').index('by-resource').getAllKeys(id); await Promise.all(fileKeys.map((key) => tx.objectStore('draftFiles').delete(key))); await tx.done; + resourceChangeListener?.(); } export async function saveDraftFile(resourceId: string, itemId: string, file: File): Promise { @@ -130,10 +196,17 @@ export async function dequeueFrame(key: string): Promise { } export async function nextQueuedFrame(resourceId: string): Promise { - const values = await (await db()).getAllFromIndex('outbox', 'by-resource', resourceId, 1); + const range = IDBKeyRange.bound([resourceId, 0], [resourceId, Number.MAX_SAFE_INTEGER]); + const values = await (await db()).getAllFromIndex('outbox', 'by-resource-created', range, 1); return values[0]; } +export async function lastQueuedAt(resourceId: string): Promise { + const range = IDBKeyRange.bound([resourceId, 0], [resourceId, Number.MAX_SAFE_INTEGER]); + const cursor = await (await db()).transaction('outbox').store.index('by-resource-created').openCursor(range, 'prev'); + return cursor?.value.createdAt ?? 0; +} + export async function dequeueItem(resourceId: string, itemId: string): Promise { const database = await db(); const tx = database.transaction('outbox', 'readwrite'); diff --git a/web/src/core-bridge/errors.ts b/web/src/core-bridge/errors.ts index 48eb8ef..9789756 100644 --- a/web/src/core-bridge/errors.ts +++ b/web/src/core-bridge/errors.ts @@ -1,13 +1,17 @@ const messages: Record = { not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。', - seats_full: '频道席位已满,请联系所有者。', not_joined: '你已被移出此频道。', + seats_full: '空间席位已满,请联系所有者。', not_joined: '你已被移出此空间。', visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。', forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。', too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。', captcha_required: '验证码未通过或已失效,请重新创建。', captcha_unavailable: '验证码服务暂时不可用,请稍后重试。', api_contract_mismatch: '服务端 API 有破坏性更新,请刷新或更新前端后再试。', - mode_mismatch: '频道加密模式与链接不符,已拒绝访问。', conflict: '上传内容发生冲突,已停止发送。', + mode_mismatch: '空间加密模式与链接不符,已拒绝访问。', conflict: '上传内容发生冲突,已停止发送。', + username_unavailable: '这个用户名已被使用。', vault_conflict: '账号资源在其他设备上刚刚更新,请再次同步。', + account_invalid: '用户名或密码错误,或登录已过期。', account_required: '请先登录账号。', + invitation_required: '该空间仅允许受邀账号加入,请登录受邀账号或联系空间主。', + channel_password_required: '请输入空间加入密码。', channel_password_invalid: '空间加入密码不正确。', }; export function errorMessage(error: unknown): string { diff --git a/web/src/core-bridge/mock.ts b/web/src/core-bridge/mock.ts index ec3adcb..037845b 100644 --- a/web/src/core-bridge/mock.ts +++ b/web/src/core-bridge/mock.ts @@ -3,7 +3,7 @@ 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, Item, Snapshot, WorkspaceEvent } from './types'; +import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, FileUploadOptions, Item, Snapshot, WorkspaceEvent } from './types'; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -28,9 +28,9 @@ interface Runtime { demo: boolean; } -const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', online: true, self: true }; +const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', self: true, joinedAt: Date.now() }; -/** 频道的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */ +/** 空间的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */ export function createMockApi(): WorkspaceApi { const runtimes = new Map(); const timers = new Set(); @@ -68,7 +68,7 @@ export function createMockApi(): WorkspaceApi { return rt; } - /** 演示频道里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */ + /** 演示空间里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */ function runDemoActivity(rt: Runtime) { later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' })); later(14000, () => { @@ -82,13 +82,16 @@ export function createMockApi(): WorkspaceApi { } 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, mode: opts.mode, role: 'owner', seats: opts.seats, createdAt, expiresAt: createdAt + opts.ttlSeconds * 1000, ...buildLinks(id, opts) }; + 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; }, @@ -106,12 +109,16 @@ export function createMockApi(): WorkspaceApi { 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', online: true }]); + newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', joinedAt: Date.now() }]); return info; }, @@ -140,16 +147,48 @@ export function createMockApi(): WorkspaceApi { }; }, - async sendText(text) { - put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text }); + async sendText(text, threadId) { + put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text, ...(threadId ? { threadId } : {}) }); }, - async sendFiles(files) { + 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 }, - name: file.name, mime: file.type, size: file.size, remaining: 10, + ...(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); @@ -163,6 +202,31 @@ export function createMockApi(): WorkspaceApi { } }, + 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; diff --git a/web/src/core-bridge/mockSeed.ts b/web/src/core-bridge/mockSeed.ts index 9ac15ea..7dc8038 100644 --- a/web/src/core-bridge/mockSeed.ts +++ b/web/src/core-bridge/mockSeed.ts @@ -9,9 +9,9 @@ export const DEMO_ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n'; const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF'; export const devices: Device[] = [ - { id: SELF, name: '本机 · Chrome', kind: 'desktop', online: true, self: true }, - { id: PHONE, name: 'iPhone', kind: 'phone', online: true }, - { id: 'tablet', name: 'iPad', kind: 'tablet', online: false, lastSeen: Date.now() - 3 * 3600_000 }, + { 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 = { @@ -19,6 +19,7 @@ export const channel: ChannelInfo = { 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}`, diff --git a/web/src/core-bridge/real.ts b/web/src/core-bridge/real.ts index 0e1a24e..8bb6ff5 100644 --- a/web/src/core-bridge/real.ts +++ b/web/src/core-bridge/real.ts @@ -1,9 +1,10 @@ import type { ParsedChannelLink } from '@/lib/channelLink'; +import type { AccountInvitation } from './account'; 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, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types'; +import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types'; const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; @@ -15,7 +16,13 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) = export const realApi: WorkspaceApi = { createChannel: (options: CreateOptions, captchaToken?: string) => callWorker('createChannel', { options, captchaToken, apiBase: API_BASE }), - joinChannel: (link: ParsedChannelLink) => callWorker('joinChannel', { link, apiBase: API_BASE }), + updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }), + renameDevice: (endpointHash, name) => callWorker('renameDevice', { id: currentChannelId(), endpointHash, name, apiBase: API_BASE }), + releaseDevice: (endpointHash) => callWorker('releaseDevice', { id: currentChannelId(), endpointHash, apiBase: API_BASE }), + joinChannel: (link: ParsedChannelLink, password?: string) => callWorker('joinChannel', { link, password, apiBase: API_BASE }), + inviteUser: (username) => callWorker('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }), + listInvitations: () => callWorker('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }), + revokeInvitation: (username) => callWorker('revokeChannelInvitation', { id: currentChannelId(), username, apiBase: API_BASE }), listSaved: () => callWorker('listChannels', { apiBase: API_BASE }), forget: (id) => callWorker('forgetChannel', { id, apiBase: API_BASE }), @@ -30,9 +37,17 @@ export const realApi: WorkspaceApi = { }; }, - sendText: (text) => callWorker('sendText', { id: currentChannelId(), text, apiBase: API_BASE }), - sendFiles: (files) => callWorker('sendFiles', { id: currentChannelId(), files, apiBase: API_BASE }), - editText: (id, text) => callWorker('editChannelText', { id: currentChannelId(), itemId: id, text, apiBase: API_BASE }), + sendText: (text, threadId) => callWorker('sendText', { id: currentChannelId(), text, threadId, apiBase: API_BASE }), + sendFiles: (files, options, threadId, folderId) => callWorker('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, apiBase: API_BASE }), + createThread: (name, groupId) => callWorker('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }), + createGroup: (name) => callWorker('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }), + 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 }), + 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 }), + editText: (id, text, threadId) => callWorker('editChannelText', { id: currentChannelId(), itemId: id, text, threadId, apiBase: API_BASE }), resolveConflict: (id, choice) => callWorker('resolveTextConflict', { id: currentChannelId(), itemId: id, choice, apiBase: API_BASE }), remove: (id) => callWorker('removeChannelItem', { id: currentChannelId(), itemId: id, apiBase: API_BASE }), setSnapshot: (text) => callWorker('updateSnapshot', { id: currentChannelId(), text, apiBase: API_BASE }), diff --git a/web/src/core-bridge/shareApi.ts b/web/src/core-bridge/shareApi.ts index 19fd4e8..001e364 100644 --- a/web/src/core-bridge/shareApi.ts +++ b/web/src/core-bridge/shareApi.ts @@ -2,7 +2,7 @@ import type { ParsedShareLink } from '@/lib/shareLink'; import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types'; /** - * 便捷分享的后端边界。和频道分开:分享没有设备、便签、冲突与历史,只有 + * 便捷分享的后端边界。和空间分开:分享没有设备、便签、冲突与历史,只有 * 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。 */ export interface ShareApi { diff --git a/web/src/core-bridge/types.ts b/web/src/core-bridge/types.ts index 02f5055..2fb6766 100644 --- a/web/src/core-bridge/types.ts +++ b/web/src/core-bridge/types.ts @@ -6,19 +6,29 @@ export interface Device { id: string; name: string; kind: DeviceKind; - online: boolean; self?: boolean; /** 正在编辑便签 */ editing?: boolean; - lastSeen?: number; + joinedAt?: number; } -/** 常驻频道:自己多台设备之间长期同步 */ +/** 常驻空间:自己多台设备之间长期同步 */ export interface ChannelInfo { + creatorAccount?: string; + joinPolicy?: 'open' | 'invite'; + allowGuests?: boolean; + passwordRequired?: boolean; id: string; + /** 服务端 channel settings 中的共享名称,加入者可见。 */ + name?: string; mode: 'enc' | 'raw'; role: 'owner' | 'member'; seats: number; + seatsUsed?: number; + membersCanUpload?: boolean; + ttlSeconds?: number; + /** 每个新上传文件的默认下载次数上限。 */ + maxDownloads?: number; memberLink: string; /** 只有所有者有 */ ownerLink?: string; @@ -27,12 +37,34 @@ export interface ChannelInfo { canUpload?: boolean; } -/** 新建频道的选项(protocol §12 设置的子集) */ +/** 新建空间的选项(protocol §12 设置的子集) */ export interface CreateOptions { + joinPolicy?: 'open' | 'invite'; + allowGuests?: boolean; + joinPassword?: string; + name?: string; mode: 'enc' | 'raw'; seats: number; ttlSeconds: number; membersCanUpload: boolean; + fileMaxDownloads?: number; +} + +export interface FileUploadOptions { + /** 单帧文件可随空间同步,加入者能立即预览;多帧文件仍由空间上限保护。 */ + directPreview: boolean; + maxDownloads: number; +} + +export interface ChannelSettingsPatch { + joinPolicy?: 'open' | 'invite'; + allowGuests?: boolean; + joinPassword?: string; + name?: string; + seats?: number; + membersCanUpload?: boolean; + ttlSeconds?: number; + fileMaxDownloads?: number; } /** @@ -71,12 +103,17 @@ export type ItemState = | { kind: 'tampered' }; interface ItemBase { + author?: SenderInfo; id: string; ts: number; deviceId: string; state: ItemState; + threadId?: string; + folderId?: string; } +export interface SenderInfo { username: string; displayName: string; avatar?: string } + export interface TextItem extends ItemBase { kind: 'text'; text: string; @@ -94,6 +131,8 @@ export interface FileItem extends ItemBase { remaining: number | null; preview: PreviewKind; gated?: boolean; + /** announce-only placeholder; omit thread/folder/name until explicit item read. */ + metadataHidden?: boolean; /** * 旧版本,新的在前,不含当前版本。 * 约定(待写入 protocol §7):新版本是一个新条目,其加密的 meta_json 带 `replaces`, @@ -119,13 +158,17 @@ export interface ArchiveEntry { dir: boolean; } -/** protocol §9:频道唯一的快照槽,最后写入者胜 */ +/** protocol §9:空间唯一的快照槽,最后写入者胜 */ export interface Snapshot { text: string; updatedBy: string; 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 WorkspaceEvent = | { t: 'reset' } | { t: 'error'; message: string } @@ -134,7 +177,17 @@ export type WorkspaceEvent = | { t: 'channel'; info: ChannelInfo } | { 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 1659570..1904db3 100644 --- a/web/src/core-bridge/worker.ts +++ b/web/src/core-bridge/worker.ts @@ -1,6 +1,6 @@ import { setApiBase } from './worker/http'; import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems'; -import { createChannel, forgetChannel, joinChannel, listChannels } from './worker/channelResources'; +import { createChannel, forgetChannel, joinChannel, listChannels, releaseDevice, renameDevice, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources'; import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend'; import { stopChannel } from './worker/channelTransport'; import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft'; @@ -10,6 +10,9 @@ import { sealShare } from './worker/shareSeal'; 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 { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice } from './worker/account'; interface WorkerRequest { requestId: number; operation: string; payload: unknown } interface WorkerScope { @@ -23,20 +26,48 @@ 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); + await restoreAccount(); const id = typeof payload.id === 'string' ? payload.id : ''; switch (operation) { + 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; + case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus(); + case 'registerAccount': return registerAccount(String(payload.username ?? ''), String(payload.password ?? '')); + case 'loginAccount': return loginAccount(String(payload.username ?? ''), String(payload.password ?? '')); + case 'accountStatus': return accountStatus(); + case 'logoutAccount': await logoutAccount(); return true; + case 'syncAccount': await syncAccount(); return accountStatus(); + case 'accountInvitations': return listAccountInvitations(); + case 'acceptInvitation': return acceptInvitation(id); + case 'inviteChannelUser': return inviteChannelUser(id, String(payload.username ?? '')); + case 'listChannelInvitations': return listChannelInvitations(id); + case 'revokeChannelInvitation': return revokeChannelInvitation(id, String(payload.username ?? '')); case 'createChannel': return createChannel(payload.options as Parameters[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined); + case 'updateChannelSettings': return updateChannelSettings(id, payload.patch as Parameters[1]); + case 'createThread': return createThread(id, String(payload.name ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined); + case 'createGroup': return createGroup(id, String(payload.name ?? '')); + case 'renameGroup': return renameGroup(id, String(payload.groupId ?? ''), String(payload.name ?? '')); + case 'groupThread': return groupThread(id, String(payload.threadId ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined); + case 'renameThread': return renameThread(id, String(payload.threadId ?? ''), String(payload.name ?? '')); + case 'createFolder': return createFolder(id, String(payload.threadId ?? ''), String(payload.name ?? ''), typeof payload.parentId === 'string' ? payload.parentId : undefined); + case 'renameFolder': return renameFolder(id, String(payload.folderId ?? ''), String(payload.name ?? ''), String(payload.threadId ?? '')); + case 'moveItems': return moveItems(id, payload.itemIds as string[], String(payload.threadId ?? ''), typeof payload.folderId === 'string' ? payload.folderId : undefined); + case 'renameDevice': return renameDevice(id, String(payload.endpointHash ?? ''), String(payload.name ?? '')); + case 'releaseDevice': return releaseDevice(id, String(payload.endpointHash ?? '')); case 'joinChannel': return joinChannel(payload as Parameters[0]); + case 'previewChannel': return previewChannel(payload.link as Parameters[0]); + case 'channelMembers': return channelMembers(id); case 'listChannels': return listChannels(); case 'forgetChannel': return forgetChannel(id); case 'connectChannel': void connectChannelResource(id); return true; case 'stopChannel': stopChannel(id); return true; - case 'sendText': return sendText(id, String(payload.text ?? '')); - case 'sendFiles': return sendFiles(id, payload.files as File[]); + case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined); + case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined); case 'removeChannelItem': return removeChannelItem(id, String(payload.itemId)); case 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined); case 'updateSnapshot': return updateSnapshot(id, String(payload.text ?? '')); - case 'editChannelText': return editChannelText(id, String(payload.itemId), String(payload.text ?? '')); + case 'editChannelText': return editChannelText(id, String(payload.itemId), String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined); case 'resolveTextConflict': return resolveTextConflict(); case 'markChannelSeen': return markChannelSeen(); case 'listArchive': return listArchive(id); @@ -59,11 +90,7 @@ async function dispatch(operation: string, input: unknown): Promise { scope.onmessage = (event) => { const request = event.data; - const payload = request.payload as { id?: string; link?: { id: string } }; - const key = payload.id ?? payload.link?.id; - const readOnly = /^(connect|stop|list|get)/.test(request.operation); - const task = key && !readOnly ? serial(key, () => dispatch(request.operation, request.payload)) - : dispatch(request.operation, request.payload); + const task = serial('account-scope', () => dispatch(request.operation, request.payload)); void task.then( (result) => scope.postMessage({ requestId: request.requestId, result }), (error: unknown) => scope.postMessage({ diff --git a/web/src/core-bridge/worker/account.ts b/web/src/core-bridge/worker/account.ts new file mode 100644 index 0000000..71c64a3 --- /dev/null +++ b/web/src/core-bridge/worker/account.ts @@ -0,0 +1,214 @@ +import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../database'; +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 { joinChannel } from './channelResources'; +import { openChannelGrant, sealChannelGrant } from './channelGrant'; +import { recoverAccountChannels } from './accountChannels'; +import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName } from './accountPersistence'; + +interface VaultBody { revision: number; vault: string } +interface AccountSession { + username: string; token: string; key: CryptoKey; profile: VaultProfile; revision: number; + 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 } +let account: AccountSession | undefined; +let restoration: Promise | undefined; +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; +} +function headers(session: Pick): Record { return { 'X-WD-Account': session.token }; } +function stillActive(session: AccountSession): void { + if (account !== session || session.controller.signal.aborted) throw new Error('账号已切换,操作已停止'); +} +async function applyProfile(profile: VaultProfile): Promise { + await importResources(profile.resources.map((row) => ({ ...row, endpointSecret: new Uint8Array(32) } as SavedResource))); + await importResourceDeletes(profile.deletes); +} +async function syncVault(): Promise { + if (!account) throw new Error('请先登录账号'); + const session = account; + if (session.syncing) { session.dirty = true; return session.syncing; } + session.syncing = (async () => { + await recoverAccountChannels(session.username, session.profile.keys, session.controller.signal); + do { + session.dirty = false; + for (let attempt = 0; attempt < 3; attempt++) { + 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); + 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); + await rememberAccount({ username: session.username, token: session.token, key: session.key, ...remote }); + break; + } + const vault = await sealJson(merged, session.key); + stillActive(session); + try { + const result = await apiFetch('/accounts/vault', { method: 'PUT', headers: { ...headers(session), 'Content-Type': 'application/json' }, + signal: session.controller.signal, body: JSON.stringify({ revision: remote.revision, vault }) }).then((r) => r.json() as Promise); + stillActive(session); + await applyProfile(merged); + session.profile = merged; session.revision = result.revision; session.error = undefined; + setAccountIdentity(session, merged.keys.publicKey, merged.personal); + await rememberAccount({ username: session.username, token: session.token, key: session.key, vault, revision: result.revision }); + break; + } catch (error) { + if (attempt === 2 || !(error instanceof Error && 'status' in error && error.status === 409)) throw error; + } + } + } while (session.dirty && account === session); + })().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) { + account = undefined; clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); + setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null); + void forgetAccount(); + } + throw error; + }).finally(() => { session.syncing = undefined; }); + return session.syncing; +} + +async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody): Promise { + const profile = validateVault(await openJson(remote.vault, key)); + clearResourceSessions(); setResourceScope(identity.username); + const session: AccountSession = { ...identity, key, profile, revision: remote.revision, controller: new AbortController(), dirty: false }; + account = session; setAccountIdentity(identity, profile.keys.publicKey, profile.personal); + await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes())); + setResourceChangeListener(scheduleSync); + session.polling = setInterval(scheduleSync, 20_000); + return session; +} + +export function restoreAccount(): Promise { + restoration ??= (async () => { + const saved = await rememberedAccount(); + if (!saved || account) return; + let remote: VaultBody; + try { + remote = await apiFetch('/accounts/vault', { headers: headers(saved) }).then((r) => r.json() as Promise); + } catch (error) { + if (error instanceof Error && 'status' in error && error.status === 401) { await forgetAccount(); return; } + 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); + await syncVault().catch(() => {}); + } catch (error) { await logoutAccount(); throw error; } + })().catch((error: unknown) => { restoration = undefined; throw error; }); + return restoration; +} +function scheduleSync(): void { + if (!account) return; + account.dirty = true; clearTimeout(account.timer); + account.timer = setTimeout(() => { void syncVault().catch(() => {}); }, 700); +} + +export async function registerAccount(input: string, password: string): Promise>> { + if (account) throw new Error('请先退出当前账号'); + const username = usernameOf(input); + 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: [] }; + 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) }) }); + } finally { verifier.fill(0); } + return loginAccount(username, password); +} +export async function loginAccount(input: string, password: string): Promise>> { + 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 }>); + const { verifier, key } = await deriveAccountKeys(password, unb64(challenge.salt)); + let identity: { username: string; token: string }; + 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 }>); + } finally { verifier.fill(0); } + try { + 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 published = JSON.parse(publicKey.public_key) as JsonWebKey; + if (published.x !== profile.keys.publicKey.x || published.y !== profile.keys.publicKey.y) throw new Error('账号邀请公钥与保险库不匹配'); + await activate(identity, key, remote); + await rememberAccount({ ...identity, key, ...remote }); + await syncVault().catch(() => {}); + return accountStatus(); + } catch (error) { + await logoutAccount(); + void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(identity) }).catch(() => {}); + throw error; + } +} +export async function accountStatus(): Promise<{ username: string | null; revision: number; personal?: PersonalProfile; error?: string }> { + return { username: account?.username ?? null, revision: account?.revision ?? 0, + ...(account?.profile.personal ? { personal: account.profile.personal } : {}), + ...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) }; +} +export async function logoutAccount(): Promise { + 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); + await forgetAccount(); + if (session) void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(session) }).catch(() => {}); +} +export async function syncAccount(): Promise { await syncVault(); } +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 rememberAccount({ username: account.username, token: account.token, key: account.key, vault: await sealJson(account.profile, account.key), revision: account.revision }); + await syncVault(); +} +export async function listAccountDevices(): Promise { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); } +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()); +} +export async function revokeAccountDevice(id: string): Promise { + requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'DELETE' }); + if ((await localAccountDevice()).id === id) await logoutAccount(); +} +export async function listAccountInvitations(): Promise { + requireAccount(); return apiFetch('/accounts/invitations').then((r) => r.json() as Promise); +} +export async function acceptInvitation(channelId: string): Promise { + if (!account) throw new Error('请先登录账号'); + const session = account; + const invitation = (await listAccountInvitations()).find((row) => row.channel_id === channelId); + if (!invitation) throw new Error('邀请已失效'); + const link = await openChannelGrant(invitation.payload, session.username, channelId, session.profile.keys.privateKey, 'member', self.location.origin); + stillActive(session); await joinChannel({ link }); await syncVault(); +} +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 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 }) }); +} +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); +} +export async function revokeChannelInvitation(channelId: string, input: string): Promise { + const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders('owner') }); +} diff --git a/web/src/core-bridge/worker/accountChannels.ts b/web/src/core-bridge/worker/accountChannels.ts new file mode 100644 index 0000000..b3f729f --- /dev/null +++ b/web/src/core-bridge/worker/accountChannels.ts @@ -0,0 +1,45 @@ +import { ChannelSession } from '@webdrop/core'; +import { getResource, importResources, listAllResources, listResourceDeletes, type SavedResource } from '../database'; +import { openChannelGrant, sealChannelGrant } 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 } + +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 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 (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); + const session = await ChannelSession.open(makeLink(self.location.origin, parsed.id, parsed.keys)); + try { + 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, + 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 }; + 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.test.ts b/web/src/core-bridge/worker/accountCrypto.test.ts new file mode 100644 index 0000000..843304e --- /dev/null +++ b/web/src/core-bridge/worker/accountCrypto.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { decryptInvitation, encryptInvitation, generateInvitationKeys, openJson, sealJson } from './accountCrypto'; +describe('encrypted invitation recipient binding', () => { + it('only the addressed account and channel can decrypt the member link', async () => { + const bob = await generateInvitationKeys(); const carol = await generateInvitationKeys(); + const payload = await encryptInvitation('https://drop.test/c/channel#k=secret', 'bob', 'channel', bob.publicKey); + expect(payload).not.toContain('secret'); + expect(await decryptInvitation(payload, 'bob', 'channel', bob.privateKey)).toContain('#k=secret'); + await expect(decryptInvitation(payload, 'carol', 'channel', carol.privateKey)).rejects.toThrow(); + await expect(decryptInvitation(payload, 'bob', 'other-channel', bob.privateKey)).rejects.toThrow(); + }); + it('modified encrypted vault contents are rejected', async () => { + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + const encrypted = await sealJson({ resources: [] }, key); + const raw = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0)); raw[13] = (raw[13] ?? 0) ^ 1; + const corrupted = btoa(String.fromCharCode(...raw)); + await expect(openJson(corrupted, key)).rejects.toThrow('校验失败'); + }); +}); diff --git a/web/src/core-bridge/worker/accountCrypto.ts b/web/src/core-bridge/worker/accountCrypto.ts new file mode 100644 index 0000000..08ffacd --- /dev/null +++ b/web/src/core-bridge/worker/accountCrypto.ts @@ -0,0 +1,67 @@ +const encoder = new TextEncoder(); +const vaultLabel = 'webdrop-account-vault-v1'; +const invitationLabel = 'webdrop-invitation-v2'; +export function bytes(value: Uint8Array): ArrayBuffer { return Uint8Array.from(value).buffer; } +export function b64(value: Uint8Array): string { + let text = ''; + for (let i = 0; i < value.length; i += 32768) text += String.fromCharCode(...value.subarray(i, i + 32768)); + 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 }> { + 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 }; + } finally { root.fill(0); } +} +export async function sealJson(value: unknown, key: CryptoKey, context = vaultLabel): Promise { + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData: encoder.encode(context) }, key, encoder.encode(JSON.stringify(value)))); + const result = new Uint8Array(nonce.length + cipher.length); result.set(nonce); result.set(cipher, 12); + if (result.length > 1_300_000) throw new Error('账号保险库已达到大小上限'); + return b64(result); +} +export async function openJson(value: string, key: CryptoKey, context = vaultLabel): Promise { + const input = unb64(value); + if (input.length < 28 || input.length > 1_300_000) throw new Error('加密数据格式无效'); + try { + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes(input.subarray(0, 12)), additionalData: encoder.encode(context) }, key, bytes(input.subarray(12))); + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plain)); + } catch { throw new Error('加密数据校验失败,已拒绝导入'); } +} +export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } +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) }; +} +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, []); + const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: 'ECDH', public: peer }, own, 256)); + try { + const root = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']); + return await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: encoder.encode(context), info: encoder.encode(invitationLabel) }, root, + { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + } finally { shared.fill(0); } +} +export async function encryptInvitation(link: string, recipient: string, channelId: string, publicKey: JsonWebKey): Promise { + const pair = await generateInvitationKeys(); const context = `${invitationLabel}:${recipient}:${channelId}`; + const key = await invitationKey(pair.privateKey, publicKey, context); + return JSON.stringify({ v: 2, publicKey: pair.publicKey, cipher: await sealJson({ link }, key, context) }); +} +export async function decryptInvitation(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey): Promise { + const envelope = JSON.parse(payload) as { v: number; publicKey: JsonWebKey; cipher: string }; + if (envelope.v !== 2 || typeof envelope.cipher !== 'string') throw new Error('邀请格式无效'); + const context = `${invitationLabel}:${recipient}:${channelId}`; + const key = await invitationKey(privateKey, envelope.publicKey, context); + const value = await openJson(envelope.cipher, key, context) as { link?: unknown }; + if (typeof value.link !== 'string') throw new Error('邀请格式无效'); + return value.link; +} diff --git a/web/src/core-bridge/worker/accountPersistence.ts b/web/src/core-bridge/worker/accountPersistence.ts new file mode 100644 index 0000000..8b60808 --- /dev/null +++ b/web/src/core-bridge/worker/accountPersistence.ts @@ -0,0 +1,34 @@ +import { openDB, type DBSchema } from 'idb'; +import { apiPath } from './http'; + +export interface RememberedAccount { + username: string; + token: string; + key: CryptoKey; + vault: string; + revision: number; +} +interface LocalIdentity extends DBSchema { + sessions: { key: string; value: RememberedAccount }; + device: { key: string; value: { id: string; name: string } }; +} +const database = () => openDB('webdrop-account-local-2', 1, { upgrade(db) { + db.createObjectStore('sessions'); db.createObjectStore('device'); +} }); +const endpoint = () => apiPath('/'); +export async function rememberedAccount(): Promise { return (await database()).get('sessions', endpoint()); } +export async function rememberAccount(value: RememberedAccount): Promise { await (await database()).put('sessions', value, endpoint()); } +export async function forgetAccount(): Promise { await (await database()).delete('sessions', endpoint()); } +export async function localAccountDevice(): Promise<{ id: string; name: string }> { + const db = await database(); + const existing = await db.get('device', endpoint()); + if (existing) return existing; + const name = /Android/i.test(navigator.userAgent) ? 'Android 浏览器' : /iPhone|iPad/i.test(navigator.userAgent) ? 'Apple 移动浏览器' + : /Windows/i.test(navigator.userAgent) ? 'Windows 浏览器' : /Mac/i.test(navigator.userAgent) ? 'Mac 浏览器' : '浏览器设备'; + const value = { id: crypto.randomUUID(), name }; + await db.put('device', value, endpoint()); + return value; +} +export async function saveDeviceName(name: string): Promise { + const device = await localAccountDevice(); await (await database()).put('device', { ...device, name }, endpoint()); +} diff --git a/web/src/core-bridge/worker/accountSession.ts b/web/src/core-bridge/worker/accountSession.ts new file mode 100644 index 0000000..bd8bab0 --- /dev/null +++ b/web/src/core-bridge/worker/accountSession.ts @@ -0,0 +1,17 @@ +import type { PersonalProfile } from './accountVault'; +let identity: { username: string; token: string } | undefined; +let publicKey: JsonWebKey | undefined; +let personal: PersonalProfile | undefined; +export function setAccountIdentity(value: { username: string; token: string } | undefined, key?: JsonWebKey, profile?: PersonalProfile): void { identity = value; publicKey = key; personal = value ? profile : undefined; } +export function accountDisplay(): { username: string; displayName: string; avatar?: string } | undefined { + return identity ? { username: identity.username, displayName: personal?.displayName || identity.username, ...(personal?.avatar ? { avatar: personal.avatar } : {}) } : undefined; +} +export function accountPublicKey(): JsonWebKey { + if (!identity || !publicKey) throw new Error('请先解锁账号'); + return publicKey; +} +export function accountToken(): string | undefined { return identity?.token; } +export function requireAccount(): string { + if (!identity) throw new Error('请先登录账号'); + return identity.username; +} diff --git a/web/src/core-bridge/worker/accountVault.test.ts b/web/src/core-bridge/worker/accountVault.test.ts new file mode 100644 index 0000000..b6c90fd --- /dev/null +++ b/web/src/core-bridge/worker/accountVault.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { exportResource, mergeVault, validatePersonal, 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: [] }); +describe('account vault portability and merge', () => { + it('never synchronizes device identity or local scope', () => { + const exported = exportResource(resource()); + expect(exported).not.toHaveProperty('endpointSecret'); expect(exported).not.toHaveProperty('deviceLabel'); + expect(exported).not.toHaveProperty('localScope'); expect(exported.link).toContain('secret'); + }); + it('a stale device does not overwrite a newer remote resource', () => { + expect(mergeVault(profile(), [resource(10)], []).resources[0]?.name).toBe('version-20'); + }); + it('resource removal survives a stale device syncing its old copy', () => { + const remote = { ...profile(), resources: [], deletes: [{ id: resource().id, modifiedAt: 30 }] }; + expect(mergeVault(remote, [resource(20)], []).resources).toEqual([]); + expect(mergeVault(profile(), [], [{ id: resource().id, modifiedAt: 30 }]).resources).toEqual([]); + }); + it('explicitly rejoining after removal restores the resource', () => { + const remote = { ...profile(), resources: [], deletes: [{ id: resource().id, modifiedAt: 30 }] }; + 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 new file mode 100644 index 0000000..46dc3fe --- /dev/null +++ b/web/src/core-bridge/worker/accountVault.ts @@ -0,0 +1,48 @@ +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 } +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; +} +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' + || !profile.keys.publicKey?.x || profile.keys.publicKey.d || !Array.isArray(profile.resources) || !Array.isArray(profile.deletes)) + 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; +} +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 { + 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) { + const current = byId.get(row.id); + if (!current || (row.modifiedAt ?? row.createdAt) > (current.modifiedAt ?? current.createdAt)) byId.set(row.id, exportResource(row)); + } + for (const row of deletes) if ((removed.get(row.id)?.modifiedAt ?? 0) < row.modifiedAt) removed.set(row.id, row); + for (const [id, row] of byId) { + 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()] }; +} diff --git a/web/src/core-bridge/worker/channelEvents.ts b/web/src/core-bridge/worker/channelEvents.ts index 92ff4e2..bd49540 100644 --- a/web/src/core-bridge/worker/channelEvents.ts +++ b/web/src/core-bridge/worker/channelEvents.ts @@ -6,21 +6,27 @@ 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 { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog'; export function mapReceived(item: ReceivedItem): Item { const timestamp = item.meta?.ts ?? Date.now(); const deviceId = item.meta?.dev ?? '未知设备'; const state = item.complete ? { kind: 'complete' as const } : { kind: 'incomplete' as const }; - if (item.kind === 'file' || item.kind === 'raw') { + if (item.kind === 'file') { const name = item.meta?.name ?? '加密文件'; return { id: item.itemId, kind: 'file', ts: timestamp, deviceId, state, name, + ...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}), + ...(item.meta?.folderId ? { folderId: item.meta.folderId } : {}), mime: item.meta?.mime ?? 'application/octet-stream', size: item.meta?.size ?? item.content.byteLength, remaining: null, preview: previewKindOf(item.meta?.mime ?? '', name), gated: item.gated, }; } - return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state, text: new TextDecoder().decode(item.content) }; + return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state, + ...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}), + text: new TextDecoder().decode(item.content) }; } export async function applyChannelRecord(resource: SavedResource, session: ChannelSession, input: Uint8Array | LogRecord): Promise { @@ -33,7 +39,19 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann for (const event of events) { switch (event.type) { case 'item': { + const control = applyControl(event.item); + if (control !== false) { + if (control && control.t === 'file-catalog') { + const key = `${resource.id}:${control.item.id}`; + fileCatalogs.set(key, control.item); + if (visibleFiles.has(key)) publishWorkspace(resource.id, control); + break; + } + if (control) publishWorkspace(resource.id, control); + break; + } const item = mapReceived(event.item); + if (item.kind === 'file') visibleFiles.add(`${resource.id}:${item.id}`); if (event.item.snapshot) { let snapshot: Snapshot = { text: '', updatedBy: item.deviceId, updatedAt: item.ts }; if (item.kind === 'text') { @@ -52,16 +70,22 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann break; } case 'announce': { + const key = `${resource.id}:${event.itemId}`; + visibleFiles.add(key); + const catalog = fileCatalogs.get(key); + if (catalog) { publishWorkspace(resource.id, { t: 'file-catalog', item: catalog }); break; } const local = (await getResource(resource.id))?.localItems?.find((item) => item.id === event.itemId); const placeholder: FileItem = local?.kind === 'file' ? local : { id: event.itemId, kind: 'file', ts: event.createdAt, deviceId: '未知设备', state: { kind: 'incomplete' }, name: '受限下载内容', mime: 'application/octet-stream', size: 0, remaining: null, preview: 'none', - gated: true, + gated: true, metadataHidden: true, }; publishWorkspace(resource.id, { t: 'item', item: placeholder }); break; } case 'removed': + visibleFiles.delete(`${resource.id}:${event.itemId}`); + fileCatalogs.delete(`${resource.id}:${event.itemId}`); blobs.delete(`${resource.id}:${event.itemId}`); publishWorkspace(resource.id, { t: 'removed', id: event.itemId }); break; @@ -79,13 +103,15 @@ 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')); 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 }); publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) }); const ownId = toBase64Url(session.endpointHash); - let devices = [deviceInfo(ownId, Date.now(), deviceIdentity().name, ownId)]; + 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>( `/channels/${resource.id}/seats`, session.authHeaders('owner')); @@ -95,16 +121,15 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS try { name = await session.openLabel(seat.label, seat.endpoint_hash); } catch { name = '设备名校验失败'; } } - return deviceInfo(seat.endpoint_hash, seat.joined_at, name, ownId); + return deviceInfo(seat.endpoint_hash, seat.joined_at, name ?? (seat.endpoint_hash === ownId ? ownName : undefined), ownId); })); - const own = devices.find((device) => device.self); - if (own) own.name = deviceIdentity().name; } publishWorkspace(resource.id, { t: 'devices', devices }); } export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise { publishWorkspace(resource.id, { t: 'reset' }); + clearFileCatalog(resource.id); readers.set(resource.id, session.createItemReader()); let from = 0; for (;;) { diff --git a/web/src/core-bridge/worker/channelGrant.test.ts b/web/src/core-bridge/worker/channelGrant.test.ts new file mode 100644 index 0000000..ab02168 --- /dev/null +++ b/web/src/core-bridge/worker/channelGrant.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { ChannelSession } from '@webdrop/core'; +import { generateInvitationKeys } from './accountCrypto'; +import { openChannelGrant, sealChannelGrant } from './channelGrant'; + +describe('account-bound channel grants', () => { + it('delivers the same channel key to another account without a public secret URL', async () => { + const { session } = await ChannelSession.create(); + const bob = await generateInvitationKeys(); + const payload = await sealChannelGrant(session.link('https://drop.test'), 'bob', bob.publicKey); + expect(payload).not.toContain('#k='); + const parsed = await openChannelGrant(payload, 'bob', session.id, bob.privateKey, 'member', 'https://other.test'); + expect(parsed.mode).toBe('enc'); expect(parsed.isOwner).toBe(false); expect(parsed.keys.a).toBeUndefined(); + const recovered = await ChannelSession.open(`https://other.test/c/${parsed.id}#k=${parsed.keys.k}`); + const plan = session.encryptText('private file message'); + const reader = recovered.createItemReader(); + const frames = []; for await (const frame of plan.frames) frames.push(frame.bytes); + const events = await reader.pushFrame(frames[0]!); + expect(events.some((e) => e.type === 'item' && new TextDecoder().decode(e.item.content) === 'private file message')).toBe(true); + session.dispose(); recovered.dispose(); + }); + it('binds the envelope to account, channel and owner/member role', async () => { + const { session } = await ChannelSession.create(); const alice = await generateInvitationKeys(); const other = await generateInvitationKeys(); + const payload = await sealChannelGrant(session.link('https://drop.test', 'owner'), 'alice', alice.publicKey); + expect((await openChannelGrant(payload, 'alice', session.id, alice.privateKey, 'owner', 'https://drop.test')).isOwner).toBe(true); + await expect(openChannelGrant(payload, 'alice', session.id, other.privateKey, 'owner', 'https://drop.test')).rejects.toThrow(); + await expect(openChannelGrant(payload, 'bob', session.id, alice.privateKey, 'owner', 'https://drop.test')).rejects.toThrow(); + await expect(openChannelGrant(payload, 'alice', session.id, alice.privateKey, 'member', 'https://drop.test')).rejects.toThrow(); + await expect(openChannelGrant(payload, 'alice', 'AAAAAAAAAAAAAAAAAAAAAA', alice.privateKey, 'owner', 'https://drop.test')).rejects.toThrow(); + session.dispose(); + }); +}); diff --git a/web/src/core-bridge/worker/channelGrant.ts b/web/src/core-bridge/worker/channelGrant.ts new file mode 100644 index 0000000..830ad97 --- /dev/null +++ b/web/src/core-bridge/worker/channelGrant.ts @@ -0,0 +1,37 @@ +import { parseChannelLink, type ParsedChannelLink } from '@/lib/channelLink'; +import { makeLink } from './links'; +import { decryptInvitation, encryptInvitation } from './accountCrypto'; + +interface ChannelGrant { + v: 1; + channelId: string; + mode: 'enc' | 'raw'; + role: 'owner' | 'member'; + keys: ParsedChannelLink['keys']; +} + +export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey): Promise { + const parsed = parseChannelLink(link); + if (!parsed.ok) throw new Error('空间凭据格式无效'); + const grant: ChannelGrant = { v: 1, channelId: parsed.link.id, mode: parsed.link.mode, + role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys }; + return encryptInvitation(JSON.stringify(grant), recipient, grant.channelId, publicKey); +} + +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); + } + 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 cc09407..894693a 100644 --- a/web/src/core-bridge/worker/channelItems.ts +++ b/web/src/core-bridge/worker/channelItems.ts @@ -10,6 +10,9 @@ import { stopChannel, connectChannel } from './channelTransport'; import { downloadDecrypted } from './transfer'; import { publishWorkspace } from './presentation'; import { IntegrityError } from '@webdrop/core'; +import { fileCatalogs, verifyFileCatalog } from './fileCatalog'; +import { dequeueItem, saveResource } from '../database'; +import { serial } from './serial'; export function connectChannelResource(id: string): () => void { void connectChannel(id); @@ -17,10 +20,14 @@ export function connectChannelResource(id: string): () => void { } export async function removeChannelItem(resourceId: string, itemId: string): Promise { - const { session } = await getSession(resourceId); + const { session, resource } = await getSession(resourceId); const role = session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'; - await apiFetch(`/channels/${resourceId}/items/${itemId}`, { - method: 'DELETE', headers: session.authHeaders(role), + await serial(`outbox:${resourceId}`, async () => { + await apiFetch(`/channels/${resourceId}/items/${itemId}`, { method: 'DELETE', headers: session.authHeaders(role) }); + await dequeueItem(resourceId, itemId); + resource.localItems = resource.localItems?.filter((item) => item.id !== itemId); + await saveResource(resource); + blobs.delete(`${resourceId}:${itemId}`); }); } @@ -31,22 +38,33 @@ export async function getChannelBlob(resourceId: string, itemId: string, version const { session, resource } = await getSession(resourceId); if (resource.kind !== 'channel') throw new Error('资源类型不匹配'); let result: Awaited>; - try { result = await downloadDecrypted(session, resource, itemId); } + try { + result = await downloadDecrypted(session, resource, itemId); + verifyFileCatalog(fileCatalogs.get(`${resourceId}:${itemId}`), result.meta, result.blob.size); + } catch (error) { if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), deviceId: '', text: '', state: { kind: 'tampered' } } }); throw error; } - blobs.set(`${resourceId}:${itemId}`, result.blob); + const fileBlob = result.meta?.kind === 'file' && typeof File !== 'undefined' + ? new File([result.blob], result.meta.name ?? '加密文件', { + type: result.meta.mime ?? result.blob.type, + lastModified: result.meta.ts, + }) + : result.blob; + blobs.set(`${resourceId}:${itemId}`, fileBlob); if (result.meta?.kind === 'file') { const item: FileItem = { id: itemId, kind: 'file', ts: result.meta.ts, deviceId: result.meta.dev ?? '未知设备', state: { kind: 'complete' }, - name: result.meta.name ?? '加密文件', mime: result.meta.mime ?? result.blob.type, size: result.blob.size, + ...(result.meta.threadId ? { threadId: result.meta.threadId } : {}), + ...(result.meta.folderId ? { folderId: result.meta.folderId } : {}), + name: result.meta.name ?? '加密文件', mime: result.meta.mime ?? result.blob.type, size: fileBlob.size, remaining: null, preview: 'none', gated: true, }; item.preview = previewKindOf(item.mime, item.name); publishWorkspace(resourceId, { t: 'item', item }); } - return result.blob; + return fileBlob; } export async function resolveTextConflict(): Promise { diff --git a/web/src/core-bridge/worker/channelResources.ts b/web/src/core-bridge/worker/channelResources.ts index dd420de..459d7d0 100644 --- a/web/src/core-bridge/worker/channelResources.ts +++ b/web/src/core-bridge/worker/channelResources.ts @@ -1,47 +1,63 @@ -import { ChannelSession } from '@webdrop/core'; +import { ChannelSession, toBase64Url } from '@webdrop/core'; import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; import type { ParsedChannelLink } from '@/lib/channelLink'; -import type { ChannelInfo, CreateOptions } from '../types'; +import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types'; import { apiFetch, readJson } from './http'; -import { sessions } from './sessions'; +import { getSession, sessions } from './sessions'; import { deviceIdentity } from './presentation'; import { makeLink } from './links'; import { toChannelInfo, type ChannelWire } from './wire'; import { stopChannel } from './channelTransport'; +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 endpointSecret = crypto.getRandomValues(new Uint8Array(32)); const { session, request } = await ChannelSession.create({ mode: options.mode, endpointSecret, settings: { + 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: 10, + file_max_downloads: options.fileMaxDownloads ?? 10, ui_restrict_copy_download: false, + join_policy: options.joinPolicy ?? 'invite', allow_guests: options.allowGuests ?? false, }, }); try { + const keyPackage = await sealChannelGrant(session.link(self.location.origin, 'owner'), username, accountPublicKey()); const info = await apiFetch('/channels', { - method: 'POST', headers: { ...request.headers, 'Content-Type': 'application/json', ...(captchaToken ? { 'X-WD-Cap': captchaToken } : {}) }, body: JSON.stringify(request.body), + method: 'POST', headers: { ...request.headers, 'Content-Type': 'application/json', ...(captchaToken ? { 'X-WD-Cap': captchaToken } : {}) }, body: JSON.stringify({ ...request.body, key_package: keyPackage, join_password: options.joinPassword || undefined }), }).then((response) => response.json() as Promise); session.verifyChannelInfo(info); const resource: SavedResource = { id: session.id, + creatorAccount: info.creator_account, kind: 'channel', mode: session.mode, role: 'owner', + name: info.settings.name, 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, maxDownloads: info.settings.file_max_downloads, localItems: [], }; + const ownerLabel = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name); + await apiFetch(`/channels/${session.id}/seats/me`, { + method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, body: ownerLabel, + }); await saveResource(resource); sessions.set(session.id, session); + await createThread(session.id, '常规'); return toChannelInfo(info, resource); } catch (error) { session.dispose(); @@ -49,7 +65,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin } } -export async function joinChannel(input: { link: ParsedChannelLink }): Promise { +export async function joinChannel(input: { link: ParsedChannelLink; password?: string }): Promise { const { id, keys } = input.link; const existing = await getResource(id); const endpointSecret = existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32)); @@ -57,15 +73,20 @@ export async function joinChannel(input: { link: ParsedChannelLink }): Promise(`/channels/${id}`, session.authHeaders('read')); session.verifyChannelInfo(info); - await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }); + 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 }>); + info.seats_used = joined.seats_used; const resource: SavedResource = { id, kind: 'channel', mode: session.mode, role: session.role, + creatorAccount: info.creator_account, + name: info.settings.name, 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, maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [], }; - const label = await session.sealLabel(deviceIdentity().name); + const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name); await apiFetch(`/channels/${id}/seats/me`, { method: 'PUT', headers: { ...session.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, body: label, }); @@ -78,14 +99,87 @@ export async function joinChannel(input: { link: ParsedChannelLink }): Promise { + const session = await ChannelSession.open(makeLink(self.location.origin, link.id, link.keys)); + try { + const info = await readJson(`/channels/${link.id}`, session.authHeaders('read')); + session.verifyChannelInfo(info); + return toChannelInfo(info, { id: link.id, kind: 'channel', mode: session.mode, role: session.role, + endpointSecret: session.endpointSecret, link: session.link(self.location.origin), createdAt: info.created_at, + expiresAt: info.expires_at, seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload }); + } finally { session.dispose(); } +} + +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) => ({ - id: resource.id, mode: resource.mode, role: resource.role, seats: resource.seats, + 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, })); } +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 = {}; + 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; + if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds; + if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads; + if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy; + if (patch.allowGuests !== undefined) body.allow_guests = patch.allowGuests; + if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword; + const info = await apiFetch(`/channels/${resourceId}/settings`, { + method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, + 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) }); +} + +export async function renameDevice(resourceId: string, endpointHash: string, name: string): Promise { + const { session, resource } = await getSession(resourceId); + const ownEndpointHash = toBase64Url(session.endpointHash); + const isSelf = endpointHash === ownEndpointHash; + if (!isSelf && !session.can('owner')) throw new Error('只有所有者可以重命名其他设备'); + const normalized = name.trim(); + if (!normalized) throw new Error('设备名称不能为空'); + const label = await session.sealLabel(normalized, { endpointHash }); + const path = isSelf + ? `/channels/${resourceId}/seats/me` + : `/channels/${resourceId}/seats/${endpointHash}/label`; + const role = !isSelf || session.can('owner') ? 'owner' : 'read'; + await apiFetch(path, { + method: 'PUT', headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream' }, body: label, + }); + if (isSelf) { + resource.deviceLabel = normalized; + await saveResource(resource); + } + await channelMetadata(resource, session); +} + +export async function releaseDevice(resourceId: string, endpointHash: string): Promise { + const { session, resource } = await getSession(resourceId); + if (!session.can('owner')) throw new Error('只有所有者可以移除设备'); + if (endpointHash === toBase64Url(session.endpointHash)) throw new Error('不能移除当前设备'); + await apiFetch(`/channels/${resourceId}/seats/${endpointHash}`, { method: 'DELETE', headers: session.authHeaders('owner') }); + await channelMetadata(resource, session); +} + export async function forgetChannel(id: string): Promise { stopChannel(id); sessions.get(id)?.dispose(); diff --git a/web/src/core-bridge/worker/channelSend.ts b/web/src/core-bridge/worker/channelSend.ts index ebb0a24..3953629 100644 --- a/web/src/core-bridge/worker/channelSend.ts +++ b/web/src/core-bridge/worker/channelSend.ts @@ -1,10 +1,12 @@ import { removeChannelItem } from './channelItems'; +import { queueFileCatalog, queueItemAuthor } from './channelThreads'; +import { senderSnapshot } from './senderSnapshot'; import { errorMessage } from '../errors'; import { saveResource } from '../database'; import { previewKindOf } from '@/lib/fileKind'; -import type { FileItem, Item } from '../types'; +import type { FileItem, FileUploadOptions, Item } from '../types'; import { blobs, getSession } from './sessions'; import { deviceIdentity } from './presentation'; @@ -14,38 +16,53 @@ 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): Promise<{ itemId: string; uploaded: boolean }> { +export async function sendText(resourceId: string, text: string, threadId?: 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('此链接没有上传权限'); - const device = deviceIdentity(); + const identity = await deviceIdentity(session.endpointSecret); + const device = { ...identity, name: resource.deviceLabel ?? identity.name }; session.deviceName = device.name; const ts = Date.now(); - const plan = session.encryptText(text, { dev: device.name, ts }); - const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0 }, text }; + const author = await senderSnapshot(); + const plan = session.encryptText(text, { dev: device.name, ts, threadId }); + 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); 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[]): Promise { +export async function sendFiles(resourceId: string, files: File[], options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, 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 device = deviceIdentity(); + const identity = await deviceIdentity(session.endpointSecret); + const device = { ...identity, name: resource.deviceLabel ?? identity.name }; session.deviceName = device.name; const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA; + const author = await senderSnapshot(); for (const file of files) { const ts = Date.now(); let item: FileItem | undefined; try { - const plan = session.encryptFile(file, {}, { maxDownloads: resource.maxDownloads ?? 10, dev: device.name, ts, maxFrameData }); + const plan = session.encryptFile(file, {}, { + ...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }), + dev: device.name, ts, maxFrameData, threadId, folderId, + }); item = { id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' }, + ...(author ? { author } : {}), + ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), name: file.name, mime: file.type || 'application/octet-stream', size: file.size, - remaining: resource.maxDownloads || null, preview: previewKindOf(file.type, file.name), gated: true, + remaining: plan.gated + ? (options.directPreview ? resource.maxDownloads ?? 10 : options.maxDownloads) || null + : null, + preview: previewKindOf(file.type, file.name), gated: plan.gated, }; resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item]; await saveResource(resource); @@ -69,6 +86,7 @@ export async function sendFiles(resourceId: string, files: File[]): Promise(); + await queueFileCatalog(resource, session, item); let uploadedBytes = 0; const uploadStartedAt = performance.now(); const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => { @@ -85,7 +103,7 @@ export async function sendFiles(resourceId: string, files: File[]): Promise entry.id === item?.id ? item! : entry); await saveResource(resource); publishWorkspace(resourceId, { t: 'item', item }); - if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开频道并连接网络后,可以继续发送已加密内容。' }); + if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开空间并连接网络后,可以继续发送已加密内容。' }); } catch (error) { if (item) { item = { ...item, state: { kind: 'incomplete' } }; @@ -101,13 +119,14 @@ export async function sendFiles(resourceId: string, files: File[]): Promise { const { session, resource } = await getSession(resourceId); - const plan = session.encryptJson({ text, updatedBy: deviceIdentity().name, updatedAt: Date.now() }, { snapshot: true, dev: deviceIdentity().name }); + 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); await flushOutbox(resource, session); } -export async function editChannelText(resourceId: string, itemId: string, text: string): Promise { - const created = await sendText(resourceId, text); +export async function editChannelText(resourceId: string, itemId: string, text: string, threadId?: string): Promise { + const created = await sendText(resourceId, text, threadId); 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 new file mode 100644 index 0000000..6d17043 --- /dev/null +++ b/web/src/core-bridge/worker/channelThreads.ts @@ -0,0 +1,172 @@ +import type { ChannelSession, ReceivedItem } 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 { getSession } from './sessions'; +import { deviceIdentity } from './presentation'; +import { flushOutbox, persistPlan } from './transfer'; + +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 } : {}) }); + 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 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 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); +} + +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 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 renameFolder(resourceId: string, id: string, input: string, threadId: string): Promise { + await sendControl(resourceId, { op: 'folder.rename', id, name: cleanName(input) }, threadId); +} + +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); +} + +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: '空间结构记录格式无效,已拒绝应用。' }; + } +} diff --git a/web/src/core-bridge/worker/channelTransport.ts b/web/src/core-bridge/worker/channelTransport.ts index 42f8197..de91477 100644 --- a/web/src/core-bridge/worker/channelTransport.ts +++ b/web/src/core-bridge/worker/channelTransport.ts @@ -6,6 +6,8 @@ import { channelMetadata, processChannelBytes, syncChannel } from './channelEven import { flushOutbox } from './transfer'; import { socketStream, sseStream, delay, type StreamConsumer } from './transports'; import { errorMessage } from '../errors'; +import { saveResource } from '../database'; +import { deviceIdentity } from './presentation'; export async function connectChannel(id: string): Promise { active.get(id)?.abort(); @@ -17,6 +19,15 @@ export async function connectChannel(id: string): Promise { const { session, resource } = await getSession(id); if (signal.aborted) return; if (resource.kind !== 'channel') throw new Error('资源类型不匹配'); + // Device sessions can be revoked independently of account membership; rejoin idempotently. + await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal }); + if (resource.needsJoin) { + if (session.can('owner')) await channelMetadata(resource, session); + const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name); + await apiFetch(`/channels/${id}/seats/me`, { method: 'PUT', headers: session.authHeaders('read'), body: label, signal }); + resource.needsJoin = false; + await saveResource(resource); + } const stream: StreamConsumer = { cursor: 0, async record(bytes) { stream.cursor = Math.max(stream.cursor, await processChannelBytes(resource, session, bytes)); }, diff --git a/web/src/core-bridge/worker/downloadStorage.test.ts b/web/src/core-bridge/worker/downloadStorage.test.ts new file mode 100644 index 0000000..86a19f6 --- /dev/null +++ b/web/src/core-bridge/worker/downloadStorage.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import { storeVerifiedDownload } from './downloadStorage'; + +function content(chunks: number[][]) { return new ReadableStream({ start(c) { for (const chunk of chunks) c.enqueue(new Uint8Array(chunk)); c.close(); } }); } +describe('verified download delivery', () => { + it('writes each chunk separately and closes only after verified FINAL', async () => { + const write = vi.fn(async () => {}); const close = vi.fn(async () => {}); + const temporary = { write, close, abort: vi.fn(async () => {}), remove: vi.fn(async () => {}), file: vi.fn(async () => new Blob([new Uint8Array([1, 2, 3])])) }; + const download = await storeVerifiedDownload({ content: content([[1], [2, 3]]), result: Promise.resolve({ complete: true, nextIndex: 2, meta: { kind: 'file', size: 3, ts: 0 } }) }, 3, async () => temporary); + expect(download.blob.size).toBe(3); expect(write).toHaveBeenCalledTimes(2); expect(close).toHaveBeenCalledOnce(); expect(temporary.remove).not.toHaveBeenCalled(); + }); + it.each([false, true])('deletes incomplete or size-mismatched temporary plaintext (FINAL=%s)', async (complete) => { + const temporary = { write: vi.fn(async () => {}), close: vi.fn(async () => {}), abort: vi.fn(async () => {}), remove: vi.fn(async () => {}), file: vi.fn(async () => new Blob()) }; + await expect(storeVerifiedDownload({ content: content([[1, 2]]), result: Promise.resolve({ complete, nextIndex: 1, meta: { kind: 'file', size: 3, ts: 0 } }) }, undefined, async () => temporary)).rejects.toThrow(); + expect(temporary.abort).toHaveBeenCalledOnce(); expect(temporary.remove).toHaveBeenCalledOnce(); expect(temporary.file).not.toHaveBeenCalled(); + }); + it('never delivers content after an authentication failure', async () => { + const error = new Error('authentication failed'); + await expect(storeVerifiedDownload({ content: new ReadableStream({ start(c) { c.error(error); } }), result: Promise.reject(error) }, undefined, async () => undefined)).rejects.toThrow('authentication failed'); + }); +}); diff --git a/web/src/core-bridge/worker/downloadStorage.ts b/web/src/core-bridge/worker/downloadStorage.ts new file mode 100644 index 0000000..8389e4f --- /dev/null +++ b/web/src/core-bridge/worker/downloadStorage.ts @@ -0,0 +1,77 @@ +import { IntegrityError, type ItemDecodeResult } from '@webdrop/core'; + +interface TemporaryFile { + write(bytes: Uint8Array): Promise; + close(): Promise; + abort(): Promise; + file(): Promise; + remove(): Promise; +} +const FALLBACK_LIMIT = 64 * 1024 * 1024; +let directory: Promise<{ root: FileSystemDirectoryHandle; dir: FileSystemDirectoryHandle; name: string }> | undefined; + +async function temporaryFile(): Promise { + if (!navigator.storage?.getDirectory) return undefined; + directory ??= (async () => { + const root = await navigator.storage.getDirectory(); + const name = `webdrop-download-${crypto.randomUUID()}`; + return { root, name, dir: await root.getDirectoryHandle(name, { create: true }) }; + })(); + const { dir } = await directory; + const name = crypto.randomUUID(); + const handle = await dir.getFileHandle(name, { create: true }); + let writer: FileSystemWritableFileStream; + try { writer = await handle.createWritable(); } + catch (error) { await dir.removeEntry(name); throw error; } + return { + write: (bytes) => writer.write(Uint8Array.from(bytes).buffer), + close: () => writer.close(), abort: () => writer.abort(), file: () => handle.getFile(), + remove: () => dir.removeEntry(name), + }; +} + +export async function clearTemporaryDownloads(): Promise { + const current = directory; directory = undefined; + if (current) { const { root, name } = await current; await root.removeEntry(name, { recursive: true }); } +} + +/** Deliver a file only after FINAL, contiguous frames and expected sizes are authenticated. */ +export async function storeVerifiedDownload( + decoded: { content: ReadableStream; result: Promise }, + expectedSize?: number, + createTemporary: () => Promise = temporaryFile, +): Promise<{ blob: Blob; meta: ItemDecodeResult['meta'] }> { + const reader = decoded.content.getReader(); + let temporary: TemporaryFile | undefined; + const parts: ArrayBuffer[] = []; + let size = 0; + let closed = false; + // A decoder may fail before its consumer starts; always observe both channels. + void decoded.result.catch(() => {}); + try { + temporary = await createTemporary(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (expectedSize !== undefined && size > expectedSize) throw new IntegrityError('bad_meta'); + if (temporary) await temporary.write(value); + else { + if (size > FALLBACK_LIMIT) throw new Error('此浏览器不支持大文件临时存储,请使用支持 OPFS 的浏览器下载超过 64 MB 的文件'); + parts.push(Uint8Array.from(value).buffer); + } + } + 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'); + if (temporary) { await temporary.close(); closed = true; } + return { blob: temporary ? await temporary.file() : new Blob(parts), meta: result.meta }; + } catch (error) { + await reader.cancel(error).catch(() => {}); + if (temporary) { + if (!closed) await temporary.abort().catch(() => {}); + await temporary.remove().catch(() => {}); + } + throw error; + } finally { reader.releaseLock(); } +} diff --git a/web/src/core-bridge/worker/fileCatalog.ts b/web/src/core-bridge/worker/fileCatalog.ts new file mode 100644 index 0000000..527dab5 --- /dev/null +++ b/web/src/core-bridge/worker/fileCatalog.ts @@ -0,0 +1,15 @@ +import type { FileItem } from '../types'; +import { IntegrityError, type ItemMeta } from '@webdrop/core'; + +export const fileCatalogs = new Map(); +export const visibleFiles = new Set(); +export function clearFileCatalog(resourceId?: string): void { + if (!resourceId) { fileCatalogs.clear(); visibleFiles.clear(); return; } + for (const key of fileCatalogs.keys()) if (key.startsWith(`${resourceId}:`)) fileCatalogs.delete(key); + for (const key of visibleFiles) if (key.startsWith(`${resourceId}:`)) visibleFiles.delete(key); +} +export function verifyFileCatalog(catalog: FileItem | undefined, meta: ItemMeta | undefined, size: number): void { + if (!catalog) return; + if (!meta || meta.kind !== 'file' || meta.name !== catalog.name || (meta.mime ?? 'application/octet-stream') !== catalog.mime + || size !== catalog.size || meta.threadId !== catalog.threadId || meta.folderId !== catalog.folderId) throw new IntegrityError('bad_meta'); +} diff --git a/web/src/core-bridge/worker/groupControl.test.ts b/web/src/core-bridge/worker/groupControl.test.ts new file mode 100644 index 0000000..a998a22 --- /dev/null +++ b/web/src/core-bridge/worker/groupControl.test.ts @@ -0,0 +1,42 @@ +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.test.ts b/web/src/core-bridge/worker/http.test.ts index 96f350b..1b56786 100644 --- a/web/src/core-bridge/worker/http.test.ts +++ b/web/src/core-bridge/worker/http.test.ts @@ -8,7 +8,7 @@ describe('API contract preflight', () => { }); it('blocks API requests when the server has a newer breaking major', async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '2.0.0' }), { status: 200 })); + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '3.0.0' }), { status: 200 })); vi.stubGlobal('fetch', fetchMock); setApiBase('http://api.test/api/v1'); @@ -19,7 +19,7 @@ describe('API contract preflight', () => { it('checks the contract once and then sends the API request', async () => { const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '1.2.0' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '2.2.0' }), { status: 200 })) .mockResolvedValueOnce(new Response('{}', { status: 200 })) .mockResolvedValueOnce(new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); diff --git a/web/src/core-bridge/worker/http.ts b/web/src/core-bridge/worker/http.ts index ee8cdfd..fdada35 100644 --- a/web/src/core-bridge/worker/http.ts +++ b/web/src/core-bridge/worker/http.ts @@ -1,4 +1,5 @@ import { supportsApiContract } from '../contract'; +import { accountToken } from './accountSession'; export type ApiError = Error & { status?: number; code?: string }; let apiBase = '/api/v1'; @@ -38,8 +39,12 @@ function ensureApiContract(): Promise { export async function apiFetch(path: string, init: RequestInit = {}): Promise { try { if (path !== '/info') await ensureApiContract(); + const headers = new Headers(init.headers); + const token = accountToken(); + if (token && !headers.has('X-WD-Account')) headers.set('X-WD-Account', token); const response = await fetch(apiPath(path), { ...init, + headers, cache: 'no-store', credentials: 'omit', referrerPolicy: 'no-referrer', @@ -73,6 +78,8 @@ export async function apiUpload( xhr.withCredentials = false; xhr.responseType = 'text'; xhr.setRequestHeader('Cache-Control', 'no-store'); + const token = accountToken(); + if (token) xhr.setRequestHeader('X-WD-Account', token); for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value); xhr.upload.onprogress = (event) => { if (event.lengthComputable) onProgress(event.loaded, event.total); diff --git a/web/src/core-bridge/worker/presentation.ts b/web/src/core-bridge/worker/presentation.ts index e90bd31..299f6dd 100644 --- a/web/src/core-bridge/worker/presentation.ts +++ b/web/src/core-bridge/worker/presentation.ts @@ -2,13 +2,77 @@ import type { ItemEvent } from '@webdrop/core'; import type { Device, FileItem, ShareEvent, WorkspaceEvent } from '../types'; import { previewKindOf } from '@/lib/fileKind'; import { publish } from './port'; -export function deviceIdentity(): { name: string; kind: Device['kind'] } { - const mobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent); - return { name: mobile ? '本机 · 移动设备' : '本机 · 浏览器', kind: mobile ? 'phone' : 'desktop' }; +interface ClientHints { + mobile?: boolean; + model?: string; + platform?: string; + platformVersion?: string; + getHighEntropyValues?: (hints: string[]) => Promise; +} + +let clientHintsPromise: Promise | undefined; + +async function readClientHints(): Promise { + const hints = (navigator as Navigator & { userAgentData?: ClientHints }).userAgentData; + if (!hints?.getHighEntropyValues) return hints ?? {}; + clientHintsPromise ??= hints.getHighEntropyValues(['model', 'platform', 'platformVersion']).catch(() => hints); + return clientHintsPromise; +} + +function clean(value: string | undefined): string | undefined { + const result = value?.replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 48); + return result || undefined; +} + +function version(value: string | undefined): string | undefined { + return value?.split('.').slice(0, 2).join('.') || undefined; +} + +export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: Device['kind'] }> { + const ua = navigator.userAgent; + const hints = await readClientHints(); + const android = /Android/i.test(ua) || hints.platform === 'Android'; + const iphone = /iPhone|iPod/i.test(ua); + const ipad = /iPad/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + const tablet = ipad || (android && (hints.mobile === false || (!hints.mobile && !/Mobile/i.test(ua)))); + const mobile = !tablet && (android || iphone || /Mobile/i.test(ua)); + const kind: Device['kind'] = tablet ? 'tablet' : mobile ? 'phone' : 'desktop'; + const iosVersion = ua.match(/(?:iPhone )?OS ([\d_]+)/i)?.[1]?.replaceAll('_', '.'); + const windowsVersion = version(hints.platformVersion); + const macVersion = ua.match(/Mac OS X ([\d_]+)/i)?.[1]?.replaceAll('_', '.'); + const androidVersion = ua.match(/Android ([\d.]+)/i)?.[1]; + const platformVersion = version(hints.platformVersion); + let identity: string; + if (clean(hints.model)) { + identity = `${clean(hints.model)} (${clean(hints.platform) ?? 'Android'}${platformVersion ? ` ${platformVersion}` : ''})`; + } else if (iphone) { + identity = `iPhone${iosVersion ? ` (iOS ${iosVersion})` : ''}`; + } else if (ipad) { + identity = `iPad${iosVersion ? ` (iPadOS ${iosVersion})` : ''}`; + } else if (/Windows/i.test(ua) || hints.platform === 'Windows') { + identity = `Windows${windowsVersion ? ` ${Number(windowsVersion.split('.')[0]) >= 13 ? '11' : '10'}` : ''}`; + } else if (android) { + identity = `Android${platformVersion ?? androidVersion ? ` ${platformVersion ?? androidVersion}` : ''}`; + } else if (/Macintosh|Mac OS X/i.test(ua) || hints.platform === 'macOS') { + identity = `Mac${platformVersion ?? macVersion ? ` (macOS ${platformVersion ?? macVersion})` : ''}`; + } else if (/CrOS/i.test(ua) || hints.platform === 'Chrome OS') { + identity = 'ChromeOS'; + } else if (/Linux/i.test(ua) || hints.platform === 'Linux') { + identity = 'Linux'; + } else { + identity = '未知设备'; + } + const suffix = Array.from(endpointSecret.subarray(0, 4), (b) => b.toString(16).padStart(2, '0')).join('').toUpperCase(); + return { name: `${identity} · ${suffix}`, kind }; } export function deviceInfo(id: string, joinedAt: number, label: string | undefined, selfId: string): Device { - return { id, name: label ?? '未知设备', kind: 'desktop', online: id === selfId, self: id === selfId, lastSeen: joinedAt }; + const name = label ?? '未知设备'; + const kind: Device['kind'] = /iPhone|iPod/i.test(name) ? 'phone' + : /iPad|Galaxy Tab|Pixel Tablet|Android.*Tablet/i.test(name) ? 'tablet' + : /Android|Pixel|Galaxy|OnePlus|Xiaomi|Redmi|OPPO|vivo|Huawei/i.test(name) ? 'phone' + : 'desktop'; + return { id, name, kind, self: id === selfId, joinedAt }; } export function showRejected(resourceId: string, event: Extract, share = false): void { if (share) publish(resourceId, { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' }); diff --git a/web/src/core-bridge/worker/senderSnapshot.ts b/web/src/core-bridge/worker/senderSnapshot.ts new file mode 100644 index 0000000..48a681c --- /dev/null +++ b/web/src/core-bridge/worker/senderSnapshot.ts @@ -0,0 +1,29 @@ +import type { SenderInfo } from '../types'; +import { accountDisplay } from './accountSession'; + +let lastAvatar: string | undefined; +let thumbnail: Promise | undefined; +async function compactAvatar(value: string): Promise { + if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') return value.length <= 8000 ? value : undefined; + try { + const mime = value.slice(5, value.indexOf(';')); const raw = atob(value.slice(value.indexOf(',') + 1)); + const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0)); + const image = await createImageBitmap(new Blob([bytes], { type: mime })); + try { + const canvas = new OffscreenCanvas(64, 64); const context = canvas.getContext('2d'); if (!context) return undefined; + const size = Math.min(image.width, image.height); + context.drawImage(image, (image.width - size) / 2, (image.height - size) / 2, size, size, 0, 0, 64, 64); + const encoded = await canvas.convertToBlob({ type: 'image/webp', quality: 0.7 }); + const data = new Uint8Array(await encoded.arrayBuffer()); + const result = `data:${encoded.type};base64,${btoa(String.fromCharCode(...data))}`; + return result.length <= 8000 ? result : undefined; + } finally { image.close(); } + } catch { return undefined; } +} +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; + return { username: sender.username, displayName: sender.displayName, ...(avatar ? { avatar } : {}) }; +} diff --git a/web/src/core-bridge/worker/sessions.ts b/web/src/core-bridge/worker/sessions.ts index 9572abb..ee4f469 100644 --- a/web/src/core-bridge/worker/sessions.ts +++ b/web/src/core-bridge/worker/sessions.ts @@ -1,12 +1,21 @@ import { ChannelSession } from '@webdrop/core'; -import { getResource, type SavedResource } from '../database'; +import { getResource, currentResourceScope, type SavedResource } from '../database'; import { fail } from './http'; import { toCoreLink } from './links'; +import { clearFileCatalog } from './fileCatalog'; +import { clearTemporaryDownloads } from './downloadStorage'; 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 { + clearFileCatalog(); + void clearTemporaryDownloads().catch(() => {}); + for (const controller of active.values()) controller.abort(); + for (const session of sessions.values()) session.dispose(); + active.clear(); sessions.clear(); readers.clear(); blobs.clear(); opening.clear(); +} export async function getSession(id: string): Promise<{ session: ChannelSession; resource: SavedResource }> { const resource = await getResource(id); if (!resource) throw fail(404, 'not_found'); @@ -19,6 +28,7 @@ export async function getSession(id: string): Promise<{ session: ChannelSession; opening.set(id, task); } try { session = await task; } finally { opening.delete(id); } + if (resource.localScope !== currentResourceScope()) { session.dispose(); throw new Error('账号已切换'); } sessions.set(id, session); } return { session, resource }; diff --git a/web/src/core-bridge/worker/shareDraft.ts b/web/src/core-bridge/worker/shareDraft.ts index a1a9494..9f5379b 100644 --- a/web/src/core-bridge/worker/shareDraft.ts +++ b/web/src/core-bridge/worker/shareDraft.ts @@ -37,7 +37,7 @@ export async function addShareFiles(id: string, files: File[]): Promise { if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存'); await invalidateManifest(session, resource); const downloadLimit = resource.maxDownloads ?? 0; - const maxFrameData = deviceIdentity().kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA; + const maxFrameData = (await deviceIdentity(session.endpointSecret)).kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA; for (const file of files) { const ts = Date.now(); let item: FileItem | undefined; diff --git a/web/src/core-bridge/worker/shareRead.ts b/web/src/core-bridge/worker/shareRead.ts index a7eabb0..7bc9e7e 100644 --- a/web/src/core-bridge/worker/shareRead.ts +++ b/web/src/core-bridge/worker/shareRead.ts @@ -32,8 +32,10 @@ export async function connectShare(id: string): Promise { publishShare(id, { t: 'share', share }); if (raw.state === 'draft' && resource.mine) { - const resumed = await flushOutbox(resource, session); - if (resumed) resource.draftItems = resource.draftItems?.map((item) => item.state.kind === 'incomplete' ? { ...item, state: { kind: 'complete' } } : item); + await flushOutbox(resource, session); + const current = await readShareInfo(session, resource); + resource.draftItems = resource.draftItems?.map((item) => item.kind === 'file' + ? { ...item, state: { kind: current.items.some((row) => row.item_id === item.id && row.complete) ? 'complete' : 'incomplete' } } : item); await saveResource(resource); if (controller.signal.aborted) return; for (const item of resource.draftItems ?? []) publishShare(id, { t: 'item', item }); @@ -93,6 +95,11 @@ export async function getShareBlob(id: string, itemId: string): Promise { blobs.set(`${id}:${itemId}`, draftFile); return draftFile; } + const entry = resource.manifestEntries?.find((row) => row.t === 'file' && row.id === itemId); + if (!entry || entry.t !== 'file') throw fail(404, 'not_found'); + const result = await downloadDecrypted(session, resource, itemId, entry.size); + blobs.set(`${id}:${itemId}`, result.blob); + return result.blob; } const raw = await readJson(`/shares/${id}`, session.authHeaders('read')); const manifest = await fetchManifest(session, id, raw); diff --git a/web/src/core-bridge/worker/shareResources.ts b/web/src/core-bridge/worker/shareResources.ts index 8921ee3..513d08b 100644 --- a/web/src/core-bridge/worker/shareResources.ts +++ b/web/src/core-bridge/worker/shareResources.ts @@ -6,11 +6,13 @@ import { active, blobs, getSession, sessions } from './sessions'; import { apiFetch, fail, readJson } from './http'; import { apiShareSettings, toShareInfo, type ShareWire } from './wire'; import { makeLink, toShareLink } from './links'; +import { requireAccount } from './accountSession'; export async function createShare(settings: ShareSettings, captchaToken?: string): Promise { + requireAccount(); const endpointSecret = crypto.getRandomValues(new Uint8Array(32)); const { session, request } = await ChannelSession.create({ mode: 'enc', endpointSecret }); try { diff --git a/web/src/core-bridge/worker/transfer.ts b/web/src/core-bridge/worker/transfer.ts index 1a7385e..e9d759c 100644 --- a/web/src/core-bridge/worker/transfer.ts +++ b/web/src/core-bridge/worker/transfer.ts @@ -1,7 +1,8 @@ import { fromBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core'; -import { dequeueFrame, queueFrame, nextQueuedFrame, type SavedResource } from '../database'; +import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, type SavedResource } from '../database'; import { apiFetch, apiUpload, type ApiError } from './http'; import { serial } from './serial'; +import { storeVerifiedDownload } from './downloadStorage'; // Frame plaintext includes a 4-byte length prefix before padding to the 256 KiB tier. export const MOBILE_UPLOAD_FRAME_DATA_BYTES = 256 * 1024 - 4; @@ -13,7 +14,7 @@ export async function persistPlan( plan: ItemPlan, onProgress?: (frames: number, bytes: number) => void, ): Promise<{ frames: number; bytes: number }> { - const createdAt = Date.now(); + const createdAt = Math.max(Date.now(), (await lastQueuedAt(resource.id)) + 1); let frames = 0; let bytes = 0; for await (const frame of plan.frames) { @@ -94,7 +95,7 @@ export async function downloadDecrypted( itemId: string, expectedSize?: number, ): Promise<{ blob: Blob; meta: ItemDecodeResult['meta'] }> { - const role = session.can('read') ? 'read' : 'owner'; + const role = resource.kind === 'share' && resource.state === 'draft' ? 'owner' : session.can('read') ? 'read' : 'owner'; const path = resource.kind === 'share' ? `/shares/${resource.id}/items/${itemId}/frames?from=0` : `/channels/${resource.id}/items/${itemId}/frames?from=0`; @@ -103,10 +104,5 @@ export async function downloadDecrypted( const expectedId = fromBase64Url(itemId, 16); if (!expectedId) throw new IntegrityError('bad_meta'); const decoded = session.decryptItemStream(response.body, { itemId: expectedId }); - const blobPromise = new Response(decoded.content).blob(); - const [result, blob] = await Promise.all([decoded.result, blobPromise]); - if (!result.complete) throw new Error('文件传输不完整'); - if (result.meta?.size !== undefined && result.meta.size !== blob.size) throw new IntegrityError('bad_meta'); - if (expectedSize !== undefined && expectedSize !== blob.size) throw new IntegrityError('bad_meta'); - return { blob, meta: result.meta }; + return storeVerifiedDownload(decoded, expectedSize); } diff --git a/web/src/core-bridge/worker/transports.ts b/web/src/core-bridge/worker/transports.ts index 98cae67..b970daa 100644 --- a/web/src/core-bridge/worker/transports.ts +++ b/web/src/core-bridge/worker/transports.ts @@ -1,5 +1,6 @@ import { fromBase64Url, toBase64Url, type ChannelSession } from '@webdrop/core'; import { apiFetch, apiPath } from './http'; +import { accountToken } from './accountSession'; export interface StreamConsumer { cursor: number; @@ -13,7 +14,8 @@ export function socketStream(id: string, session: ChannelSession, signal: AbortS const address = new URL(apiPath(`/channels/${id}/ws?from=${consumer.cursor}`), self.location.origin); address.protocol = address.protocol === 'https:' ? 'wss:' : 'ws:'; const token = session.authHeaders('read').Authorization?.slice(7); - const socket = new WebSocket(address, ['wd.v1', 'wd.role.read', ...(token ? [`wd.auth.${token}`] : []), `wd.ep.${toBase64Url(session.endpointSecret)}`]); + const identity = accountToken()?.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const socket = new WebSocket(address, ['wd.v1', 'wd.role.read', ...(token ? [`wd.auth.${token}`] : []), ...(identity ? [`wd.account.${identity}`] : []), `wd.ep.${toBase64Url(session.endpointSecret)}`]); socket.binaryType = 'arraybuffer'; return new Promise((resolve, reject) => { let chain = Promise.resolve(); diff --git a/web/src/core-bridge/worker/wire.ts b/web/src/core-bridge/worker/wire.ts index ef2ad85..6ba4f9c 100644 --- a/web/src/core-bridge/worker/wire.ts +++ b/web/src/core-bridge/worker/wire.ts @@ -1,9 +1,11 @@ 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: { seats: number; members_can_upload: boolean; delete_policy: 'owner_only' | 'own' | 'anyone'; ttl_seconds: number; max_items: number; file_max_downloads: number }; + 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; @@ -11,7 +13,7 @@ export interface ChannelWire { expires_at: number; } -export interface ShareWireItem { item_id: string; bytes: number; frames: number; max_downloads: number; downloads_left: number | null } +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'; @@ -25,10 +27,17 @@ export interface ShareWire { export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelInfo { 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, mode: raw.mode, role: saved.role, canUpload: saved.role === 'owner' || raw.settings.members_can_upload, seats: raw.settings.seats, + seatsUsed: raw.seats_used, + membersCanUpload: raw.settings.members_can_upload, + ttlSeconds: raw.settings.ttl_seconds, + maxDownloads: raw.settings.file_max_downloads, memberLink: saved.link, ownerLink: saved.ownerLink, createdAt: raw.created_at, diff --git a/web/src/features/channel/ActionBar.tsx b/web/src/features/channel/ActionBar.tsx index 3602f7e..1e58f89 100644 --- a/web/src/features/channel/ActionBar.tsx +++ b/web/src/features/channel/ActionBar.tsx @@ -7,7 +7,7 @@ import styles from './ActionBar.module.css'; interface Props { /** dock:手机底部动作栏;toolbar:电脑列表上方的工具条 */ layout: 'dock' | 'toolbar'; - /** 添加文件、文本:频道发到频道里,分享放进草稿 */ + /** 添加文件、文本:空间发到空间里,分享放进草稿 */ onFiles: (files: File[]) => void; onText: (text: string) => void; onCompose: () => void; diff --git a/web/src/features/channel/ChannelInvitations.tsx b/web/src/features/channel/ChannelInvitations.tsx new file mode 100644 index 0000000..d861990 --- /dev/null +++ b/web/src/features/channel/ChannelInvitations.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { useWorkspace } from '@/store/workspace'; +import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess'; +import type { AccountInvitation } from '@/core-bridge/account'; +import { Button } from '@/ui/Button'; +import styles from './ChannelSettingsSheet.module.css'; + +export function ChannelInvitations({ owner }: { owner: boolean }) { + const channelId = useWorkspace((s) => s.channel?.id); + const invite = useWorkspace((s) => s.inviteUser); + const list = useWorkspace((s) => s.listInvitations); + const revoke = useWorkspace((s) => s.revokeInvitation); + const [members, setMembers] = useState([]); + const [invitations, setInvitations] = useState([]); + const [username, setUsername] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + async function refresh() { + if (channelId) setMembers(await channelAccessApi.members(channelId)); + if (owner) setInvitations(await list()); + } + useEffect(() => { void refresh().catch(() => setError('成员清单读取失败')); }, [channelId, owner, list]); + async function submit(event: FormEvent) { + event.preventDefault(); setBusy(true); setError(''); + try { await invite(username); setUsername(''); await refresh(); } + catch (cause) { setError(cause instanceof Error ? cause.message : '邀请失败'); } + finally { setBusy(false); } + } + async function remove(username: string) { + setBusy(true); setError(''); + try { await revoke(username); await refresh(); } + catch (cause) { setError(cause instanceof Error ? cause.message : '移除失败'); } + finally { setBusy(false); } + } + return
空间成员 + {members.map((member) =>
+ {member.username} · {member.role === 'owner' ? '空间主' : '成员'} + {owner && member.role !== 'owner' ? : null} +
)} +

未登录访客以独立设备身份加入,可在设备面板移除。

+ {owner ? <>邀请用户
{ void submit(event); }}> + setUsername(event.target.value)} placeholder="已注册用户的用户名" required /> + +
+ {invitations.map((row) =>
{row.username} · 已邀请
)} +

移除会取消加入授权并断开该账号的设备。已读取的内容无法收回。

: null} + {error ?

{error}

: null} +
; +} diff --git a/web/src/features/channel/ChannelSettingsSheet.module.css b/web/src/features/channel/ChannelSettingsSheet.module.css new file mode 100644 index 0000000..4b25c41 --- /dev/null +++ b/web/src/features/channel/ChannelSettingsSheet.module.css @@ -0,0 +1,70 @@ +.member { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.member input { + min-width: 0; + flex: 1; + padding: var(--space-2); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); +} + +.form { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.field { + display: flex; + flex-direction: column; + gap: var(--space-2); + color: var(--text-dim); + font-size: 13px; +} + +.field input, +.field select { + width: 100%; + min-height: 42px; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-2); + color: var(--text); + font: inherit; +} + +.check { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 36px; +} + +.check input { + accent-color: var(--accent); +} + +.field input:disabled, +.field select:disabled { + opacity: 0.7; +} + +.note { + margin: 0; + color: var(--text-dim); + font-size: 12px; +} + +.error { + margin: 0; + color: var(--danger); + font-size: 13px; +} diff --git a/web/src/features/channel/ChannelSettingsSheet.tsx b/web/src/features/channel/ChannelSettingsSheet.tsx new file mode 100644 index 0000000..1d43952 --- /dev/null +++ b/web/src/features/channel/ChannelSettingsSheet.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types'; +import { Button } from '@/ui/Button'; +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]; + +interface Props { + channel: ChannelInfo; + onClose: () => void; + onSave: (patch: ChannelSettingsPatch) => Promise; +} + +export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) { + 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 [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); + + async function save() { + if (!owner || saving) return; + setSaving(true); + setFailed(false); + try { + await onSave({ name: name.trim(), seats, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests, + ...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) }); + onClose(); + } catch { + setFailed(true); + } finally { + setSaving(false); + } + } + + return ( + +
+ + + + + + + + + + + + + + {owner && channel.passwordRequired ? : null} + + {owner ? ( + <> + {failed ?

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

: null} + + + ) :

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

} +
+
+ ); +} diff --git a/web/src/features/channel/ChannelSwitcher.tsx b/web/src/features/channel/ChannelSwitcher.tsx new file mode 100644 index 0000000..2af53b7 --- /dev/null +++ b/web/src/features/channel/ChannelSwitcher.tsx @@ -0,0 +1,22 @@ +import { useChannels } from '@/store/channels'; +import { useAccount } from '@/store/account'; +import { channelPath, navigate } from '@/app/navigation'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +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
+ +
空间
+ +
; +} diff --git a/web/src/features/channel/ChatMessage.tsx b/web/src/features/channel/ChatMessage.tsx new file mode 100644 index 0000000..8d507dc --- /dev/null +++ b/web/src/features/channel/ChatMessage.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { useWorkspace } from '@/store/workspace'; +import { useAccount } from '@/store/account'; +import { useDownload } from '@/features/item/download'; +import { iconOf } from '@/features/item/summary'; +import { formatSize, formatTime, formatTransferProgress } from '@/lib/format'; +import { Icon } from '@/ui/Icon'; +import { Button } from '@/ui/Button'; +import styles from './ChatPane.module.css'; + +export function ChatMessage({ id, onOpenFile, onDelete }: { id: string; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) { + const item = useWorkspace((s) => s.byId[id]); + const owner = useWorkspace((s) => s.channel?.role === 'owner'); + const username = useAccount((s) => s.username); + const download = useDownload(); + const [downloading, setDownloading] = useState(false); + if (!item) return null; + const sender = item.author; + const name = sender?.displayName || sender?.username || item.deviceId || '未知用户'; + const self = sender?.username !== undefined && sender.username === username; + const ready = item.state.kind === 'complete'; + async function save() { + if (!item || downloading) return; + setDownloading(true); try { await download(item); } finally { setDownloading(false); } + } + return
+ {sender?.avatar ? {`${name} + : {Array.from(name)[0] ?? '?'}} +
+
{name} + {owner || self ?
+ {item.state.kind === 'tampered' ?

内容校验失败,已拒绝显示。

: item.kind === 'text' ?

{item.text}

+ :
+ +
+ +
+
} + {item.state.kind === 'receiving' ?
{formatTransferProgress(item.state)}
+ : item.state.kind === 'incomplete' ? 发送未完成,等待续传。 : null} +
+
; +} diff --git a/web/src/features/channel/ChatPane.module.css b/web/src/features/channel/ChatPane.module.css new file mode 100644 index 0000000..b24f775 --- /dev/null +++ b/web/src/features/channel/ChatPane.module.css @@ -0,0 +1,203 @@ +.pane { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + border-top: 1px solid var(--line); + background: var(--surface); +} + +.head { + display: flex; + align-items: center; + justify-content: space-between; + min-height: var(--bar-h); + padding: 0 var(--space-3); + border-bottom: 1px solid var(--line); + font-size: 12px; +} + +.head span, +.empty, +.message time { + color: var(--text-dim); + font-size: 11px; +} + +.messages { + flex: 1; + min-height: 0; + padding: var(--space-4); + overflow-y: auto; + overscroll-behavior: contain; +} + +.empty { + margin: var(--space-2) 0; +} + +.message { + display: flex; + align-items: flex-start; + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.mine { + flex-direction: row-reverse; +} + +.avatar { + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: var(--radius-avatar); + object-fit: cover; +} + +.avatarFallback { + display: grid; + place-items: center; + background: var(--surface-2); + color: var(--text-dim); + font-size: 14px; +} + +.bubble { + min-width: 0; + max-width: min(85%, 560px); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-2); +} + +.mine .bubble { + background: var(--accent-soft); +} + +.message header { + display: flex; + align-items: baseline; + gap: var(--space-2); +} + +.senderName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.message header time { + margin-inline-end: auto; +} + +.messageText { + margin: var(--space-1) 0 0; + overflow-wrap: anywhere; + white-space: pre-wrap; + font-size: 13px; +} + +.fileMessage { + width: 280px; + max-width: 100%; +} + +.fileCard { + display: flex; + align-items: center; + gap: var(--space-3); + width: 100%; + padding: var(--space-3) 0; + border: 0; + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; +} + +.fileIcon { + flex-shrink: 0; +} + +.fileInfo { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; +} + +.fileInfo strong { + overflow-wrap: anywhere; + font-size: 13px; +} + +.fileInfo > span, +.delivery { + color: var(--text-dim); + overflow-wrap: anywhere; + font-size: 11px; +} + +.fileActions { + display: flex; + gap: var(--space-2); + border-top: 1px solid var(--line); +} + +.delivery { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.delivery progress { + width: 100%; + height: var(--space-1); + accent-color: var(--busy); +} + +.integrity { + color: var(--danger); + font-size: 12px; +} + +.composer { + display: flex; + align-items: flex-end; + gap: var(--space-2); + padding: var(--space-2); + border-top: 1px solid var(--line); +} + +.composer textarea { + flex: 1; + min-width: 0; + min-height: 42px; + max-height: 112px; + padding: var(--space-2); + resize: vertical; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 13px; +} + +.error { + margin: 0; + padding: 0 var(--space-3) var(--space-2); + color: var(--danger); + font-size: 12px; +} + +@media (width <= 899px) { + .pane { + flex: 1; + min-height: 0; + } +} diff --git a/web/src/features/channel/ChatPane.tsx b/web/src/features/channel/ChatPane.tsx new file mode 100644 index 0000000..dd3ce94 --- /dev/null +++ b/web/src/features/channel/ChatPane.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; +import { Button } from '@/ui/Button'; +import { ChatMessage } from './ChatMessage'; +import styles from './ChatPane.module.css'; + +interface Props { threadName: string; ids: string[]; enabled: boolean; onSend: (text: string) => Promise; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void } + +export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) { + const [text, setText] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const messages = useRef(null); + const fileInput = useRef(null); + const chronological = ids.slice().reverse(); + + useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]); + + async function submit() { + const value = text.trim(); + if (!value || busy || !enabled) return; + setBusy(true); + setError(''); + try { await onSend(value); setText(''); } + catch (cause) { setError(cause instanceof Error ? cause.message : '发送失败'); } + finally { setBusy(false); } + } + + function keyDown(event: KeyboardEvent) { + if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(); } + } + + return ( +
+
{threadName}频道聊天
+
+ {chronological.length === 0 ?

在这里发送消息或文件,成员可以看到你的名称和头像。

: null} + {chronological.map((id) => )} +
+
{ event.preventDefault(); void submit(); }}> + {onFiles ? <> { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} /> +