feat(web): add spaces, grouped channels and file chat

This commit is contained in:
justme committed 2026-10-13 01:39:23 +08:00
1 parent c7d8ef15bc
commit f6cfb12716
111 files changed
+4371 -506

No files matched your search

+21
View File
@@ -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<AccountStatus>('registerAccount', { username, password, apiBase: API_BASE }),
login: (username: string, password: string) => callWorker<AccountStatus>('loginAccount', { username, password, apiBase: API_BASE }),
status: () => callWorker<AccountStatus>('accountStatus', { apiBase: API_BASE }),
logout: () => callWorker<void>('logoutAccount', { apiBase: API_BASE }),
sync: () => callWorker<AccountStatus>('syncAccount', { apiBase: API_BASE }),
invitations: () => callWorker<AccountInvitation[]>('accountInvitations', { apiBase: API_BASE }),
acceptInvitation: (id: string) => callWorker<void>('acceptInvitation', { id, apiBase: API_BASE }),
updatePersonal: (displayName: string, avatar?: string) => callWorker<AccountStatus>('updatePersonal', { displayName, avatar, apiBase: API_BASE }),
devices: () => callWorker<AccountDevice[]>('accountDevices', { apiBase: API_BASE }),
renameDevice: (id: string, name: string) => callWorker<void>('renameAccountDevice', { id, name, apiBase: API_BASE }),
revokeDevice: (id: string) => callWorker<AccountStatus>('revokeAccountDevice', { id, apiBase: API_BASE }),
};
+27 -11
View File
@@ -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<ChannelInfo>;
/** 通过链接加入:先 GET 频道信息预览,再 POST seats */
joinChannel(link: ParsedChannelLink): Promise<ChannelInfo>;
/** 使用现有空间 settings 接口更新空间设置。 */
updateChannelSettings(patch: ChannelSettingsPatch): Promise<void>;
renameDevice(endpointHash: string, name: string): Promise<void>;
releaseDevice(endpointHash: string): Promise<void>;
/** 通过链接加入:先 GET 空间信息预览,再 POST seats */
joinChannel(link: ParsedChannelLink, password?: string): Promise<ChannelInfo>;
inviteUser(username: string): Promise<void>;
listInvitations(): Promise<AccountInvitation[]>;
revokeInvitation(username: string): Promise<void>;
listSaved(): Promise<ChannelInfo[]>;
/** 仅从本机删除保存的链接和终端密钥,不会删服务端频道 */
/** 仅从本机删除保存的链接和终端密钥,不会删服务端空间 */
forget(id: string): Promise<void>;
/** 仅假实现提供:载入演示用的频道 */
/** 仅假实现提供:载入演示用的空间 */
loadDemo?(): ChannelInfo[];
/** 订阅某个频道的事件流(对应追加日志的推送),返回取消订阅函数 */
/** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
sendText(text: string): Promise<void>;
sendFiles(files: File[]): Promise<void>;
sendText(text: string, threadId?: string): Promise<void>;
sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void>;
createThread(name: string, groupId?: string): Promise<string>;
createGroup(name: string): Promise<string>;
renameGroup(id: string, name: string): Promise<void>;
groupThread(id: string, groupId?: string): Promise<void>;
renameThread(threadId: string, name: string): Promise<void>;
createFolder(threadId: string, name: string, parentId?: string): Promise<void>;
renameFolder(folderId: string, threadId: string, name: string): Promise<void>;
moveItems(itemIds: string[], threadId: string, folderId?: string): Promise<void>;
/** 修改 = 追加一个替代旧条目的新版本,后写者胜 */
editText(id: string, text: string): Promise<void>;
editText(id: string, text: string, threadId?: string): Promise<void>;
resolveConflict(id: string, choice: 'mine' | 'theirs'): Promise<void>;
remove(id: string): Promise<void>;
setSnapshot(text: string): Promise<void>;
+9
View File
@@ -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<ChannelMember[]>('channelMembers', { id, apiBase }),
preview: (link: ParsedChannelLink) => callWorker<ChannelInfo>('previewChannel', { link, apiBase }),
};
+87 -14
View File
@@ -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<IDBPDatabase<WebDropDb>> | undefined;
const databases = new Map<string, Promise<IDBPDatabase<WebDropDb>>>();
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<IDBPDatabase<WebDropDb>> {
dbPromise ??= openDB<WebDropDb>('webdrop-local', 2, {
upgrade(database, oldVersion) {
let dbPromise = databases.get(resourceScope);
dbPromise ??= openDB<WebDropDb>(`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<void> {
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<SavedResource[]> {
return (await db()).getAll('resources');
}
export async function listResourceDeletes(): Promise<Array<{ id: string; modifiedAt: number }>> {
return (await db()).getAll('resourceDeletes');
}
export async function importResourceDeletes(deletes: Array<{ id: string; modifiedAt: number }>): Promise<void> {
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<void> {
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<SavedResource | undefined> {
@@ -86,13 +150,15 @@ export async function listResources(kind: SavedResource['kind']): Promise<SavedR
export async function removeResource(id: string): Promise<void> {
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<void> {
@@ -130,10 +196,17 @@ export async function dequeueFrame(key: string): Promise<void> {
}
export async function nextQueuedFrame(resourceId: string): Promise<QueuedFrame | undefined> {
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<number> {
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<void> {
const database = await db();
const tx = database.transaction('outbox', 'readwrite');
+6 -2
View File
@@ -1,13 +1,17 @@
const messages: Record<string, string> = {
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 {
+74 -10
View File
@@ -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<void>((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<string, Runtime>();
const timers = new Set<number>();
@@ -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;
+4 -3
View File
@@ -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}`,
+20 -5
View File
@@ -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<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
joinChannel: (link: ParsedChannelLink) => callWorker<ChannelInfo>('joinChannel', { link, apiBase: API_BASE }),
updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker<void>('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }),
renameDevice: (endpointHash, name) => callWorker<void>('renameDevice', { id: currentChannelId(), endpointHash, name, apiBase: API_BASE }),
releaseDevice: (endpointHash) => callWorker<void>('releaseDevice', { id: currentChannelId(), endpointHash, apiBase: API_BASE }),
joinChannel: (link: ParsedChannelLink, password?: string) => callWorker<ChannelInfo>('joinChannel', { link, password, apiBase: API_BASE }),
inviteUser: (username) => callWorker<void>('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }),
listInvitations: () => callWorker<AccountInvitation[]>('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }),
revokeInvitation: (username) => callWorker<void>('revokeChannelInvitation', { id: currentChannelId(), username, apiBase: API_BASE }),
listSaved: () => callWorker<ChannelInfo[]>('listChannels', { apiBase: API_BASE }),
forget: (id) => callWorker<void>('forgetChannel', { id, apiBase: API_BASE }),
@@ -30,9 +37,17 @@ export const realApi: WorkspaceApi = {
};
},
sendText: (text) => callWorker<void>('sendText', { id: currentChannelId(), text, apiBase: API_BASE }),
sendFiles: (files) => callWorker<void>('sendFiles', { id: currentChannelId(), files, apiBase: API_BASE }),
editText: (id, text) => callWorker<void>('editChannelText', { id: currentChannelId(), itemId: id, text, apiBase: API_BASE }),
sendText: (text, threadId) => callWorker<void>('sendText', { id: currentChannelId(), text, threadId, apiBase: API_BASE }),
sendFiles: (files, options, threadId, folderId) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, apiBase: API_BASE }),
createThread: (name, groupId) => callWorker<string>('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }),
createGroup: (name) => callWorker<string>('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }),
renameGroup: (groupId, name) => callWorker<void>('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }),
groupThread: (threadId, groupId) => callWorker<void>('groupThread', { id: currentChannelId(), threadId, groupId, apiBase: API_BASE }),
renameThread: (threadId, name) => callWorker<void>('renameThread', { id: currentChannelId(), threadId, name, apiBase: API_BASE }),
createFolder: (threadId, name, parentId) => callWorker<void>('createFolder', { id: currentChannelId(), threadId, name, parentId, apiBase: API_BASE }),
renameFolder: (folderId, threadId, name) => callWorker<void>('renameFolder', { id: currentChannelId(), folderId, threadId, name, apiBase: API_BASE }),
moveItems: (itemIds, threadId, folderId) => callWorker<void>('moveItems', { id: currentChannelId(), itemIds, threadId, folderId, apiBase: API_BASE }),
editText: (id, text, threadId) => callWorker<void>('editChannelText', { id: currentChannelId(), itemId: id, text, threadId, apiBase: API_BASE }),
resolveConflict: (id, choice) => callWorker<void>('resolveTextConflict', { id: currentChannelId(), itemId: id, choice, apiBase: API_BASE }),
remove: (id) => callWorker<void>('removeChannelItem', { id: currentChannelId(), itemId: id, apiBase: API_BASE }),
setSnapshot: (text) => callWorker<void>('updateSnapshot', { id: currentChannelId(), text, apiBase: API_BASE }),
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ParsedShareLink } from '@/lib/shareLink';
import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types';
/**
* 便捷分享的后端边界。和频道分开:分享没有设备、便签、冲突与历史,只有
* 便捷分享的后端边界。和空间分开:分享没有设备、便签、冲突与历史,只有
* 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。
*/
export interface ShareApi {
+58 -5
View File
@@ -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 =
+36 -9
View File
@@ -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<unknown> {
const payload = input as Record<string, unknown>;
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<typeof createChannel>[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined);
case 'updateChannelSettings': return updateChannelSettings(id, payload.patch as Parameters<typeof updateChannelSettings>[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<typeof joinChannel>[0]);
case 'previewChannel': return previewChannel(payload.link as Parameters<typeof previewChannel>[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<typeof sendFiles>[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<unknown> {
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({
+214
View File
@@ -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<void>; error?: string;
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
}
export interface Invitation { channel_id: string; username: string; payload: string; created_at: number }
let account: AccountSession | undefined;
let restoration: Promise<void> | 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<AccountSession, 'token'>): Record<string, string> { 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<void> {
await importResources(profile.resources.map((row) => ({ ...row, endpointSecret: new Uint8Array(32) } as SavedResource)));
await importResourceDeletes(profile.deletes);
}
async function syncVault(): Promise<void> {
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<VaultBody>);
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<VaultBody>);
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<AccountSession> {
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<void> {
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<VaultBody>);
} 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<Awaited<ReturnType<typeof accountStatus>>> {
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<Awaited<ReturnType<typeof accountStatus>>> {
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<VaultBody>);
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<void> {
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<void> { await syncVault(); }
export async function updatePersonal(displayName: string, avatar?: string): Promise<void> {
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<unknown> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); }
export async function renameAccountDevice(id: string, name: string): Promise<void> {
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<void> {
requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'DELETE' });
if ((await localAccountDevice()).id === id) await logoutAccount();
}
export async function listAccountInvitations(): Promise<Invitation[]> {
requireAccount(); return apiFetch('/accounts/invitations').then((r) => r.json() as Promise<Invitation[]>);
}
export async function acceptInvitation(channelId: string): Promise<void> {
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<void> {
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<Invitation[]> {
const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise<Invitation[]>);
}
export async function revokeChannelInvitation(channelId: string, input: string): Promise<void> {
const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders('owner') });
}
@@ -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<void> {
const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise<AccountChannel[]>);
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<ChannelWire>(`/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(); }
}
}
@@ -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('校验失败');
});
});
@@ -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<string> {
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<unknown> {
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<InvitationKeyPair> {
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<CryptoKey> {
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<string> {
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<string> {
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;
}
@@ -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<LocalIdentity>('webdrop-account-local-2', 1, { upgrade(db) {
db.createObjectStore('sessions'); db.createObjectStore('device');
} });
const endpoint = () => apiPath('/');
export async function rememberedAccount(): Promise<RememberedAccount | undefined> { return (await database()).get('sessions', endpoint()); }
export async function rememberAccount(value: RememberedAccount): Promise<void> { await (await database()).put('sessions', value, endpoint()); }
export async function forgetAccount(): Promise<void> { 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<void> {
const device = await localAccountDevice(); await (await database()).put('device', { ...device, name }, endpoint());
}
@@ -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;
}
@@ -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();
});
});
@@ -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<SavedResource, 'endpointSecret' | 'deviceLabel' | 'localItems' | 'localScope' | 'needsJoin' | 'pendingManifestId'>;
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()] };
}
+32 -7
View File
@@ -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<void> {
@@ -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<void> {
const info = await readJson<ChannelWire>(`/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<Array<{ endpoint_hash: string; joined_at: number; label?: string }>>(
`/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<number> {
publishWorkspace(resource.id, { t: 'reset' });
clearFileCatalog(resource.id);
readers.set(resource.id, session.createItemReader());
let from = 0;
for (;;) {
@@ -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();
});
});
@@ -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<string> {
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<ParsedChannelLink> {
const plain = await decryptInvitation(payload, recipient, channelId, privateKey);
let parsed: ReturnType<typeof parseChannelLink>;
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;
}
+25 -7
View File
@@ -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<void> {
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<ReturnType<typeof downloadDecrypted>>;
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<void> {
+103 -9
View File
@@ -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<ChannelInfo> {
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<ChannelWire>);
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<ChannelInfo> {
export async function joinChannel(input: { link: ParsedChannelLink; password?: string }): Promise<ChannelInfo> {
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<C
try {
const info = await readJson<ChannelWire>(`/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<C
}
}
export async function previewChannel(link: ParsedChannelLink): Promise<ChannelInfo> {
const session = await ChannelSession.open(makeLink(self.location.origin, link.id, link.keys));
try {
const info = await readJson<ChannelWire>(`/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<unknown> {
const { session } = await getSession(id);
return readJson(`/channels/${id}/members`, session.authHeaders(session.can('owner') ? 'owner' : 'read'));
}
export async function listChannels(): Promise<ChannelInfo[]> {
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<void> {
const { session, resource } = await getSession(resourceId);
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
const body: Record<string, unknown> = {};
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<ChannelWire>);
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<void> {
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<void> {
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<void> {
stopChannel(id);
sessions.get(id)?.dispose();
+32 -13
View File
@@ -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<void> {
export async function sendFiles(resourceId: string, files: File[], options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId?: string, folderId?: string): Promise<void> {
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<void
updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting');
});
const uploadedFrames = new Set<number>();
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<void
resource.localItems = resource.localItems.map((entry) => 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<void
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
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<void> {
const created = await sendText(resourceId, text);
export async function editChannelText(resourceId: string, itemId: string, text: string, threadId?: string): Promise<void> {
const created = await sendText(resourceId, text, threadId);
if (!created.uploaded) throw new Error('新版本已排队,网络恢复前保留旧内容。');
await removeChannelItem(resourceId, itemId);
}
@@ -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<void> {
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<string> {
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<string> {
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<void> {
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<void> {
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<void> {
await sendControl(resourceId, { op: 'thread.rename', id, name: cleanName(input) }, id);
}
export async function createFolder(resourceId: string, threadId: string, input: string, parentId?: string): Promise<void> {
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<void> {
await sendControl(resourceId, { op: 'folder.rename', id, name: cleanName(input) }, threadId);
}
export async function moveItems(resourceId: string, itemIds: string[], threadId: string, folderId?: string): Promise<void> {
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<void> {
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<void> {
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<string, unknown>;
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: '空间结构记录格式无效,已拒绝应用。' };
}
}
@@ -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<void> {
active.get(id)?.abort();
@@ -17,6 +19,15 @@ export async function connectChannel(id: string): Promise<void> {
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)); },
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest';
import { storeVerifiedDownload } from './downloadStorage';
function content(chunks: number[][]) { return new ReadableStream<Uint8Array>({ 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');
});
});
@@ -0,0 +1,77 @@
import { IntegrityError, type ItemDecodeResult } from '@webdrop/core';
interface TemporaryFile {
write(bytes: Uint8Array): Promise<void>;
close(): Promise<void>;
abort(): Promise<void>;
file(): Promise<Blob>;
remove(): Promise<void>;
}
const FALLBACK_LIMIT = 64 * 1024 * 1024;
let directory: Promise<{ root: FileSystemDirectoryHandle; dir: FileSystemDirectoryHandle; name: string }> | undefined;
async function temporaryFile(): Promise<TemporaryFile | undefined> {
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<void> {
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<Uint8Array>; result: Promise<ItemDecodeResult> },
expectedSize?: number,
createTemporary: () => Promise<TemporaryFile | undefined> = 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(); }
}
+15
View File
@@ -0,0 +1,15 @@
import type { FileItem } from '../types';
import { IntegrityError, type ItemMeta } from '@webdrop/core';
export const fileCatalogs = new Map<string, FileItem>();
export const visibleFiles = new Set<string>();
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');
}
@@ -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<string, unknown>) {
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' });
});
});
+2 -2
View File
@@ -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);
+7
View File
@@ -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<void> {
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
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);
+68 -4
View File
@@ -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<ClientHints>;
}
let clientHintsPromise: Promise<ClientHints> | undefined;
async function readClientHints(): Promise<ClientHints> {
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<ItemEvent, { type: 'rejected' }>, share = false): void {
if (share) publish(resourceId, { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' });
@@ -0,0 +1,29 @@
import type { SenderInfo } from '../types';
import { accountDisplay } from './accountSession';
let lastAvatar: string | undefined;
let thumbnail: Promise<string | undefined> | undefined;
async function compactAvatar(value: string): Promise<string | undefined> {
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<SenderInfo | undefined> {
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 } : {}) };
}
+11 -1
View File
@@ -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<string, ChannelSession>();
export const readers = new Map<string, ReturnType<ChannelSession['createItemReader']>>();
export const active = new Map<string, AbortController>();
export const blobs = new Map<string, Blob>();
const opening = new Map<string, Promise<ChannelSession>>();
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 };
+1 -1
View File
@@ -37,7 +37,7 @@ export async function addShareFiles(id: string, files: File[]): Promise<void> {
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;
+9 -2
View File
@@ -32,8 +32,10 @@ export async function connectShare(id: string): Promise<void> {
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<Blob> {
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<ShareWire>(`/shares/${id}`, session.authHeaders('read'));
const manifest = await fetchManifest(session, id, raw);
@@ -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<Share> {
requireAccount();
const endpointSecret = crypto.getRandomValues(new Uint8Array(32));
const { session, request } = await ChannelSession.create({ mode: 'enc', endpointSecret });
try {
+5 -9
View File
@@ -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);
}
+3 -1
View File
@@ -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();
+11 -2
View File
@@ -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,