feat(web): add account workspaces and signed content flows
This commit is contained in:
1 parent
7b8d61d1ba
commit
b5cf3d5958
117 files changed
+2789
-1674
No files matched your search
+1
-2
@@ -14,8 +14,7 @@
|
||||
|
||||
## 范围
|
||||
|
||||
- 只修改 `web/` 与前端相关文档(`docs/ui-design.md`、`docs/frontend.md`)。
|
||||
- **不修改** `server/`、`docs/protocol.md`、`docs/api.md`、`docs/openapi.yaml`、`docs/threat-model.md`、`docs/architecture.md`。需要变更时,写明诉求交给负责人。
|
||||
- 按用户要求修改项目文件,遵循对应目录的约定与测试要求。
|
||||
- `@webdrop/core` 未就绪时,在 `src/core-bridge/` 按文档接口做适配与假实现,不自行发明协议。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ShareScreen } from './ShareScreen';
|
||||
import { channelPath, navigate, useRoute } from './navigation';
|
||||
import { Splash } from './Splash';
|
||||
import { AccountScreen } from '@/features/home/AccountScreen';
|
||||
import { AccountDock } from '@/features/home/AccountDock';
|
||||
import { useAccount } from '@/store/account';
|
||||
|
||||
const SEEN_KEY = 'wd.splash';
|
||||
@@ -80,6 +81,7 @@ export function App() {
|
||||
return (
|
||||
<>
|
||||
{screen}
|
||||
{username ? <AccountDock /> : null}
|
||||
{splash ? <Splash ready={ready} onDone={onSplashDone} /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { callWorker } from './workerClient';
|
||||
import type { AccountDeviceBody, InvitationBody } from './generated/api';
|
||||
import type { PersonalProfile } from './protocol/account';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
|
||||
export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: { displayName: string; avatar?: string; modifiedAt: number }; error?: string }
|
||||
export interface AccountDevice { id: string; name: string; created_at: number; last_seen: number; current: boolean }
|
||||
export interface AccountInvitation { channel_id: string; username: string; payload: string; created_at: number }
|
||||
export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }
|
||||
export type AccountDevice = AccountDeviceBody;
|
||||
export type AccountInvitation = InvitationBody;
|
||||
|
||||
export const accountApi = {
|
||||
register: (username: string, password: string, remember = true) => callWorker<AccountStatus>('registerAccount', { username, password, remember, apiBase: API_BASE }),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
import type { AccountInvitation } from './account';
|
||||
import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types';
|
||||
import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types';
|
||||
|
||||
/**
|
||||
* 常驻空间的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。
|
||||
* 常驻空间的 Worker 边界;业务结构来自后端,资源加解密由 @webdrop/core 处理。
|
||||
* 便捷分享有自己的边界,见 shareApi.ts。
|
||||
*/
|
||||
export interface WorkspaceApi {
|
||||
@@ -21,8 +21,6 @@ export interface WorkspaceApi {
|
||||
listSaved(): Promise<ChannelInfo[]>;
|
||||
/** 仅从本机删除保存的链接和终端密钥,不会删服务端空间 */
|
||||
forget(id: string): Promise<void>;
|
||||
/** 仅假实现提供:载入演示用的空间 */
|
||||
loadDemo?(): ChannelInfo[];
|
||||
|
||||
/** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */
|
||||
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
|
||||
@@ -33,6 +31,7 @@ export interface WorkspaceApi {
|
||||
renameGroup(id: string, name: string): Promise<void>;
|
||||
groupThread(id: string, groupId?: string): Promise<void>;
|
||||
renameThread(threadId: string, name: string): Promise<void>;
|
||||
setChannelHistory(threadId: string, chatHistory: boolean, filesHistory: boolean): 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>;
|
||||
@@ -46,6 +45,4 @@ export interface WorkspaceApi {
|
||||
listArchive(id: string): Promise<ArchiveEntry[]>;
|
||||
/** 标记「别的设备的修改」已看过(本地状态) */
|
||||
markSeen(id: string): Promise<void>;
|
||||
/** 仅假实现提供,用于演示连接状态 */
|
||||
simulateConnection?(state: ConnectionState): void;
|
||||
}
|
||||
@@ -1,18 +1,8 @@
|
||||
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
|
||||
interface ServerInfo {
|
||||
captcha?: { enabled?: boolean; api_endpoint?: string };
|
||||
}
|
||||
import { serverApi } from './server';
|
||||
|
||||
/** Cap performs challenge solving in its own workers and only returns a one-use token. */
|
||||
export async function solveCreationCaptcha(): Promise<string | undefined> {
|
||||
const response = await fetch(`${API_BASE.replace(/\/$/, '')}/info`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
if (!response.ok) throw new Error('无法读取服务端能力');
|
||||
const info = await response.json() as ServerInfo;
|
||||
const info = await serverApi.info();
|
||||
if (!info.captcha?.enabled) return undefined;
|
||||
if (typeof info.captcha.api_endpoint !== 'string') throw new Error('验证码服务配置无效');
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { callWorker } from './workerClient';
|
||||
import type { ChannelInfo } from './types';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
import type { MemberBody } from './generated/api';
|
||||
const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
export interface ChannelMember { username: string; role: 'owner' | 'member'; joined_at: number }
|
||||
export type ChannelMember = MemberBody;
|
||||
export const channelAccessApi = {
|
||||
members: (id: string) => callWorker<ChannelMember[]>('channelMembers', { id, apiBase }),
|
||||
preview: (link: ParsedChannelLink) => callWorker<ChannelInfo>('previewChannel', { link, apiBase }),
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||
import type { Item } from './types';
|
||||
import type { ChannelInfo as ChannelMetadata, ShareInfo as ShareMetadata, WorkspaceBody } from './generated/api';
|
||||
|
||||
export type ManifestEntry =
|
||||
| { t: 'file'; id: string; name: string; mime: string; size: number; ts: number }
|
||||
| { t: 'text'; localId: string; name?: string; text: string; ts: number };
|
||||
|
||||
export interface SavedResource {
|
||||
domainKeys?: Record<string, string>;
|
||||
workspaceMetadata?: WorkspaceBody;
|
||||
channelMetadata?: ChannelMetadata;
|
||||
shareMetadata?: ShareMetadata;
|
||||
creatorAccount?: string;
|
||||
localScope?: string;
|
||||
modifiedAt?: number;
|
||||
@@ -26,7 +31,6 @@ export interface SavedResource {
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
seats: number;
|
||||
membersCanUpload: boolean;
|
||||
state?: 'draft' | 'sealed';
|
||||
maxVisitors?: number;
|
||||
maxDownloads?: number | null;
|
||||
@@ -55,6 +59,7 @@ interface DraftFile {
|
||||
}
|
||||
|
||||
interface WebDropDb extends DBSchema {
|
||||
locationOutbox: { key: string; value: QueuedLocation; indexes: { 'by-resource': string } };
|
||||
resourceDeletes: { key: string; value: { id: string; modifiedAt: number } };
|
||||
resources: { key: string; value: SavedResource };
|
||||
outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string; 'by-resource-created': [string, number] } };
|
||||
@@ -73,8 +78,9 @@ export function setResourceChangeListener(listener: (() => void) | undefined): v
|
||||
|
||||
function db(): Promise<IDBPDatabase<WebDropDb>> {
|
||||
let dbPromise = databases.get(resourceScope);
|
||||
dbPromise ??= openDB<WebDropDb>(`webdrop-2-${resourceScope}`, 2, {
|
||||
dbPromise ??= openDB<WebDropDb>(`webdrop-2-${resourceScope}`, 3, {
|
||||
upgrade(database, oldVersion, _newVersion, tx) {
|
||||
if (oldVersion < 3) database.createObjectStore('locationOutbox', { keyPath: 'key' }).createIndex('by-resource', 'resourceId');
|
||||
if (oldVersion < 1) {
|
||||
database.createObjectStore('resources', { keyPath: 'id' });
|
||||
const outbox = database.createObjectStore('outbox', { keyPath: 'key' });
|
||||
@@ -143,6 +149,19 @@ export async function getResource(id: string): Promise<SavedResource | undefined
|
||||
return (await db()).get('resources', id);
|
||||
}
|
||||
|
||||
/** Metadata refresh isn't a user edit and must not enqueue another vault synchronization. */
|
||||
export async function cacheResource(resource: SavedResource, patch: Partial<SavedResource>): Promise<SavedResource> {
|
||||
if (resource.localScope !== currentResourceScope()) throw new DOMException('账号已切换', 'AbortError');
|
||||
const database = await db();
|
||||
const tx = database.transaction('resources', 'readwrite');
|
||||
const current = await tx.store.get(resource.id);
|
||||
if (!current) throw new Error('资源已移除');
|
||||
const updated = { ...current, ...patch };
|
||||
await tx.store.put(updated);
|
||||
await tx.done;
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function listResources(kind: SavedResource['kind']): Promise<SavedResource[]> {
|
||||
const values = await (await db()).getAll('resources');
|
||||
return values.filter((resource) => resource.kind === kind).sort((a, b) => b.createdAt - a.createdAt);
|
||||
@@ -150,7 +169,9 @@ export async function listResources(kind: SavedResource['kind']): Promise<SavedR
|
||||
|
||||
export async function removeResource(id: string): Promise<void> {
|
||||
const database = await db();
|
||||
const tx = database.transaction(['resources', 'outbox', 'draftFiles', 'resourceDeletes'], 'readwrite');
|
||||
const tx = database.transaction(['resources', 'outbox', 'draftFiles', 'resourceDeletes', 'locationOutbox'], 'readwrite');
|
||||
const locationKeys = await tx.objectStore('locationOutbox').index('by-resource').getAllKeys(id);
|
||||
await Promise.all(locationKeys.map(key => tx.objectStore('locationOutbox').delete(key)));
|
||||
await tx.objectStore('resources').delete(id);
|
||||
await tx.objectStore('resourceDeletes').put({ id, modifiedAt: Date.now() });
|
||||
const keys = await tx.objectStore('outbox').index('by-resource').getAllKeys(id);
|
||||
@@ -213,4 +234,14 @@ export async function dequeueItem(resourceId: string, itemId: string): Promise<v
|
||||
const frames = await tx.store.index('by-resource').getAll(resourceId);
|
||||
await Promise.all(frames.filter((frame) => frame.itemId === itemId).map((frame) => tx.store.delete(frame.key)));
|
||||
await tx.done;
|
||||
await (await db()).delete('locationOutbox', `${resourceId}:${itemId}`);
|
||||
}
|
||||
|
||||
export interface QueuedLocation { key: string; resourceId: string; itemId: string; threadId: string; folderId?: string }
|
||||
export async function queueLocation(resourceId: string, itemId: string, threadId: string, folderId?: string): Promise<void> {
|
||||
await (await db()).put('locationOutbox', { key: `${resourceId}:${itemId}`, resourceId, itemId, threadId, folderId });
|
||||
}
|
||||
export async function nextQueuedLocation(resourceId: string): Promise<QueuedLocation | undefined> {
|
||||
return (await (await db()).getAllFromIndex('locationOutbox', 'by-resource', resourceId, 1))[0];
|
||||
}
|
||||
export async function dequeueLocation(key: string): Promise<void> { await (await db()).delete('locationOutbox', key); }
|
||||
@@ -1,6 +1,6 @@
|
||||
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: '请求过于频繁,请稍后重试。',
|
||||
@@ -12,6 +12,7 @@ const messages: Record<string, string> = {
|
||||
account_invalid: '用户名或密码错误,或登录已过期。', account_required: '请先登录账号。',
|
||||
invitation_required: '该空间仅允许受邀账号加入,请登录受邀账号或联系空间主。',
|
||||
channel_password_required: '请输入空间加入密码。', channel_password_invalid: '空间加入密码不正确。',
|
||||
space_name_required: '请填写空间名称。',
|
||||
};
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import type { WorkspaceApi } from './api';
|
||||
import { b64url } from './mockLinks';
|
||||
import { PHONE, SELF, archiveEntries, channel as demoChannel, devices as demoDevices, makeSvgBlob, makeVideoBlob, seedItems, snapshot as demoSnapshot } from './mockSeed';
|
||||
import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, FileUploadOptions, Item, Snapshot, WorkspaceEvent } from './types';
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
/** protocol §2:链接形态。enc 带 k,raw 不带 */
|
||||
function buildLinks(id: string, o: CreateOptions): Pick<ChannelInfo, 'memberLink' | 'ownerLink'> {
|
||||
const base = `${location.origin}/c/${id}`;
|
||||
const a = b64url(32);
|
||||
if (o.mode === 'enc') {
|
||||
const k = b64url(32);
|
||||
return { memberLink: `${base}#k=${k}`, ownerLink: `${base}#k=${k}&a=${a}` };
|
||||
}
|
||||
const w = o.membersCanUpload ? b64url(32) : null;
|
||||
return { memberLink: w ? `${base}#w=${w}` : base, ownerLink: `${base}#a=${a}${w ? `&w=${w}` : ''}` };
|
||||
}
|
||||
|
||||
interface Runtime {
|
||||
info: ChannelInfo;
|
||||
items: Map<string, Item>;
|
||||
blobs: Map<string, Blob | Promise<Blob>>;
|
||||
devices: Device[];
|
||||
snapshot: Snapshot;
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', self: true, joinedAt: Date.now() };
|
||||
|
||||
/** 空间的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */
|
||||
export function createMockApi(): WorkspaceApi {
|
||||
const runtimes = new Map<string, Runtime>();
|
||||
const timers = new Set<number>();
|
||||
let current: Runtime | undefined;
|
||||
let emit: (e: WorkspaceEvent) => void = () => {};
|
||||
|
||||
const later = (ms: number, fn: () => void) => {
|
||||
const id = window.setTimeout(() => {
|
||||
timers.delete(id);
|
||||
fn();
|
||||
}, ms);
|
||||
timers.add(id);
|
||||
};
|
||||
const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const put = (item: Item) => {
|
||||
current?.items.set(item.id, item);
|
||||
emit({ t: 'item', item });
|
||||
};
|
||||
|
||||
function newRuntime(info: ChannelInfo, devices: Device[]): Runtime {
|
||||
const rt: Runtime = { info, items: new Map(), blobs: new Map(), devices, snapshot: { text: '', updatedBy: '', updatedAt: 0 }, demo: false };
|
||||
runtimes.set(info.id, rt);
|
||||
return rt;
|
||||
}
|
||||
|
||||
function seedDemo(): Runtime {
|
||||
const rt = newRuntime(demoChannel, demoDevices);
|
||||
rt.demo = true;
|
||||
rt.snapshot = demoSnapshot;
|
||||
for (const item of seedItems().items) rt.items.set(item.id, item);
|
||||
rt.blobs.set('it-image', makeSvgBlob());
|
||||
rt.blobs.set('it-video', makeVideoBlob().then((b) => b ?? Promise.reject(new Error('unsupported'))));
|
||||
rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' }));
|
||||
rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' }));
|
||||
return rt;
|
||||
}
|
||||
|
||||
/** 演示空间里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */
|
||||
function runDemoActivity(rt: Runtime) {
|
||||
later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' }));
|
||||
later(14000, () => {
|
||||
const cur = rt.items.get('it-image');
|
||||
if (cur?.kind !== 'file') return;
|
||||
const prev = { id: `v${(cur.versions?.length ?? 0) + 1}`, ts: cur.ts, deviceId: cur.deviceId, size: cur.size };
|
||||
put({ ...cur, ts: Date.now(), deviceId: PHONE, size: cur.size + 140, versions: [prev, ...(cur.versions ?? [])], unseen: true });
|
||||
});
|
||||
later(5000, () => emit({ t: 'devices', devices: rt.devices.map((d) => (d.id === PHONE ? { ...d, editing: true } : d)) }));
|
||||
later(11000, () => emit({ t: 'devices', devices: rt.devices }));
|
||||
}
|
||||
|
||||
return {
|
||||
async inviteUser() {},
|
||||
async listInvitations() { return []; },
|
||||
async revokeInvitation() {},
|
||||
async listSaved() { return []; },
|
||||
async forget() {},
|
||||
async createChannel(opts) {
|
||||
await wait(300);
|
||||
const id = b64url(16);
|
||||
const createdAt = Date.now();
|
||||
const info: ChannelInfo = { id, name: opts.name?.trim() || undefined, mode: opts.mode, role: 'owner', seats: opts.seats, seatsUsed: 1, membersCanUpload: opts.membersCanUpload, maxDownloads: opts.fileMaxDownloads ?? 10, ttlSeconds: opts.ttlSeconds, createdAt, expiresAt: createdAt + opts.ttlSeconds * 1000, ...buildLinks(id, opts) };
|
||||
newRuntime(info, [selfDevice]);
|
||||
return info;
|
||||
},
|
||||
|
||||
async joinChannel(link: ParsedChannelLink) {
|
||||
await wait(500);
|
||||
const known = runtimes.get(link.id);
|
||||
if (known) return { ...known.info, role: 'member', ownerLink: undefined };
|
||||
const base = `${location.origin}/c/${link.id}`;
|
||||
const frag = (keys: Record<string, string | undefined>) =>
|
||||
Object.entries(keys).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join('&');
|
||||
const member = frag({ k: link.keys.k, w: link.keys.w });
|
||||
const info: ChannelInfo = {
|
||||
id: link.id,
|
||||
mode: link.mode,
|
||||
role: link.isOwner ? 'owner' : 'member',
|
||||
seats: 3,
|
||||
seatsUsed: 1,
|
||||
membersCanUpload: true,
|
||||
maxDownloads: 10,
|
||||
ttlSeconds: 86400,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 86400_000,
|
||||
memberLink: member ? `${base}#${member}` : base,
|
||||
ownerLink: link.isOwner ? `${base}#${frag(link.keys)}` : undefined,
|
||||
};
|
||||
newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', joinedAt: Date.now() }]);
|
||||
return info;
|
||||
},
|
||||
|
||||
loadDemo() {
|
||||
return [(runtimes.get(demoChannel.id) ?? seedDemo()).info];
|
||||
},
|
||||
|
||||
connect(channelId, onEvent) {
|
||||
emit = onEvent;
|
||||
current = runtimes.get(channelId);
|
||||
const rt = current;
|
||||
if (!rt) return () => {};
|
||||
later(500, () => {
|
||||
emit({ t: 'channel', info: rt.info });
|
||||
emit({ t: 'devices', devices: rt.devices });
|
||||
emit({ t: 'snapshot', snapshot: rt.snapshot });
|
||||
for (const item of rt.items.values()) emit({ t: 'item', item });
|
||||
emit({ t: 'connection', state: 'online' });
|
||||
emit({ t: 'ready' });
|
||||
});
|
||||
if (rt.demo) runDemoActivity(rt);
|
||||
return () => {
|
||||
for (const id of timers) clearTimeout(id);
|
||||
timers.clear();
|
||||
emit = () => {};
|
||||
};
|
||||
},
|
||||
|
||||
async sendText(text, threadId) {
|
||||
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text, ...(threadId ? { threadId } : {}) });
|
||||
},
|
||||
|
||||
async updateChannelSettings(patch) {
|
||||
if (!current || current.info.role !== 'owner') throw new Error('只有所有者可以修改空间设置');
|
||||
current.info = {
|
||||
...current.info,
|
||||
...(patch.name === undefined ? {} : { name: patch.name.trim() || undefined }),
|
||||
...(patch.seats === undefined ? {} : { seats: patch.seats }),
|
||||
...(patch.membersCanUpload === undefined ? {} : { membersCanUpload: patch.membersCanUpload, canUpload: current.info.role === 'owner' || patch.membersCanUpload }),
|
||||
...(patch.ttlSeconds === undefined ? {} : { expiresAt: Date.now() + patch.ttlSeconds * 1000 }),
|
||||
...(patch.fileMaxDownloads === undefined ? {} : { maxDownloads: patch.fileMaxDownloads }),
|
||||
};
|
||||
emit({ t: 'channel', info: current.info });
|
||||
},
|
||||
|
||||
async renameDevice(endpointHash, name) {
|
||||
if (!current) throw new Error('空间未打开');
|
||||
const device = current.devices.find((entry) => entry.id === endpointHash);
|
||||
if (!device) throw new Error('设备不存在');
|
||||
if (current.info.role !== 'owner' && !device.self) throw new Error('只有所有者可以重命名其他设备');
|
||||
current.devices = current.devices.map((entry) => entry.id === endpointHash ? { ...entry, name: name.trim() } : entry);
|
||||
emit({ t: 'devices', devices: current.devices });
|
||||
},
|
||||
|
||||
async releaseDevice(endpointHash) {
|
||||
if (!current || current.info.role !== 'owner') throw new Error('只有所有者可以移除设备');
|
||||
current.devices = current.devices.filter((entry) => entry.id !== endpointHash);
|
||||
current.info = { ...current.info, seatsUsed: current.devices.length };
|
||||
emit({ t: 'devices', devices: current.devices });
|
||||
emit({ t: 'channel', info: current.info });
|
||||
},
|
||||
|
||||
async sendFiles(files, options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId, folderId) {
|
||||
for (const file of files) {
|
||||
const id = uid();
|
||||
const base: FileItem = {
|
||||
id, kind: 'file', ts: Date.now(), deviceId: SELF, state: { kind: 'receiving', progress: 0 },
|
||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
|
||||
name: file.name, mime: file.type, size: file.size, remaining: options.directPreview || options.maxDownloads === 0 ? null : options.maxDownloads,
|
||||
gated: !options.directPreview,
|
||||
preview: previewKindOf(file.type, file.name),
|
||||
};
|
||||
current?.blobs.set(id, file);
|
||||
put(base);
|
||||
for (let p = 0.2; p < 1; p += 0.2) {
|
||||
await wait(250);
|
||||
put({ ...base, state: { kind: 'receiving', progress: p } });
|
||||
}
|
||||
await wait(250);
|
||||
put({ ...base, state: { kind: 'complete' } });
|
||||
}
|
||||
},
|
||||
|
||||
async createThread(name, groupId) {
|
||||
const id = crypto.randomUUID();
|
||||
emit({ t: 'thread-created', thread: { id, name: name.trim(), createdAt: Date.now(), createdBy: SELF, ...(groupId ? { groupId } : {}) } });
|
||||
return id;
|
||||
},
|
||||
|
||||
async renameThread(threadId, name) { emit({ t: 'thread-renamed', id: threadId, name: name.trim() }); },
|
||||
async createGroup(name) { const id = crypto.randomUUID(); emit({ t: 'group-created', group: { id, name: name.trim(), createdAt: Date.now() } }); return id; },
|
||||
async renameGroup(id, name) { emit({ t: 'group-renamed', id, name: name.trim() }); },
|
||||
async groupThread(id, groupId) { emit({ t: 'thread-grouped', id, ...(groupId ? { groupId } : {}) }); },
|
||||
|
||||
async createFolder(threadId, name, parentId) {
|
||||
emit({ t: 'folder-created', folder: { id: crypto.randomUUID(), threadId, ...(parentId ? { parentId } : {}), name: name.trim() } });
|
||||
},
|
||||
|
||||
async renameFolder(folderId, _threadId, name) { emit({ t: 'folder-renamed', id: folderId, name: name.trim() }); },
|
||||
|
||||
async moveItems(itemIds, threadId, folderId) {
|
||||
for (const id of itemIds) {
|
||||
const item = current?.items.get(id);
|
||||
if (item) put({ ...item, threadId, folderId });
|
||||
}
|
||||
emit({ t: 'items-moved', itemIds, threadId, ...(folderId ? { folderId } : {}) });
|
||||
},
|
||||
|
||||
async editText(id, text) {
|
||||
const cur = current?.items.get(id);
|
||||
if (cur?.kind !== 'text') return;
|
||||
put({ ...cur, text, editedAt: Date.now() });
|
||||
},
|
||||
|
||||
async resolveConflict(id, choice) {
|
||||
const cur = current?.items.get(id);
|
||||
if (cur?.kind !== 'text' || !cur.conflict) return;
|
||||
put({ ...cur, text: choice === 'theirs' ? cur.conflict.text : cur.text, conflict: undefined });
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
current?.items.delete(id);
|
||||
current?.blobs.delete(id);
|
||||
emit({ t: 'removed', id });
|
||||
},
|
||||
|
||||
async setSnapshot(text) {
|
||||
const snapshot = { text, updatedBy: SELF, updatedAt: Date.now() };
|
||||
if (current) current.snapshot = snapshot;
|
||||
emit({ t: 'snapshot', snapshot });
|
||||
},
|
||||
|
||||
async getBlob(id) {
|
||||
const b = current?.blobs.get(id);
|
||||
if (b) return b;
|
||||
const item = current?.items.get(id);
|
||||
if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' });
|
||||
throw new Error('not found');
|
||||
},
|
||||
|
||||
async markSeen(id) {
|
||||
const cur = current?.items.get(id);
|
||||
if (cur?.kind !== 'file' || !cur.unseen) return;
|
||||
put({ ...cur, unseen: false });
|
||||
},
|
||||
|
||||
async listArchive() {
|
||||
await wait(250);
|
||||
return archiveEntries;
|
||||
},
|
||||
|
||||
simulateConnection(state: ConnectionState) {
|
||||
emit({ t: 'connection', state });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/** 假实现用的随机 id 与密钥:n 字节 → base64url 无填充(16 字节 22 字符,32 字节 43 字符) */
|
||||
export function b64url(bytes: number): string {
|
||||
const b = crypto.getRandomValues(new Uint8Array(bytes));
|
||||
return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import type { ArchiveEntry, ChannelInfo, Device, Item, Snapshot } from './types';
|
||||
|
||||
/** 仅供假实现使用的演示数据与演示用文件内容 */
|
||||
|
||||
export const SELF = 'self';
|
||||
export const PHONE = 'phone';
|
||||
export const DEMO_ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
|
||||
|
||||
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
|
||||
|
||||
export const devices: Device[] = [
|
||||
{ id: SELF, name: '本机 · Chrome', kind: 'desktop', self: true, joinedAt: Date.now() },
|
||||
{ id: PHONE, name: 'iPhone', kind: 'phone', joinedAt: Date.now() - 3600_000 },
|
||||
{ id: 'tablet', name: 'iPad', kind: 'tablet', joinedAt: Date.now() - 3 * 3600_000 },
|
||||
];
|
||||
|
||||
export const channel: ChannelInfo = {
|
||||
id: DEMO_ID,
|
||||
mode: 'enc',
|
||||
role: 'owner',
|
||||
seats: 3,
|
||||
seatsUsed: 3,
|
||||
createdAt: Date.now() - 3 * 3600_000,
|
||||
expiresAt: Date.now() + 21 * 3600_000,
|
||||
memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`,
|
||||
ownerLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}&a=${KEY}`,
|
||||
};
|
||||
|
||||
export const snapshot: Snapshot = {
|
||||
text: '待办\n- 取快递\n- 发周报\n- 备份相册',
|
||||
updatedBy: PHONE,
|
||||
updatedAt: Date.now() - 20 * 60_000,
|
||||
};
|
||||
|
||||
export const archiveEntries: ArchiveEntry[] = [
|
||||
{ path: 'project-assets/', size: 0, dir: true },
|
||||
{ path: 'project-assets/README.md', size: 2_140, dir: false },
|
||||
{ path: 'project-assets/design/', size: 0, dir: true },
|
||||
{ path: 'project-assets/design/logo.svg', size: 8_420, dir: false },
|
||||
{ path: 'project-assets/design/cover.png', size: 3_281_990, dir: false },
|
||||
{ path: 'project-assets/design/mockup.fig', size: 18_204_113, dir: false },
|
||||
{ path: 'project-assets/fonts/', size: 0, dir: true },
|
||||
{ path: 'project-assets/fonts/SourceHanSans-Regular.otf', size: 16_532_204, dir: false },
|
||||
{ path: 'project-assets/data/', size: 0, dir: true },
|
||||
{ path: 'project-assets/data/export.csv', size: 412_873, dir: false },
|
||||
{ path: 'project-assets/notes.txt', size: 1_822, dir: false },
|
||||
];
|
||||
|
||||
const ago = (min: number) => Date.now() - min * 60_000;
|
||||
|
||||
export function seedItems(): { items: Item[]; sizeOverride: Record<string, number> } {
|
||||
const logText = Array.from({ length: 180 }, (_, i) => {
|
||||
const level = i % 17 === 0 ? 'WARN' : 'INFO';
|
||||
return `2026-10-01 14:${String(i % 60).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')} ${level} worker-${i % 4} handled request #${1000 + i} in ${20 + ((i * 13) % 80)}ms`;
|
||||
}).join('\n');
|
||||
|
||||
const items: Item[] = [
|
||||
{
|
||||
id: 'it-video', kind: 'file', ts: ago(3), deviceId: PHONE, state: { kind: 'complete' },
|
||||
name: '屏幕录制.webm', mime: 'video/webm', size: 1_840_000, remaining: null, preview: 'video',
|
||||
},
|
||||
{
|
||||
id: 'it-zip', kind: 'file', ts: ago(7), deviceId: PHONE, state: { kind: 'complete' },
|
||||
name: 'project-assets.zip', mime: 'application/zip', size: 48_200_000, remaining: 8, preview: 'archive',
|
||||
},
|
||||
{
|
||||
id: 'it-image', kind: 'file', ts: ago(12), deviceId: PHONE, state: { kind: 'complete' },
|
||||
name: '设计稿.svg', mime: 'image/svg+xml', size: 6_420, remaining: null, preview: 'image',
|
||||
versions: [
|
||||
{ id: 'v2', ts: ago(55), deviceId: SELF, size: 6_110 },
|
||||
{ id: 'v1', ts: ago(130), deviceId: SELF, size: 5_820 },
|
||||
],
|
||||
unseen: true,
|
||||
},
|
||||
{
|
||||
id: 'it-log', kind: 'text', ts: ago(18), deviceId: SELF, state: { kind: 'complete' },
|
||||
text: logText,
|
||||
},
|
||||
{
|
||||
id: 'it-conflict', kind: 'text', ts: ago(26), deviceId: SELF, state: { kind: 'complete' },
|
||||
text: '会议室 B204', editedAt: ago(25), conflict: { deviceId: PHONE, text: '会议室 B305' },
|
||||
},
|
||||
{
|
||||
id: 'it-json', kind: 'text', ts: ago(30), deviceId: PHONE, state: { kind: 'complete' },
|
||||
text: '{"name":"webdrop","version":1,"tags":["share","e2ee"],"limits":{"seats":3,"ttl":86400}}',
|
||||
},
|
||||
{
|
||||
id: 'it-short', kind: 'text', ts: ago(41), deviceId: PHONE, state: { kind: 'complete' },
|
||||
text: '明天 10 点开会,带电脑',
|
||||
},
|
||||
{
|
||||
id: 'it-pdf', kind: 'file', ts: ago(95), deviceId: SELF, state: { kind: 'complete' },
|
||||
name: 'invoice-2026-09.pdf', mime: 'application/pdf', size: 1_240_000, remaining: null, preview: 'none',
|
||||
},
|
||||
];
|
||||
return { items, sizeOverride: {} };
|
||||
}
|
||||
|
||||
export function makeSvgBlob(): Blob {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d9d9d4"/><stop offset="1" stop-color="#8f8f88"/></linearGradient></defs>
|
||||
<rect width="640" height="400" fill="url(#g)"/>
|
||||
<rect x="60" y="60" width="520" height="280" rx="12" fill="#fff" opacity=".85"/>
|
||||
<text x="320" y="215" font-size="42" text-anchor="middle" font-family="sans-serif" fill="#1a1a1a">设计稿 · 演示图片</text></svg>`;
|
||||
return new Blob([svg], { type: 'image/svg+xml' });
|
||||
}
|
||||
|
||||
/** 用 canvas + MediaRecorder 临时生成一段几秒的演示视频;浏览器不支持则返回 null */
|
||||
export async function makeVideoBlob(): Promise<Blob | null> {
|
||||
try {
|
||||
if (typeof MediaRecorder === 'undefined') return null;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 480;
|
||||
canvas.height = 270;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const rec = new MediaRecorder(canvas.captureStream(24), { mimeType: 'video/webm' });
|
||||
const chunks: Blob[] = [];
|
||||
rec.ondataavailable = (e) => chunks.push(e.data);
|
||||
const done = new Promise<Blob>((res) => {
|
||||
rec.onstop = () => res(new Blob(chunks, { type: 'video/webm' }));
|
||||
});
|
||||
rec.start();
|
||||
const t0 = performance.now();
|
||||
await new Promise<void>((resolve) => {
|
||||
const draw = () => {
|
||||
const t = (performance.now() - t0) / 1000;
|
||||
ctx.fillStyle = `hsl(${(t * 60) % 360} 10% ${30 + 10 * Math.sin(t * 2)}%)`;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '28px sans-serif';
|
||||
ctx.fillText(`演示视频 ${t.toFixed(1)}s`, 150, 140);
|
||||
if (t < 3) requestAnimationFrame(draw);
|
||||
else resolve();
|
||||
};
|
||||
draw();
|
||||
});
|
||||
rec.stop();
|
||||
return await done;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function makeLogBlob(text: string): Blob {
|
||||
return new Blob([text], { type: 'text/plain' });
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import type { ShareApi } from './shareApi';
|
||||
import { b64url } from './mockLinks';
|
||||
import { PHONE, archiveEntries, makeSvgBlob, seedItems } from './mockSeed';
|
||||
import type { FileItem, Item, Share, ShareEvent, ShareSettings } from './types';
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
const CREATOR = 'creator';
|
||||
export const DEMO_SHARE_ID = 'ShareDemoShareDemoAbcd';
|
||||
const DEMO_KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
|
||||
|
||||
interface Runtime {
|
||||
share: Share;
|
||||
items: Map<string, Item>;
|
||||
blobs: Map<string, Blob | Promise<Blob>>;
|
||||
}
|
||||
|
||||
/** 便捷分享的假实现:不联网,数据都在内存里,刷新即丢。接口见 shareApi.ts */
|
||||
export function createMockShareApi(): ShareApi {
|
||||
const runtimes = new Map<string, Runtime>();
|
||||
let current: Runtime | undefined;
|
||||
let emit: (e: ShareEvent) => void = () => {};
|
||||
const timers = new Set<number>();
|
||||
|
||||
const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const put = (item: Item) => {
|
||||
current?.items.set(item.id, item);
|
||||
emit({ t: 'item', item });
|
||||
};
|
||||
const draftOnly = () => {
|
||||
if (!current || current.share.state !== 'draft') throw new Error('forbidden');
|
||||
return current;
|
||||
};
|
||||
|
||||
function seedDemo(): Runtime {
|
||||
const share: Share = {
|
||||
id: DEMO_SHARE_ID,
|
||||
state: 'sealed',
|
||||
mine: false,
|
||||
link: `${location.origin}/s/${DEMO_SHARE_ID}#k=${DEMO_KEY}`,
|
||||
createdAt: Date.now() - 2 * 3600_000,
|
||||
expiresAt: Date.now() + 22 * 3600_000,
|
||||
ttlSeconds: 86400,
|
||||
maxVisitors: 10,
|
||||
maxDownloads: 5,
|
||||
};
|
||||
const rt: Runtime = { share, items: new Map(), blobs: new Map() };
|
||||
const keep = new Set(['it-zip', 'it-image', 'it-json', 'it-short', 'it-pdf']);
|
||||
for (const item of seedItems().items) {
|
||||
if (!keep.has(item.id)) continue;
|
||||
rt.items.set(item.id, item.kind === 'file'
|
||||
? { ...item, deviceId: CREATOR, versions: undefined, unseen: false, remaining: share.maxDownloads }
|
||||
: { ...item, deviceId: CREATOR });
|
||||
}
|
||||
rt.blobs.set('it-image', makeSvgBlob());
|
||||
rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' }));
|
||||
rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' }));
|
||||
runtimes.set(share.id, rt);
|
||||
return rt;
|
||||
}
|
||||
|
||||
return {
|
||||
async listSaved() { return []; },
|
||||
async forget() {},
|
||||
async createDraft(settings) {
|
||||
await wait(300);
|
||||
const id = b64url(16);
|
||||
const share: Share = {
|
||||
id,
|
||||
state: 'draft',
|
||||
mine: true,
|
||||
link: `${location.origin}/s/${id}#k=${b64url(32)}`,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 86400_000,
|
||||
...settings,
|
||||
};
|
||||
runtimes.set(id, { share, items: new Map(), blobs: new Map() });
|
||||
return share;
|
||||
},
|
||||
|
||||
async updateSettings(id, patch: Partial<ShareSettings>) {
|
||||
const rt = runtimes.get(id);
|
||||
if (!rt || rt.share.state !== 'draft') throw new Error('forbidden');
|
||||
rt.share = { ...rt.share, ...patch };
|
||||
emit({ t: 'share', share: rt.share });
|
||||
return rt.share;
|
||||
},
|
||||
|
||||
async seal(id) {
|
||||
await wait(300);
|
||||
const rt = runtimes.get(id);
|
||||
if (!rt || rt.share.state !== 'draft') throw new Error('forbidden');
|
||||
// 丢弃所有者密钥:此后没有人能改,创建者也只是一个读者
|
||||
rt.share = { ...rt.share, state: 'sealed' };
|
||||
for (const item of rt.items.values()) {
|
||||
if (item.kind === 'file') rt.items.set(item.id, { ...item, remaining: rt.share.maxDownloads });
|
||||
}
|
||||
emit({ t: 'share', share: rt.share });
|
||||
for (const item of rt.items.values()) emit({ t: 'item', item });
|
||||
return rt.share;
|
||||
},
|
||||
|
||||
async join(link) {
|
||||
await wait(300);
|
||||
const known = runtimes.get(link.id);
|
||||
if (known) return known.share;
|
||||
// 原型里不认识的链接:当作一个已封存、没有内容的分享
|
||||
const share: Share = {
|
||||
id: link.id,
|
||||
state: 'sealed',
|
||||
mine: false,
|
||||
link: `${location.origin}/s/${link.id}#k=${link.key}`,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 86400_000,
|
||||
ttlSeconds: 86400,
|
||||
maxVisitors: 10,
|
||||
maxDownloads: null,
|
||||
};
|
||||
runtimes.set(link.id, { share, items: new Map(), blobs: new Map() });
|
||||
return share;
|
||||
},
|
||||
|
||||
loadDemo() {
|
||||
return [(runtimes.get(DEMO_SHARE_ID) ?? seedDemo()).share];
|
||||
},
|
||||
|
||||
connect(shareId, onEvent) {
|
||||
emit = onEvent;
|
||||
current = runtimes.get(shareId);
|
||||
const rt = current;
|
||||
if (!rt) return () => {};
|
||||
const id = window.setTimeout(() => {
|
||||
timers.delete(id);
|
||||
emit({ t: 'share', share: rt.share });
|
||||
for (const item of rt.items.values()) emit({ t: 'item', item });
|
||||
emit({ t: 'ready' });
|
||||
}, 400);
|
||||
timers.add(id);
|
||||
return () => {
|
||||
for (const t of timers) clearTimeout(t);
|
||||
timers.clear();
|
||||
emit = () => {};
|
||||
};
|
||||
},
|
||||
|
||||
async addText(text) {
|
||||
draftOnly();
|
||||
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text });
|
||||
},
|
||||
|
||||
async editText(id, text) {
|
||||
const rt = draftOnly();
|
||||
const item = rt.items.get(id);
|
||||
if (!item || item.kind !== 'text') throw new Error('not_found');
|
||||
put({ ...item, text, ts: Date.now() });
|
||||
},
|
||||
|
||||
async addFiles(files) {
|
||||
const rt = draftOnly();
|
||||
for (const file of files) {
|
||||
const id = uid();
|
||||
const base: FileItem = {
|
||||
id, kind: 'file', ts: Date.now(), deviceId: PHONE, state: { kind: 'receiving', progress: 0 },
|
||||
name: file.name, mime: file.type, size: file.size, remaining: null,
|
||||
preview: previewKindOf(file.type, file.name),
|
||||
};
|
||||
rt.blobs.set(id, file);
|
||||
put(base);
|
||||
for (let p = 0.25; p < 1; p += 0.25) {
|
||||
await wait(200);
|
||||
put({ ...base, state: { kind: 'receiving', progress: p } });
|
||||
}
|
||||
await wait(200);
|
||||
put({ ...base, state: { kind: 'complete' } });
|
||||
}
|
||||
},
|
||||
|
||||
async removeItem(id) {
|
||||
const rt = draftOnly();
|
||||
rt.items.delete(id);
|
||||
rt.blobs.delete(id);
|
||||
emit({ t: 'removed', id });
|
||||
},
|
||||
|
||||
async getBlob(id) {
|
||||
const item = current?.items.get(id);
|
||||
// 封存后每次拉取消耗一次下载(GATED,api.md);用完即 410
|
||||
if (item?.kind === 'file' && current?.share.state === 'sealed' && item.remaining !== null) {
|
||||
if (item.remaining <= 0) throw new Error('gone');
|
||||
put({ ...item, remaining: item.remaining - 1 });
|
||||
}
|
||||
const b = current?.blobs.get(id);
|
||||
if (b) return b;
|
||||
if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' });
|
||||
throw new Error('not found');
|
||||
},
|
||||
|
||||
async listArchive() {
|
||||
await wait(250);
|
||||
return archiveEntries;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { callWorker } from './workerClient';
|
||||
import type { AccountStatus } from './account';
|
||||
import type { PasskeyOptionsBody, PasskeyInfoBody } from './generated/api';
|
||||
|
||||
const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
const call = <T>(operation: string, payload: object = {}) => callWorker<T>(operation, { ...payload, apiBase });
|
||||
export const passkeysAvailable = () => typeof PublicKeyCredential !== 'undefined' && window.isSecureContext;
|
||||
const decode = (value: string): Uint8Array<ArrayBuffer> => Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=')), c => c.charCodeAt(0));
|
||||
interface PrfResult { enabled?: boolean; results?: { first?: ArrayBuffer } }
|
||||
const prfResult = (credential: PublicKeyCredential) => (credential.getClientExtensionResults() as AuthenticationExtensionsClientOutputs & { prf?: PrfResult }).prf;
|
||||
function credentialJson(credential: PublicKeyCredential): string {
|
||||
// W3C PRF: omit client extension outputs when sending credentials to the server.
|
||||
return JSON.stringify({ ...credential.toJSON(), clientExtensionResults: {} });
|
||||
}
|
||||
async function assertion(options: PasskeyOptionsBody, credentialId?: string): Promise<PublicKeyCredential> {
|
||||
const json = JSON.parse(options.options) as PublicKeyCredentialRequestOptionsJSON;
|
||||
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json);
|
||||
if (credentialId) publicKey.allowCredentials = [{ type: 'public-key', id: decode(credentialId) }];
|
||||
const evalByCredential = Object.fromEntries(options.credentials.filter(p => !credentialId || p.id === credentialId).map(p => [p.id, { first: decode(p.prf_salt) }]));
|
||||
publicKey.extensions = { ...publicKey.extensions, prf: { evalByCredential } } as AuthenticationExtensionsClientInputs;
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
if (!(credential instanceof PublicKeyCredential)) throw new Error('Passkey验证未完成');
|
||||
return credential;
|
||||
}
|
||||
export async function bindPasskey(password: string, name: string): Promise<void> {
|
||||
if (!passkeysAvailable()) throw new Error('当前浏览器不支持Passkey,请使用HTTPS或localhost');
|
||||
let prf: ArrayBuffer | undefined;
|
||||
try {
|
||||
const options = await call<PasskeyOptionsBody>('beginPasskeyRegistration', { password });
|
||||
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(JSON.parse(options.options));
|
||||
publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: decode(options.prf_salt!) } } } as AuthenticationExtensionsClientInputs;
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
if (!(credential instanceof PublicKeyCredential)) throw new Error('Passkey绑定未完成');
|
||||
const extension = prfResult(credential); prf = extension?.results?.first;
|
||||
const registered = await call<PasskeyInfoBody>('finishPasskeyRegistration', { id: options.id, credentialId: credential.id, credential: credentialJson(credential), name, prf });
|
||||
if (!registered.can_recover && extension?.enabled) {
|
||||
const request = await call<PasskeyOptionsBody>('beginPasskeyRecovery');
|
||||
const proof = await assertion(request, registered.id); prf = prfResult(proof)?.results?.first;
|
||||
if (prf) await call<void>('finishPasskeyRecovery', { id: request.id, credentialId: proof.id, credential: credentialJson(proof), prf });
|
||||
}
|
||||
} finally {
|
||||
if (prf) new Uint8Array(prf).fill(0);
|
||||
await call('cancelPasskey').catch(() => {});
|
||||
}
|
||||
}
|
||||
export async function loginWithPasskey(username: string, remember: boolean): Promise<AccountStatus> {
|
||||
if (!passkeysAvailable()) throw new Error('当前浏览器不支持Passkey,请使用HTTPS或localhost');
|
||||
const options = await call<PasskeyOptionsBody>('beginPasskeyLogin', { username });
|
||||
const proof = await assertion(options); const prf = prfResult(proof)?.results?.first;
|
||||
try { return await call<AccountStatus>('finishPasskeyLogin', { username, id: options.id, credential: credentialJson(proof), prf, remember }); }
|
||||
finally { if (prf) new Uint8Array(prf).fill(0); }
|
||||
}
|
||||
export const passkeyApi = {
|
||||
list: () => call<PasskeyInfoBody[]>('listPasskeys'),
|
||||
remove: (id: string) => call<void>('removePasskey', { id }),
|
||||
unlock: (password: string, remember: boolean) => call<AccountStatus>('unlockPasskeyLogin', { password, remember }),
|
||||
cancel: () => call<AccountStatus>('cancelPasskey'),
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AccountProfile } from '../generated/api';
|
||||
export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
|
||||
export interface SigningKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
|
||||
/** Account vault v2: credentials and cached metadata, encrypted before transport. */
|
||||
export type PersonalProfile = Pick<AccountProfile, 'displayName' | 'modifiedAt'> & { avatar?: string };
|
||||
export interface ResourceDelete { id: string; modifiedAt: number }
|
||||
export interface VaultResource {
|
||||
domainKeys?: Record<string, string>;
|
||||
id: string;
|
||||
kind: 'channel' | 'share';
|
||||
mode: 'enc' | 'raw';
|
||||
role: 'owner' | 'member';
|
||||
link: string;
|
||||
ownerLink?: string;
|
||||
modifiedAt?: number;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
creatorAccount?: string;
|
||||
name?: string;
|
||||
seats: number;
|
||||
ttlSeconds?: number;
|
||||
maxDownloads?: number | null;
|
||||
maxVisitors?: number;
|
||||
mine?: boolean;
|
||||
state?: 'draft' | 'sealed';
|
||||
}
|
||||
export interface VaultProfile { v: 3; keys: InvitationKeyPair & { signing: SigningKeyPair }; resources: VaultResource[]; deletes: ResourceDelete[] }
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Encrypted resource metadata v1. Account and structure DTOs are generated from the backend. */
|
||||
export interface PublishedFile {
|
||||
id: string;
|
||||
kind: 'file';
|
||||
threadId: string;
|
||||
folderId?: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
ts: number;
|
||||
deviceId: string;
|
||||
gated: boolean;
|
||||
}
|
||||
export type ResourceControl = { op: 'file.published'; item: PublishedFile };
|
||||
export const WORKSPACE_APP = 'webdrop.workspace';
|
||||
@@ -4,7 +4,7 @@ import type { ParsedShareLink } from '@/lib/shareLink';
|
||||
import type { WorkspaceApi } from './api';
|
||||
import type { ShareApi } from './shareApi';
|
||||
import { callWorker, subscribeWorker } from './workerClient';
|
||||
import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types';
|
||||
import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
|
||||
@@ -44,6 +44,7 @@ export const realApi: WorkspaceApi = {
|
||||
renameGroup: (groupId, name) => callWorker<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 }),
|
||||
setChannelHistory: (threadId, chatHistory, filesHistory) => callWorker<void>('setChannelHistory', { id: currentChannelId(), threadId, chatHistory, filesHistory, 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 }),
|
||||
@@ -96,5 +97,3 @@ function currentChannelId(): string {
|
||||
function currentShareId(): string {
|
||||
return activeShareId;
|
||||
}
|
||||
|
||||
export function simulateConnection(_state: ConnectionState): void {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { callWorker } from './workerClient';
|
||||
import type { ServerInfoBody } from './generated/api';
|
||||
|
||||
const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
export const serverApi = { info: () => callWorker<ServerInfoBody>('serverInfo', { apiBase }) };
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { DefaultsBody } from './generated/api';
|
||||
import type { CreateOptions } from './types';
|
||||
|
||||
export function creationOptions(defaults: DefaultsBody): CreateOptions {
|
||||
const settings = defaults.channel;
|
||||
return { mode: defaults.channel_mode, seats: settings.seats, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
|
||||
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests };
|
||||
}
|
||||
@@ -20,8 +20,6 @@ export interface ShareApi {
|
||||
join(link: ParsedShareLink): Promise<Share>;
|
||||
/** 仅从本机移除链接和终端密钥,不会删除已封存的远端分享 */
|
||||
forget(id: string): Promise<void>;
|
||||
/** 仅假实现提供:载入演示用的分享 */
|
||||
loadDemo?(): Share[];
|
||||
|
||||
/** 订阅某个分享的事件流,返回取消订阅函数 */
|
||||
connect(shareId: string, onEvent: (e: ShareEvent) => void): () => void;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceFolder, WorkspaceState, AccountProfile } from './generated/api';
|
||||
|
||||
export type DeviceKind = 'desktop' | 'phone' | 'tablet';
|
||||
export type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
||||
export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none';
|
||||
@@ -14,6 +16,7 @@ export interface Device {
|
||||
|
||||
/** 常驻空间:自己多台设备之间长期同步 */
|
||||
export interface ChannelInfo {
|
||||
dataSource?: 'server' | 'cache';
|
||||
creatorAccount?: string;
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
allowGuests?: boolean;
|
||||
@@ -21,10 +24,10 @@ export interface ChannelInfo {
|
||||
id: string;
|
||||
/** 服务端 channel settings 中的共享名称,加入者可见。 */
|
||||
name?: string;
|
||||
mode: 'enc' | 'raw';
|
||||
mode: ChannelMode;
|
||||
role: 'owner' | 'member';
|
||||
seats: number;
|
||||
seatsUsed?: number;
|
||||
membersUsed?: number;
|
||||
membersCanUpload?: boolean;
|
||||
ttlSeconds?: number;
|
||||
/** 每个新上传文件的默认下载次数上限。 */
|
||||
@@ -35,6 +38,7 @@ export interface ChannelInfo {
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
canUpload?: boolean;
|
||||
canManageStructure?: boolean;
|
||||
}
|
||||
|
||||
/** 新建空间的选项(protocol §12 设置的子集) */
|
||||
@@ -43,7 +47,7 @@ export interface CreateOptions {
|
||||
allowGuests?: boolean;
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
mode: 'enc' | 'raw';
|
||||
mode: ChannelMode;
|
||||
seats: number;
|
||||
ttlSeconds: number;
|
||||
membersCanUpload: boolean;
|
||||
@@ -72,6 +76,7 @@ export interface ChannelSettingsPatch {
|
||||
* 完成分享时丢弃,此后无人可改,只能等过期。始终是加密模式。
|
||||
*/
|
||||
export interface Share {
|
||||
dataSource?: 'server' | 'cache';
|
||||
id: string;
|
||||
/** draft:创建者可编辑;sealed:已封存,只读 */
|
||||
state: 'draft' | 'sealed';
|
||||
@@ -112,7 +117,7 @@ interface ItemBase {
|
||||
folderId?: string;
|
||||
}
|
||||
|
||||
export interface SenderInfo { username: string; displayName: string; avatar?: string }
|
||||
export type SenderInfo = Pick<AccountProfile, 'username' | 'displayName'> & { avatar?: string };
|
||||
|
||||
export interface TextItem extends ItemBase {
|
||||
kind: 'text';
|
||||
@@ -165,11 +170,12 @@ export interface Snapshot {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ThreadInfo { id: string; name: string; createdAt: number; createdBy: string; groupId?: string }
|
||||
export interface ChannelGroupInfo { id: string; name: string; createdAt: number }
|
||||
export interface FolderInfo { id: string; threadId: string; parentId?: string; name: string }
|
||||
export type ThreadInfo = Omit<WorkspaceChannel, 'groupId'> & { groupId?: string };
|
||||
export type ChannelGroupInfo = WorkspaceGroup;
|
||||
export type FolderInfo = Omit<WorkspaceFolder, 'parentId'> & { parentId?: string };
|
||||
|
||||
export type WorkspaceEvent =
|
||||
| { t: 'structure'; state: WorkspaceState; people: AccountProfile[] }
|
||||
| { t: 'reset' }
|
||||
| { t: 'error'; message: string }
|
||||
| { t: 'ready' }
|
||||
@@ -178,16 +184,7 @@ export type WorkspaceEvent =
|
||||
| { t: 'devices'; devices: Device[] }
|
||||
| { t: 'item'; item: Item }
|
||||
| { t: 'file-catalog'; item: FileItem }
|
||||
| { t: 'item-author'; id: string; author: SenderInfo }
|
||||
| { t: 'removed'; id: string }
|
||||
| { t: 'thread-created'; thread: ThreadInfo }
|
||||
| { t: 'thread-renamed'; id: string; name: string }
|
||||
| { t: 'group-created'; group: ChannelGroupInfo }
|
||||
| { t: 'group-renamed'; id: string; name: string }
|
||||
| { t: 'thread-grouped'; id: string; groupId?: string }
|
||||
| { t: 'folder-created'; folder: FolderInfo }
|
||||
| { t: 'folder-renamed'; id: string; name: string }
|
||||
| { t: 'items-moved'; itemIds: string[]; threadId: string; folderId?: string }
|
||||
| { t: 'snapshot'; snapshot: Snapshot };
|
||||
|
||||
export type ShareEvent =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { setApiBase } from './worker/http';
|
||||
import { setApiBase, getServerInfo } from './worker/http';
|
||||
import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys';
|
||||
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
|
||||
import { createChannel, forgetChannel, joinChannel, listChannels, releaseDevice, renameDevice, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
|
||||
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
|
||||
@@ -11,7 +12,7 @@ import { setEventSink } from './worker/port';
|
||||
import { serial } from './worker/serial';
|
||||
import { errorMessage } from './errors';
|
||||
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
|
||||
import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread } from './worker/channelThreads';
|
||||
import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads';
|
||||
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account';
|
||||
|
||||
interface WorkerRequest { requestId: number; operation: string; payload: unknown }
|
||||
@@ -26,9 +27,21 @@ setEventSink((resourceId, event) => scope.postMessage({ resourceId, event }));
|
||||
async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
const payload = input as Record<string, unknown>;
|
||||
if (typeof payload.apiBase === 'string') setApiBase(payload.apiBase);
|
||||
if (operation === 'serverInfo') return getServerInfo();
|
||||
await restoreAccount();
|
||||
const id = typeof payload.id === 'string' ? payload.id : '';
|
||||
switch (operation) {
|
||||
case 'setChannelHistory': return setChannelHistory(id, String(payload.threadId ?? ''), payload.chatHistory === true, payload.filesHistory === true);
|
||||
case 'listPasskeys': return listPasskeys();
|
||||
case 'removePasskey': return removePasskey(String(payload.id ?? ''));
|
||||
case 'cancelPasskey': clearPasskeyState(); return accountStatus();
|
||||
case 'beginPasskeyRegistration': return beginPasskeyRegistration(String(payload.password ?? ''));
|
||||
case 'finishPasskeyRegistration': return finishPasskeyRegistration(id, String(payload.credentialId ?? ''), String(payload.credential ?? ''), String(payload.name ?? ''), payload.prf as ArrayBuffer | undefined);
|
||||
case 'beginPasskeyRecovery': return beginPasskeyRecovery();
|
||||
case 'finishPasskeyRecovery': return finishPasskeyRecovery(id, String(payload.credentialId ?? ''), String(payload.credential ?? ''), payload.prf as ArrayBuffer);
|
||||
case 'beginPasskeyLogin': return beginPasskeyLogin(String(payload.username ?? ''));
|
||||
case 'finishPasskeyLogin': return finishPasskeyLogin(String(payload.username ?? ''), id, String(payload.credential ?? ''), payload.prf as ArrayBuffer | undefined, payload.remember !== false);
|
||||
case 'unlockPasskeyLogin': return unlockPasskeyLogin(String(payload.password ?? ''), payload.remember !== false);
|
||||
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); return accountStatus();
|
||||
case 'accountDevices': return listAccountDevices();
|
||||
case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true;
|
||||
|
||||
@@ -2,24 +2,38 @@ import { importResources, importResourceDeletes, listAllResources, listResourceD
|
||||
import { apiFetch } from './http';
|
||||
import { clearResourceSessions, getSession } from './sessions';
|
||||
import { setAccountIdentity, requireAccount } from './accountSession';
|
||||
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys } from './accountCrypto';
|
||||
import { mergeVault, validateVault, validatePersonal, type VaultProfile, type PersonalProfile } from './accountVault';
|
||||
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto';
|
||||
import { mergeVault, validateVault, type VaultProfile } from './accountVault';
|
||||
import { joinChannel } from './channelResources';
|
||||
import { openChannelGrant, sealChannelGrant } from './channelGrant';
|
||||
import { recoverAccountChannels } from './accountChannels';
|
||||
import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence';
|
||||
import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api';
|
||||
|
||||
interface VaultBody { revision: number; vault: string }
|
||||
import { loadPersonal, personalProfile } from './accountProfile';
|
||||
import type { AccountProfile, AccountProfileRequest } from '../generated/api';
|
||||
|
||||
import type { PersonalProfile } from '../protocol/account';
|
||||
import { passkeyUnlockUsername, clearPasskeyState } from './passkeys';
|
||||
import { packageInvitationDomains } from './channelDataKeys';
|
||||
import { clearDeviceSigner, ensureDeviceSigning } from './accountSigning';
|
||||
|
||||
type VaultBody = AccountVaultBody;
|
||||
interface AccountSession {
|
||||
username: string; token: string; key: CryptoKey; profile: VaultProfile; revision: number;
|
||||
remember: boolean;
|
||||
deviceSigning?: CryptoKeyPair;
|
||||
remember: boolean; personal?: PersonalProfile;
|
||||
controller: AbortController; dirty: boolean; syncing?: Promise<void>; error?: string;
|
||||
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
export interface Invitation { channel_id: string; username: string; payload: string; created_at: number }
|
||||
export type Invitation = InvitationBody;
|
||||
let account: AccountSession | undefined;
|
||||
export function unlockedAccount() {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
return account;
|
||||
}
|
||||
let restoration: Promise<void> | undefined;
|
||||
function usernameOf(input: string): string {
|
||||
export function usernameOf(input: string): string {
|
||||
const username = input.trim().toLowerCase();
|
||||
if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new Error('用户名需为 3–32 位字母、数字、下划线或短横线');
|
||||
return username;
|
||||
@@ -33,7 +47,7 @@ async function applyProfile(profile: VaultProfile): Promise<void> {
|
||||
await importResourceDeletes(profile.deletes);
|
||||
}
|
||||
async function persistSession(session: AccountSession, value: VaultBody): Promise<void> {
|
||||
if (session.remember) await rememberAccount({ username: session.username, token: session.token, key: session.key, ...value });
|
||||
if (session.remember) await rememberAccount({ username: session.username, token: session.token, key: session.key, personal: session.personal, ...value });
|
||||
else await forgetAccount();
|
||||
}
|
||||
async function syncVault(): Promise<void> {
|
||||
@@ -41,6 +55,7 @@ async function syncVault(): Promise<void> {
|
||||
const session = account;
|
||||
if (session.syncing) { session.dirty = true; return session.syncing; }
|
||||
session.syncing = (async () => {
|
||||
session.personal = await loadPersonal(session.token);
|
||||
await recoverAccountChannels(session.username, session.profile.keys, session.controller.signal);
|
||||
do {
|
||||
session.dirty = false;
|
||||
@@ -48,11 +63,14 @@ async function syncVault(): Promise<void> {
|
||||
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);
|
||||
const remoteSigning = profile.keys.signing.publicKey; const localSigning = session.profile.keys.signing.publicKey;
|
||||
if (remoteSigning.x !== localSigning.x || remoteSigning.y !== localSigning.y)
|
||||
throw new Error('账号签名密钥发生冲突,已停止同步');
|
||||
const merged = mergeVault(profile, await listAllResources(), await listResourceDeletes());
|
||||
if (JSON.stringify(merged) === JSON.stringify(profile)) {
|
||||
stillActive(session); await applyProfile(merged);
|
||||
session.profile = merged; session.revision = remote.revision; session.error = undefined;
|
||||
setAccountIdentity(session, merged.keys.publicKey, merged.personal);
|
||||
setAccountIdentity(session, merged.keys.publicKey, session.personal);
|
||||
await persistSession(session, remote);
|
||||
break;
|
||||
}
|
||||
@@ -64,7 +82,7 @@ async function syncVault(): Promise<void> {
|
||||
stillActive(session);
|
||||
await applyProfile(merged);
|
||||
session.profile = merged; session.revision = result.revision; session.error = undefined;
|
||||
setAccountIdentity(session, merged.keys.publicKey, merged.personal);
|
||||
setAccountIdentity(session, merged.keys.publicKey, session.personal);
|
||||
await persistSession(session, { vault, revision: result.revision });
|
||||
break;
|
||||
} catch (error) {
|
||||
@@ -75,6 +93,7 @@ async function syncVault(): Promise<void> {
|
||||
})().catch((error: unknown) => {
|
||||
if (account === session) session.error = error instanceof Error ? error.message : '同步失败';
|
||||
if (account === session && error instanceof Error && 'status' in error && error.status === 401) {
|
||||
clearDeviceSigner();
|
||||
account = undefined; clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort();
|
||||
setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null);
|
||||
void forgetAccount();
|
||||
@@ -85,11 +104,17 @@ async function syncVault(): Promise<void> {
|
||||
return session.syncing;
|
||||
}
|
||||
|
||||
async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true): Promise<AccountSession> {
|
||||
async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody, remember = true, cachedPersonal?: PersonalProfile): Promise<AccountSession> {
|
||||
const profile = validateVault(await openJson(remote.vault, key));
|
||||
const deviceSigning = await ensureDeviceSigning(identity.username, identity.token, profile.keys.signing);
|
||||
clearResourceSessions(); setResourceScope(identity.username);
|
||||
const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false };
|
||||
account = session; setAccountIdentity(identity, profile.keys.publicKey, profile.personal);
|
||||
const session: AccountSession = { ...identity, key, profile, revision: remote.revision, remember, controller: new AbortController(), dirty: false, deviceSigning };
|
||||
try { session.personal = await loadPersonal(identity.token); }
|
||||
catch (error) {
|
||||
if (!(error instanceof Error && error.message === '网络连接失败')) throw error;
|
||||
session.personal = cachedPersonal;
|
||||
}
|
||||
account = session; setAccountIdentity(identity, profile.keys.publicKey, session.personal);
|
||||
await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes()));
|
||||
setResourceChangeListener(scheduleSync);
|
||||
session.polling = setInterval(scheduleSync, 20_000);
|
||||
@@ -108,10 +133,8 @@ export function restoreAccount(): Promise<void> {
|
||||
remote = saved;
|
||||
}
|
||||
try {
|
||||
const session = await activate(saved, saved.key, remote);
|
||||
const local = validateVault(await openJson(saved.vault, saved.key));
|
||||
if (local.personal && local.personal.modifiedAt > (session.profile.personal?.modifiedAt ?? -1)) session.profile = { ...session.profile, personal: local.personal };
|
||||
setAccountIdentity(session, session.profile.keys.publicKey, session.profile.personal);
|
||||
const session = await activate(saved, saved.key, remote, true, saved.personal);
|
||||
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
|
||||
await syncVault().catch(() => {});
|
||||
} catch (error) { await logoutAccount(); throw error; }
|
||||
})().catch((error: unknown) => { restoration = undefined; throw error; });
|
||||
@@ -129,10 +152,12 @@ export async function registerAccount(input: string, password: string, remember
|
||||
if (password.length < 12 || password.length > 1024) throw new Error('密码长度需为 12–1024 个字符');
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const { verifier, key } = await deriveAccountKeys(password, salt);
|
||||
const profile: VaultProfile = { v: 2, keys: await generateInvitationKeys(), resources: [], deletes: [] };
|
||||
const invitationKeys = await generateInvitationKeys(); const signing = await generateAccountSigningKeys();
|
||||
const profile: VaultProfile = { v: 3, keys: { ...invitationKeys, signing }, resources: [], deletes: [] };
|
||||
try {
|
||||
await apiFetch('/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey), vault: await sealJson(profile, key) }) });
|
||||
body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey),
|
||||
signing_public_key: JSON.stringify(signing.publicKey), vault: await sealJson(profile, key) } satisfies AccountRegisterRequest) });
|
||||
} finally { verifier.fill(0); }
|
||||
return loginAccount(username, password, remember);
|
||||
}
|
||||
@@ -140,22 +165,31 @@ export async function loginAccount(input: string, password: string, remember = t
|
||||
if (account) throw new Error('请先退出当前账号');
|
||||
const username = usernameOf(input);
|
||||
const challenge = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) })
|
||||
.then((r) => r.json() as Promise<{ salt: string }>);
|
||||
.then((r) => r.json() as Promise<AccountSaltBody>);
|
||||
const { verifier, key } = await deriveAccountKeys(password, unb64(challenge.salt));
|
||||
let identity: { username: string; token: string };
|
||||
let identity: AccountSessionBody;
|
||||
try {
|
||||
const device = await localAccountDevice();
|
||||
identity = await apiFetch('/accounts/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, verifier: b64(verifier), device_id: device.id, device_name: device.name }) })
|
||||
.then((r) => r.json() as Promise<{ username: string; token: string }>);
|
||||
identity = await apiFetch('/accounts/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, verifier: b64(verifier), device_id: device.id, device_name: device.name } satisfies AccountSessionRequest) })
|
||||
.then((r) => r.json() as Promise<AccountSessionBody>);
|
||||
} finally { verifier.fill(0); }
|
||||
const status = await unlockAuthenticatedAccount(identity, key, remember);
|
||||
if (remember) await saveLoginFields(username, password); else await forgetLoginFields();
|
||||
return status;
|
||||
}
|
||||
export async function unlockAuthenticatedAccount(identity: AccountSessionBody, key: CryptoKey, remember = true): Promise<Awaited<ReturnType<typeof accountStatus>>> {
|
||||
if (account) throw new Error('请先退出当前账号');
|
||||
try {
|
||||
const remote = await apiFetch('/accounts/vault', { headers: headers(identity) }).then((r) => r.json() as Promise<VaultBody>);
|
||||
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 publicKey = await apiFetch('/accounts/users/' + identity.username + '/key', { headers: headers(identity) }).then(r => r.json() as Promise<PublicKeyBody>);
|
||||
const published = JSON.parse(publicKey.public_key) as JsonWebKey;
|
||||
if (published.x !== profile.keys.publicKey.x || published.y !== profile.keys.publicKey.y) throw new Error('账号邀请公钥与保险库不匹配');
|
||||
if (!publicKey.signing_public_key) throw new Error('账号签名公钥不存在。');
|
||||
const publishedSigning = JSON.parse(publicKey.signing_public_key) as JsonWebKey;
|
||||
if (publishedSigning.x !== profile.keys.signing.publicKey.x || publishedSigning.y !== profile.keys.signing.publicKey.y)
|
||||
throw new Error('账号签名公钥与保险库不匹配');
|
||||
const session = await activate(identity, key, remote, remember);
|
||||
if (remember) await saveLoginFields(username, password); else await forgetLoginFields();
|
||||
await persistSession(session, remote);
|
||||
await syncVault().catch(() => {});
|
||||
return accountStatus();
|
||||
@@ -165,13 +199,15 @@ export async function loginAccount(input: string, password: string, remember = t
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string }> {
|
||||
export async function accountStatus(): Promise<{ username: string | null; revision: number; remembered: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }> {
|
||||
return { username: account?.username ?? null, revision: account?.revision ?? 0,
|
||||
remembered: account?.remember ?? false,
|
||||
...(account?.profile.personal ? { personal: account.profile.personal } : {}),
|
||||
remembered: account?.remember ?? false, unlockUsername: passkeyUnlockUsername(),
|
||||
...(account?.personal ? { personal: account.personal } : {}),
|
||||
...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) };
|
||||
}
|
||||
export async function logoutAccount(): Promise<void> {
|
||||
clearDeviceSigner();
|
||||
clearPasskeyState();
|
||||
const session = account; account = undefined;
|
||||
if (session) { clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); await session.syncing?.catch(() => {}); }
|
||||
setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null);
|
||||
@@ -192,13 +228,15 @@ export async function clearSavedLogin(): Promise<void> {
|
||||
}
|
||||
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 persistSession(account, { vault: await sealJson(account.profile, account.key), revision: account.revision });
|
||||
await syncVault();
|
||||
const session = account;
|
||||
const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName, avatar } satisfies AccountProfileRequest) }).then(r => r.json() as Promise<AccountProfile>);
|
||||
stillActive(session);
|
||||
session.personal = personalProfile(value);
|
||||
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
|
||||
await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision });
|
||||
}
|
||||
export async function listAccountDevices(): Promise<unknown> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); }
|
||||
export async function listAccountDevices(): Promise<AccountDeviceBody[]> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json() as Promise<AccountDeviceBody[]>); }
|
||||
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());
|
||||
@@ -221,9 +259,12 @@ export async function acceptInvitation(channelId: string): Promise<void> {
|
||||
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 plan = await apiFetch(`/channels/${channelId}/invitations/${username}/crypto-plan`, { headers: session.authHeaders('owner') })
|
||||
.then(r => r.json() as Promise<InvitationCryptoPlan>);
|
||||
const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<PublicKeyBody>);
|
||||
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey);
|
||||
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload }) });
|
||||
const domainEnvelopes = await packageInvitationDomains(resource, session, plan);
|
||||
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) });
|
||||
}
|
||||
export async function listChannelInvitations(channelId: string): Promise<Invitation[]> {
|
||||
const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise<Invitation[]>);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { getResource, importResources, listAllResources, listResourceDeletes, type SavedResource } from '../database';
|
||||
import { openChannelGrant, sealChannelGrant } from './channelGrant';
|
||||
import { getResource, importResources, listResourceDeletes, type SavedResource } from '../database';
|
||||
import { openChannelGrant } from './channelGrant';
|
||||
import { makeLink } from './links';
|
||||
import { apiFetch, readJson } from './http';
|
||||
import type { InvitationKeyPair } from './accountCrypto';
|
||||
import type { ChannelWire } from './wire';
|
||||
|
||||
interface AccountChannel { channel_id: string; role: 'owner' | 'member'; payload: string; created_at: number }
|
||||
import type { AccountChannelBody } from '../generated/api';
|
||||
import { channelCache } from './resourceMetadata';
|
||||
|
||||
export async function recoverAccountChannels(username: string, keys: InvitationKeyPair, signal: AbortSignal): Promise<void> {
|
||||
const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise<AccountChannel[]>);
|
||||
const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise<AccountChannelBody[]>);
|
||||
const deletes = new Map((await listResourceDeletes()).map((row) => [row.id, row.modifiedAt]));
|
||||
const known = new Set(grants.map((row) => row.channel_id));
|
||||
for (const grant of grants) {
|
||||
if (grant.role !== 'owner' && grant.role !== 'member') throw new Error('空间成员权限无效');
|
||||
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
|
||||
if ((deletes.get(grant.channel_id) ?? -1) >= grant.created_at || await getResource(grant.channel_id)) continue;
|
||||
const parsed = await openChannelGrant(grant.payload, username, grant.channel_id, keys.privateKey, grant.role, self.location.origin);
|
||||
@@ -21,25 +21,13 @@ export async function recoverAccountChannels(username: string, keys: InvitationK
|
||||
const info = await readJson<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,
|
||||
const resource: SavedResource = { ...channelCache(info), id: session.id, kind: 'channel', mode: session.mode, role: grant.role,
|
||||
creatorAccount: info.creator_account ?? undefined, name: info.settings.name ?? undefined,
|
||||
link: session.link(self.location.origin), ...(grant.role === 'owner' ? { ownerLink: session.link(self.location.origin, 'owner') } : {}),
|
||||
endpointSecret: new Uint8Array(32), createdAt: info.created_at, modifiedAt: grant.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, maxDownloads: info.settings.file_max_downloads };
|
||||
seats: info.settings.seats, maxDownloads: info.settings.file_max_downloads };
|
||||
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
|
||||
await importResources([resource]);
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
// Populate owner grants for channels created before account-bound delivery existed.
|
||||
for (const resource of await listAllResources()) {
|
||||
if (resource.kind !== 'channel' || resource.role !== 'owner' || !resource.ownerLink || known.has(resource.id)) continue;
|
||||
const session = await ChannelSession.open(resource.ownerLink, { endpointSecret: resource.endpointSecret });
|
||||
try {
|
||||
const payload = await sealChannelGrant(resource.ownerLink, username, keys.publicKey);
|
||||
await apiFetch(`/channels/${resource.id}/key-package`, { method: 'PUT', signal,
|
||||
headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ payload }) });
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'status' in error && error.status === 404)) throw error;
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,19 @@ export function b64(value: Uint8Array): string {
|
||||
return btoa(text);
|
||||
}
|
||||
export function unb64(value: string): Uint8Array { return Uint8Array.from(atob(value), (c) => c.charCodeAt(0)); }
|
||||
export async function deriveAccountKeys(password: string, salt: Uint8Array): Promise<{ verifier: Uint8Array; key: CryptoKey }> {
|
||||
export async function deriveAccountKeys(password: string, salt: Uint8Array, exportMaterial = false): Promise<{ verifier: Uint8Array; key: CryptoKey; material?: Uint8Array }> {
|
||||
if (salt.length !== 16 || password.length > 1024) throw new Error('账号凭据格式无效');
|
||||
const base = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits']);
|
||||
const root = new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: bytes(salt), iterations: 600_000 }, base, 256));
|
||||
try {
|
||||
const hmac = await crypto.subtle.importKey('raw', root, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const verifier = new Uint8Array(await crypto.subtle.sign('HMAC', hmac, encoder.encode('webdrop-account-login-v1')));
|
||||
const hkdf = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveKey']);
|
||||
const key = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: bytes(salt), info: encoder.encode(vaultLabel) }, hkdf,
|
||||
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
return { verifier, key };
|
||||
const hkdf = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveBits']);
|
||||
const material = new Uint8Array(await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: bytes(salt), info: encoder.encode(vaultLabel) }, hkdf, 256));
|
||||
try {
|
||||
const key = await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
return { verifier, key, ...(exportMaterial ? { material: Uint8Array.from(material) } : {}) };
|
||||
} finally { material.fill(0); }
|
||||
} finally { root.fill(0); }
|
||||
}
|
||||
export async function sealJson(value: unknown, key: CryptoKey, context = vaultLabel): Promise<string> {
|
||||
@@ -36,11 +38,18 @@ export async function openJson(value: string, key: CryptoKey, context = vaultLab
|
||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plain));
|
||||
} catch { throw new Error('加密数据校验失败,已拒绝导入'); }
|
||||
}
|
||||
export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
|
||||
export type { InvitationKeyPair } from '../protocol/account';
|
||||
import type { InvitationKeyPair } from '../protocol/account';
|
||||
import type { SigningKeyPair } from '../protocol/account';
|
||||
import { generateSigningKeyPair } from '@webdrop/core';
|
||||
export async function generateInvitationKeys(): Promise<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) };
|
||||
}
|
||||
export async function generateAccountSigningKeys(): Promise<SigningKeyPair> {
|
||||
const pair = await generateSigningKeyPair(true);
|
||||
return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), privateKey: await crypto.subtle.exportKey('jwk', pair.privateKey) };
|
||||
}
|
||||
async function invitationKey(privateKey: JsonWebKey, publicKey: JsonWebKey, context: string): Promise<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, []);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { openDB, type DBSchema } from 'idb';
|
||||
import { apiPath } from './http';
|
||||
import type { PersonalProfile } from '../protocol/account';
|
||||
import { decryptLogin, encryptLogin, type EncryptedLogin, type LoginFields } from './credentialCipher';
|
||||
|
||||
export interface RememberedAccount {
|
||||
personal?: PersonalProfile;
|
||||
username: string;
|
||||
token: string;
|
||||
key: CryptoKey;
|
||||
@@ -13,10 +15,13 @@ interface LocalIdentity extends DBSchema {
|
||||
credentials: { key: string; value: EncryptedLogin };
|
||||
sessions: { key: string; value: RememberedAccount };
|
||||
device: { key: string; value: { id: string; name: string } };
|
||||
deviceSigning: { key: string; value: CryptoKeyPair };
|
||||
trustedSigningRoots: { key: string; value: JsonWebKey };
|
||||
}
|
||||
const database = () => openDB<LocalIdentity>('webdrop-account-local-2', 2, { upgrade(db, oldVersion) {
|
||||
const database = () => openDB<LocalIdentity>('webdrop-account-local-2', 3, { upgrade(db, oldVersion) {
|
||||
if (oldVersion < 1) { db.createObjectStore('sessions'); db.createObjectStore('device'); }
|
||||
if (oldVersion < 2) db.createObjectStore('credentials');
|
||||
if (oldVersion < 3) { db.createObjectStore('deviceSigning'); db.createObjectStore('trustedSigningRoots'); }
|
||||
} });
|
||||
const endpoint = () => apiPath('/');
|
||||
export async function rememberedAccount(): Promise<RememberedAccount | undefined> { return (await database()).get('sessions', endpoint()); }
|
||||
@@ -43,3 +48,18 @@ export async function localAccountDevice(): Promise<{ id: string; name: string }
|
||||
export async function saveDeviceName(name: string): Promise<void> {
|
||||
const device = await localAccountDevice(); await (await database()).put('device', { ...device, name }, endpoint());
|
||||
}
|
||||
export async function localDeviceSigningKeys(username: string, deviceId: string): Promise<CryptoKeyPair | undefined> {
|
||||
return (await database()).get('deviceSigning', `${endpoint()}:${username}:${deviceId}`);
|
||||
}
|
||||
export async function saveLocalDeviceSigningKeys(username: string, deviceId: string, keys: CryptoKeyPair): Promise<void> {
|
||||
await (await database()).put('deviceSigning', keys, `${endpoint()}:${username}:${deviceId}`);
|
||||
}
|
||||
export async function pinSigningRoot(username: string, key: JsonWebKey): Promise<void> {
|
||||
const databaseRef = await database(); const tx = databaseRef.transaction('trustedSigningRoots', 'readwrite');
|
||||
const dbKey = `${endpoint()}:${username}`; const existing = await tx.store.get(dbKey);
|
||||
if (existing && (existing.kty !== key.kty || existing.crv !== key.crv || existing.x !== key.x || existing.y !== key.y)) {
|
||||
tx.abort(); throw new Error('账号签名公钥已变化,已阻止静默替换;请通过可信渠道核对指纹。');
|
||||
}
|
||||
if (!existing) await tx.store.put(key, dbKey);
|
||||
await tx.done;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { apiFetch } from './http';
|
||||
import type { AccountProfile } from '../generated/api';
|
||||
import type { PersonalProfile } from '../protocol/account';
|
||||
|
||||
export function personalProfile(value: AccountProfile): PersonalProfile {
|
||||
return { displayName: value.displayName, avatar: value.avatar ?? undefined, modifiedAt: value.modifiedAt };
|
||||
}
|
||||
export async function loadPersonal(token: string): Promise<PersonalProfile> {
|
||||
const headers = { 'X-WD-Account': token };
|
||||
const profile = await apiFetch('/accounts/profile', { headers }).then(r => r.json() as Promise<AccountProfile>);
|
||||
return personalProfile(profile);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PersonalProfile } from './accountVault';
|
||||
import type { PersonalProfile } from '../protocol/account';
|
||||
let identity: { username: string; token: string } | undefined;
|
||||
let publicKey: JsonWebKey | undefined;
|
||||
let personal: PersonalProfile | undefined;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { contentEventPayload, frameManifestHash, fromBase64Url, generateSigningKeyPair, signStatement, toBase64Url, verifyStatement } from '@webdrop/core';
|
||||
import type { AccountDeviceSigningRequest } from '../generated/api';
|
||||
import { b64 } from './accountCrypto';
|
||||
import { apiFetch } from './http';
|
||||
import { localAccountDevice, localDeviceSigningKeys, pinSigningRoot, saveLocalDeviceSigningKeys } from './accountPersistence';
|
||||
import type { SigningKeyPair } from '../protocol/account';
|
||||
|
||||
interface ActiveSigner { username: string; deviceId: string; pair: CryptoKeyPair }
|
||||
let activeSigner: ActiveSigner | undefined;
|
||||
|
||||
function certificatePayload(username: string, deviceId: string, key: JsonWebKey): Uint8Array {
|
||||
const name = new TextEncoder().encode(username.toLowerCase());
|
||||
const device = new TextEncoder().encode(deviceId.toLowerCase());
|
||||
if (name.length < 3 || name.length > 32 || device.length !== 36 || !/^[a-z0-9_-]+$/.test(username.toLowerCase())
|
||||
|| !/^[0-9a-f-]{36}$/.test(deviceId.toLowerCase()) || !key.x || !key.y)
|
||||
throw new Error('设备签名证书字段无效');
|
||||
const x = fromBase64Url(key.x); const y = fromBase64Url(key.y);
|
||||
if (!x || !y || x.length !== 32 || y.length !== 32) throw new Error('设备签名公钥格式无效');
|
||||
const payload = new Uint8Array(1 + name.length + 1 + device.length + 64);
|
||||
let offset = 0;
|
||||
payload[offset++] = name.length; payload.set(name, offset); offset += name.length;
|
||||
payload[offset++] = device.length; payload.set(device, offset); offset += device.length;
|
||||
payload.set(x, offset); payload.set(y, offset + 32);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function ensureDeviceSigning(
|
||||
username: string, accountToken: string, root: SigningKeyPair,
|
||||
): Promise<CryptoKeyPair> {
|
||||
await pinSigningRoot(username, root.publicKey);
|
||||
const device = await localAccountDevice();
|
||||
let pair = await localDeviceSigningKeys(username, device.id);
|
||||
if (!pair) {
|
||||
pair = await generateSigningKeyPair();
|
||||
await saveLocalDeviceSigningKeys(username, device.id, pair);
|
||||
}
|
||||
const publicKey = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
||||
const rootPrivateKey = await crypto.subtle.importKey('jwk', root.privateKey,
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
||||
const signature = await signStatement(rootPrivateKey, 'device.certificate', certificatePayload(username, device.id, publicKey));
|
||||
const request: AccountDeviceSigningRequest = {
|
||||
device_id: device.id,
|
||||
public_key: JSON.stringify(publicKey),
|
||||
signature: b64(signature),
|
||||
};
|
||||
await apiFetch('/accounts/devices/signing-key', { method: 'PUT',
|
||||
headers: { 'X-WD-Account': accountToken, 'Content-Type': 'application/json' }, body: JSON.stringify(request) });
|
||||
activeSigner = { username: username.toLowerCase(), deviceId: device.id.toLowerCase(), pair };
|
||||
return pair;
|
||||
}
|
||||
|
||||
export function clearDeviceSigner(): void { activeSigner = undefined; }
|
||||
|
||||
export async function signContentEvent(input: {
|
||||
spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string;
|
||||
eventKind: string; targetItemId?: string; frameDigests: readonly Uint8Array[];
|
||||
}): Promise<Record<string, string>> {
|
||||
return signContentEventMetadata({ ...input, frameCount: input.frameDigests.length, manifestHash: await frameManifestHash(input.frameDigests) });
|
||||
}
|
||||
|
||||
export async function signExistingContentEvent(input: {
|
||||
spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string;
|
||||
eventKind: string; frameCount: number; manifestHash: Uint8Array; targetItemId?: string;
|
||||
}): Promise<Record<string, string>> {
|
||||
return signContentEventMetadata(input);
|
||||
}
|
||||
|
||||
async function signContentEventMetadata(input: {
|
||||
spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string;
|
||||
eventKind: string; frameCount: number; manifestHash: Uint8Array; targetItemId?: string;
|
||||
}): Promise<Record<string, string>> {
|
||||
const signer = activeSigner;
|
||||
if (!signer) throw new Error('当前账号设备没有已注册的签名密钥');
|
||||
const spaceId = fromBase64Url(input.spaceId, 16); const itemId = fromBase64Url(input.itemId, 16);
|
||||
const targetItemId = input.targetItemId ? fromBase64Url(input.targetItemId, 16) : undefined;
|
||||
if (!spaceId || !itemId || (input.targetItemId && !targetItemId)) throw new Error('签名事件资源 ID 无效');
|
||||
const eventId = crypto.getRandomValues(new Uint8Array(16));
|
||||
const payload = contentEventPayload({ spaceId, channelId: input.channelId, username: signer.username, deviceId: signer.deviceId,
|
||||
eventId, eventKind: input.eventKind, domain: input.domain, epoch: input.epoch, itemId,
|
||||
frameCount: input.frameCount, manifestHash: input.manifestHash, ...(targetItemId ? { targetItemId } : {}) });
|
||||
const signature = await signStatement(signer.pair.privateKey, 'content.event', payload);
|
||||
return {
|
||||
'X-WD-Event-Id': toBase64Url(eventId), 'X-WD-Event-Kind': input.eventKind,
|
||||
'X-WD-Event-Channel': input.channelId, 'X-WD-Event-Domain': input.domain,
|
||||
'X-WD-Event-Epoch': String(input.epoch), 'X-WD-Event-Frames': String(input.frameCount),
|
||||
'X-WD-Event-Manifest': toBase64Url(input.manifestHash), 'X-WD-Event-Signature': toBase64Url(signature),
|
||||
...(input.targetItemId ? { 'X-WD-Event-Target': input.targetItemId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyDeviceCertificate(
|
||||
username: string, deviceId: string, publicKey: JsonWebKey, signature: Uint8Array, root: CryptoKey,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const payload = certificatePayload(username, deviceId, publicKey);
|
||||
return verifyStatement(root, 'device.certificate', payload, signature);
|
||||
} catch { return false; }
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { exportResource, mergeVault, validatePersonal, type VaultProfile } from './accountVault';
|
||||
import { exportResource, mergeVault, type VaultProfile } from './accountVault';
|
||||
import type { SavedResource } from '../database';
|
||||
const resource = (modifiedAt = 10): SavedResource => ({ id: 'AAAAAAAAAAAAAAAAAAAAAA', kind: 'channel', mode: 'enc', role: 'owner',
|
||||
link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), deviceLabel: 'device-a', localScope: 'account:alice',
|
||||
createdAt: 1, modifiedAt, expiresAt: 100, seats: 3, membersCanUpload: true, name: `version-${modifiedAt}` });
|
||||
const profile = (): VaultProfile => ({ v: 2, keys: { publicKey: {}, privateKey: {} }, resources: [exportResource(resource(20))], deletes: [] });
|
||||
createdAt: 1, modifiedAt, expiresAt: 100, seats: 3, name: `version-${modifiedAt}` });
|
||||
const profile = (): VaultProfile => ({ v: 3, keys: { publicKey: {}, privateKey: {}, signing: { publicKey: {}, privateKey: {} } }, resources: [exportResource(resource(20))], deletes: [] });
|
||||
describe('account vault portability and merge', () => {
|
||||
it('never synchronizes device identity or local scope', () => {
|
||||
const exported = exportResource(resource());
|
||||
@@ -24,12 +24,5 @@ describe('account vault portability and merge', () => {
|
||||
const merged = mergeVault(remote, [resource(40)], []);
|
||||
expect(merged.resources).toHaveLength(1); expect(merged.deletes).toEqual([]);
|
||||
});
|
||||
it('keeps the newest personal profile across stale devices, including avatar removal', () => {
|
||||
const remote = { ...profile(), personal: { displayName: '最新名称', avatar: 'data:image/png;base64,AA==', modifiedAt: 20 } };
|
||||
expect(mergeVault(remote, [], [], { displayName: '旧名称', modifiedAt: 10 }).personal).toEqual(remote.personal);
|
||||
expect(mergeVault(remote, [], [], { displayName: '新名称', modifiedAt: 30 }).personal).not.toHaveProperty('avatar');
|
||||
});
|
||||
it('rejects remote executable and external avatars', () => {
|
||||
for (const avatar of ['https://tracker.test/avatar.png', 'data:image/svg+xml;base64,AA==']) expect(() => validatePersonal({ displayName: 'Alice', avatar, modifiedAt: 1 })).toThrow();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,37 +1,36 @@
|
||||
import type { SavedResource } from '../database';
|
||||
import type { InvitationKeyPair } from './accountCrypto';
|
||||
export interface ResourceDelete { id: string; modifiedAt: number }
|
||||
export type VaultResource = Omit<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 }
|
||||
import type { ResourceDelete, VaultResource, VaultProfile } from '../protocol/account';
|
||||
export type { ResourceDelete, VaultResource, VaultProfile };
|
||||
export function exportResource(resource: SavedResource): VaultResource {
|
||||
const entry = { ...resource };
|
||||
delete entry.localScope; delete entry.localItems; delete entry.deviceLabel; delete entry.needsJoin; delete entry.pendingManifestId;
|
||||
const { endpointSecret: _endpointSecret, ...portable } = entry;
|
||||
return portable;
|
||||
// Explicit protocol projection prevents browser draft/UI fields leaking into the vault.
|
||||
return { id: resource.id, kind: resource.kind, mode: resource.mode, role: resource.role,
|
||||
domainKeys: resource.domainKeys,
|
||||
link: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
|
||||
modifiedAt: resource.modifiedAt, creatorAccount: resource.creatorAccount, name: resource.name,
|
||||
seats: resource.seats, ttlSeconds: resource.ttlSeconds,
|
||||
maxDownloads: resource.maxDownloads, maxVisitors: resource.maxVisitors, mine: resource.mine, state: resource.state };
|
||||
}
|
||||
export function validateVault(value: unknown): VaultProfile {
|
||||
if (!value || typeof value !== 'object') throw new Error('账号保险库格式无效');
|
||||
const profile = value as VaultProfile;
|
||||
if (profile.v !== 2 || !profile.keys?.privateKey?.d || profile.keys.privateKey.crv !== 'P-256'
|
||||
if (profile.v !== 3) throw new Error('旧账号保险库版本不兼容 API 6,且不会自动迁移。');
|
||||
if (!profile.keys?.privateKey?.d || profile.keys.privateKey.crv !== 'P-256'
|
||||
|| !profile.keys.publicKey?.x || profile.keys.publicKey.d || !Array.isArray(profile.resources) || !Array.isArray(profile.deletes))
|
||||
throw new Error('账号保险库格式无效');
|
||||
if (!profile.keys.signing || profile.keys.signing.privateKey?.crv !== 'P-256'
|
||||
|| !profile.keys.signing.privateKey.d || profile.keys.signing.privateKey.kty !== 'EC'
|
||||
|| profile.keys.signing.publicKey?.crv !== 'P-256' || !profile.keys.signing.publicKey.x
|
||||
|| !profile.keys.signing.publicKey.y || profile.keys.signing.publicKey.d !== undefined)
|
||||
throw new Error('账号签名密钥格式无效');
|
||||
for (const row of profile.resources) {
|
||||
if (!row || !/^[A-Za-z0-9_-]{22}$/.test(row.id) || !['channel', 'share'].includes(row.kind)
|
||||
|| typeof row.link !== 'string' || !['owner', 'member'].includes(row.role) || !Number.isFinite(row.createdAt)
|
||||
|| !Number.isFinite(row.expiresAt) || (row.modifiedAt !== undefined && !Number.isFinite(row.modifiedAt))) throw new Error('账号资源清单格式无效');
|
||||
}
|
||||
for (const row of profile.deletes) if (!row || !/^[A-Za-z0-9_-]{22}$/.test(row.id) || !Number.isFinite(row.modifiedAt)) throw new Error('账号资源清单格式无效');
|
||||
if (profile.personal) validatePersonal(profile.personal);
|
||||
return profile;
|
||||
return { v: profile.v, keys: profile.keys, resources: profile.resources, deletes: profile.deletes };
|
||||
}
|
||||
export function validatePersonal(value: PersonalProfile): PersonalProfile {
|
||||
if (typeof value.displayName !== 'string' || value.displayName.trim().length > 48 || /[\u0000-\u001f]/.test(value.displayName)
|
||||
|| !Number.isFinite(value.modifiedAt) || (value.avatar !== undefined && (typeof value.avatar !== 'string' || value.avatar.length > 350_000
|
||||
|| !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/.test(value.avatar)))) throw new Error('个人资料格式无效');
|
||||
return { ...value, displayName: value.displayName.trim() };
|
||||
}
|
||||
export function mergeVault(remote: VaultProfile, resources: SavedResource[], deletes: ResourceDelete[], personal?: PersonalProfile): VaultProfile {
|
||||
export function mergeVault(remote: VaultProfile, resources: SavedResource[], deletes: ResourceDelete[]): VaultProfile {
|
||||
const byId = new Map(remote.resources.map((entry) => [entry.id, entry]));
|
||||
const removed = new Map(remote.deletes.map((entry) => [entry.id, entry]));
|
||||
for (const row of resources) {
|
||||
@@ -43,6 +42,5 @@ export function mergeVault(remote: VaultProfile, resources: SavedResource[], del
|
||||
if ((removed.get(id)?.modifiedAt ?? -1) >= (row.modifiedAt ?? row.createdAt)) byId.delete(id);
|
||||
else removed.delete(id);
|
||||
}
|
||||
const newest = personal && personal.modifiedAt > (remote.personal?.modifiedAt ?? -1) ? personal : remote.personal;
|
||||
return { ...remote, ...(newest ? { personal: newest } : {}), resources: [...byId.values()], deletes: [...removed.values()] };
|
||||
return { v: remote.v, keys: remote.keys, resources: [...byId.values()], deletes: [...removed.values()] };
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { contentEventPayload, fromBase64Url, importSigningPublicKey, toBase64Url, verifyStatement, type ChannelSession } from '@webdrop/core';
|
||||
import type { CryptoState, DomainEnvelope, InvitationCryptoPlan, ObjectEnvelope, PublicKeyBody } from '../generated/api';
|
||||
import { getResource, saveResource, type SavedResource } from '../database';
|
||||
import { apiFetch } from './http';
|
||||
import { accountPublicKey, accountToken, requireAccount } from './accountSession';
|
||||
import { decryptInvitation, encryptInvitation } from './accountCrypto';
|
||||
import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto';
|
||||
import { loadWorkspace } from './workspaceStructure';
|
||||
import { pinSigningRoot } from './accountPersistence';
|
||||
import { signExistingContentEvent, verifyDeviceCertificate } from './accountSigning';
|
||||
|
||||
const objectKeys = new Map<string, CryptoKey>();
|
||||
const states = new Map<string, CryptoState>();
|
||||
const domainId = (channelId: string, domain: string, epoch: number) => `${channelId}:${domain}:${epoch}`;
|
||||
const contextId = (spaceId: string, channelId: string, domain: string, epoch: number) => `${spaceId}:${channelId}:${domain}:${epoch}`;
|
||||
export function clearDataKeys(): void { objectKeys.clear(); states.clear(); }
|
||||
export function attachDataKeys(session: ChannelSession, resource: SavedResource): void {
|
||||
if (resource.kind !== 'channel') return;
|
||||
session.objectKeyResolver = async itemId => {
|
||||
const cached = objectKeys.get(`${resource.id}:${itemId}`); if (cached) return cached;
|
||||
const { keys } = await readDataKeys(resource, session);
|
||||
const object = states.get(resource.id)?.objects.find(o => o.itemId === itemId);
|
||||
if (!object || object.domain !== 'chat' && object.domain !== 'files') throw new Error('该对象没有已授权的解密数据域');
|
||||
const key = keys[domainId(object.channelId, object.domain, object.epoch)];
|
||||
if (!key) throw new Error('未获得该频道数据域的访问授权');
|
||||
const unwrapped = await unwrapObjectKey(key, object.keyEnvelope, { spaceId: resource.id, channelId: object.channelId, domain: object.domain, epoch: object.epoch, itemId });
|
||||
objectKeys.set(`${resource.id}:${itemId}`, unwrapped); return unwrapped;
|
||||
};
|
||||
}
|
||||
export async function readDataKeys(resource: SavedResource, session: ChannelSession): Promise<{ state: CryptoState; keys: Record<string, string> }> {
|
||||
const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise<CryptoState>);
|
||||
states.set(resource.id, state);
|
||||
const latest = await getResource(resource.id); const keys = { ...latest?.domainKeys };
|
||||
for (const envelope of state.envelopes) {
|
||||
const { unlockedAccount } = await import('./account'); const account = unlockedAccount();
|
||||
const id = domainId(envelope.channelId, envelope.domain, envelope.epoch);
|
||||
if (keys[id]) continue;
|
||||
const plain = await decryptInvitation(envelope.payload, account.username, contextId(resource.id, envelope.channelId, envelope.domain, envelope.epoch), account.profile.keys.privateKey);
|
||||
const value = JSON.parse(plain) as { channelId: string; domain: string; epoch: number; key: string };
|
||||
if (value.channelId !== envelope.channelId || value.domain !== envelope.domain || value.epoch !== envelope.epoch || typeof value.key !== 'string') throw new Error('数据域密钥包与授权范围不匹配');
|
||||
keys[id] = value.key;
|
||||
}
|
||||
if (latest && JSON.stringify(keys) !== JSON.stringify(latest.domainKeys ?? {})) await saveResource({ ...latest, domainKeys: keys });
|
||||
return { state, keys };
|
||||
}
|
||||
async function envelope(spaceId: string, channelId: string, domain: 'chat' | 'files', epoch: number, key: string, username: string): Promise<DomainEnvelope> {
|
||||
const publicKey = username === requireAccount() ? accountPublicKey() : JSON.parse((await apiFetch(`/accounts/users/${username}/key`).then(r => r.json() as Promise<PublicKeyBody>)).public_key) as JsonWebKey;
|
||||
return { channelId, domain, epoch, username, payload: await encryptInvitation(JSON.stringify({ channelId, domain, epoch, key }), username, contextId(spaceId, channelId, domain, epoch), publicKey) };
|
||||
}
|
||||
export async function packageInvitationDomains(resource: SavedResource, session: ChannelSession, plan: InvitationCryptoPlan): Promise<DomainEnvelope[]> {
|
||||
if (plan.channelId !== (await loadWorkspace(resource.id)).structure.defaultChannelId) throw new Error('服务端授权计划的默认频道不匹配');
|
||||
if (resource.mode !== 'enc') {
|
||||
if (plan.batches.length) throw new Error('未加密空间收到加密授权计划');
|
||||
return [];
|
||||
}
|
||||
if (!session.can('owner')) throw new Error('只有空间主可以封装频道密钥');
|
||||
const { keys } = await readDataKeys(resource, session);
|
||||
const result: DomainEnvelope[] = [];
|
||||
for (const batch of plan.batches) {
|
||||
if (batch.domain !== 'chat' && batch.domain !== 'files' || batch.epoch < 1 || batch.recipients.length < 1 || new Set(batch.recipients).size !== batch.recipients.length)
|
||||
throw new Error('服务端加密授权计划格式无效');
|
||||
const keyId = domainId(plan.channelId, batch.domain, batch.epoch);
|
||||
let key = keys[keyId];
|
||||
if (!key) {
|
||||
if (!batch.rotate) throw new Error('缺少历史 epoch 密钥,不能为受邀成员重建或替换密钥');
|
||||
key = randomDomainKey(); keys[keyId] = key;
|
||||
const latest = await getResource(resource.id);
|
||||
if (!latest) throw new Error('空间本地密钥状态不存在');
|
||||
await saveResource({ ...latest, domainKeys: { ...latest.domainKeys, [keyId]: key } });
|
||||
}
|
||||
for (const username of batch.recipients) result.push(await envelope(resource.id, plan.channelId, batch.domain, batch.epoch, key, username));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export async function initializeChannelKeys(resource: SavedResource, session: ChannelSession, channelId: string): Promise<void> {
|
||||
if (resource.mode !== 'enc') return;
|
||||
const { structure } = await loadWorkspace(resource.id); const channel = structure.channels.find(c => c.id === channelId);
|
||||
if (!channel) throw new Error('频道不存在');
|
||||
if (!session.can('owner')) return;
|
||||
const owner = requireAccount();
|
||||
for (const domain of ['chat', 'files'] as const) {
|
||||
const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch;
|
||||
if (epoch > 0) continue;
|
||||
const key = randomDomainKey(); const latest = (await getResource(resource.id))!;
|
||||
const id = domainId(channelId, domain, 1);
|
||||
const existing = latest.domainKeys?.[id]; const selected = existing ?? key;
|
||||
if (!existing) await saveResource({ ...latest, domainKeys: { ...latest.domainKeys, [id]: selected } });
|
||||
const packaged = await envelope(resource.id, channelId, domain, 1, selected, owner);
|
||||
await apiFetch(`/spaces/${resource.id}/crypto/domains`, { method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channelId, domain, epoch: 1, envelopes: [packaged] }) });
|
||||
}
|
||||
await loadWorkspace(resource.id);
|
||||
}
|
||||
export interface PreparedObject { itemIdBytes: Uint8Array; itemId: string; channelId: string; domain: 'chat' | 'files'; epoch: number }
|
||||
export async function prepareObject(resource: SavedResource, session: ChannelSession, channelId: string, domain: 'chat' | 'files'): Promise<PreparedObject> {
|
||||
const idBytes = crypto.getRandomValues(new Uint8Array(16)); const itemId = toBase64Url(idBytes);
|
||||
const { structure } = await loadWorkspace(resource.id); let channel = structure.channels.find(c => c.id === channelId);
|
||||
if (!channel) throw new Error('请先创建频道');
|
||||
if (resource.mode === 'enc' && !(domain === 'chat' ? channel.chatEpoch : channel.filesEpoch)) {
|
||||
await initializeChannelKeys(resource, session, channelId);
|
||||
channel = (await loadWorkspace(resource.id)).structure.channels.find(c => c.id === channelId)!;
|
||||
}
|
||||
const epoch = domain === 'chat' ? channel.chatEpoch : channel.filesEpoch;
|
||||
let keyEnvelope = '';
|
||||
if (resource.mode === 'enc') {
|
||||
const { keys } = await readDataKeys(resource, session); const key = keys[domainId(channelId, domain, epoch)];
|
||||
if (!key) throw new Error('未获得该频道数据域的访问授权');
|
||||
const created = await newObjectKey(key, { spaceId: resource.id, channelId, domain, epoch, itemId }); keyEnvelope = created.envelope;
|
||||
objectKeys.set(`${resource.id}:${itemId}`, created.key);
|
||||
}
|
||||
const object = await apiFetch(`/spaces/${resource.id}/crypto/objects`, { method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemId, channelId, domain, epoch, keyEnvelope }) }).then(r => r.json() as Promise<ObjectEnvelope>);
|
||||
const state = states.get(resource.id); if (state) state.objects.push(object);
|
||||
return { itemIdBytes: idBytes, itemId, channelId, domain, epoch };
|
||||
}
|
||||
export async function canDecryptRecord(resource: SavedResource, session: ChannelSession, itemId: string): Promise<boolean> {
|
||||
if (resource.mode !== 'enc') return true;
|
||||
let state = states.get(resource.id); let object = state?.objects.find(o => o.itemId === itemId);
|
||||
let keys = (await getResource(resource.id))?.domainKeys ?? {};
|
||||
if (!object) { const refreshed = await readDataKeys(resource, session); state = refreshed.state; keys = refreshed.keys; object = state.objects.find(o => o.itemId === itemId); }
|
||||
if (!object) return false;
|
||||
return Boolean(keys[domainId(object.channelId, object.domain, object.epoch)]);
|
||||
}
|
||||
|
||||
export async function verifyObjectEvent(resource: SavedResource, session: ChannelSession, itemId: string, manifestHash: Uint8Array): Promise<boolean> {
|
||||
if (resource.mode === 'raw' && !accountToken()) return true;
|
||||
let state = states.get(resource.id);
|
||||
let object = state?.objects.find(row => row.itemId === itemId);
|
||||
if (!object?.event?.signature) {
|
||||
const refreshed = await readDataKeys(resource, session); state = refreshed.state;
|
||||
object = state.objects.find(row => row.itemId === itemId);
|
||||
}
|
||||
if (resource.mode === 'raw' && !object?.event?.signature) return true;
|
||||
const signed = object?.event;
|
||||
if (!object || !signed || !object.authorAccount || !signed.eventId || !signed.kind || !signed.frameCount
|
||||
|| !signed.manifestHash || !signed.signerDeviceId || !signed.signerRootPublicKey
|
||||
|| !signed.signerDevicePublicKey || !signed.deviceCertificateSignature || !signed.signature) return false;
|
||||
const expectedManifest = fromBase64Url(signed.manifestHash, 32);
|
||||
const spaceId = fromBase64Url(resource.id, 16); const eventId = fromBase64Url(signed.eventId, 16);
|
||||
const itemBytes = fromBase64Url(itemId, 16); const eventSignature = fromBase64Url(signed.signature, 64);
|
||||
const deviceCertificateSignature = fromBase64Url(signed.deviceCertificateSignature, 64);
|
||||
if (!expectedManifest || !spaceId || !eventId || !itemBytes || !eventSignature || !deviceCertificateSignature
|
||||
|| expectedManifest.length !== manifestHash.length || !expectedManifest.every((byte, index) => byte === manifestHash[index])) return false;
|
||||
const { unlockedAccount } = await import('./account'); const local = unlockedAccount();
|
||||
const rootJwk = JSON.parse(signed.signerRootPublicKey) as JsonWebKey;
|
||||
if (object.authorAccount === local.username && local.profile.keys.signing
|
||||
&& (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false;
|
||||
await pinSigningRoot(object.authorAccount, rootJwk);
|
||||
const root = await importSigningPublicKey(rootJwk);
|
||||
const deviceJwk = JSON.parse(signed.signerDevicePublicKey) as JsonWebKey;
|
||||
if (!await verifyDeviceCertificate(object.authorAccount, signed.signerDeviceId, deviceJwk, deviceCertificateSignature, root)) return false;
|
||||
const deviceKey = await importSigningPublicKey(deviceJwk);
|
||||
const domain = object.domain;
|
||||
if (domain !== 'chat' && domain !== 'files') return false;
|
||||
const targetItemId = signed.targetItemId ? fromBase64Url(signed.targetItemId, 16) : undefined;
|
||||
if (signed.targetItemId && !targetItemId) return false;
|
||||
const payload = contentEventPayload({ spaceId, channelId: object.channelId, username: object.authorAccount,
|
||||
deviceId: signed.signerDeviceId, eventId, eventKind: signed.kind, domain, epoch: object.epoch,
|
||||
itemId: itemBytes, frameCount: signed.frameCount, manifestHash: expectedManifest,
|
||||
...(targetItemId ? { targetItemId } : {}) });
|
||||
return verifyStatement(deviceKey, 'content.event', payload, eventSignature);
|
||||
}
|
||||
|
||||
export async function objectEventProjection(resource: SavedResource, session: ChannelSession, itemId: string): Promise<{ kind: string; targetItemId?: string } | undefined> {
|
||||
let state = states.get(resource.id); let object = state?.objects.find(row => row.itemId === itemId);
|
||||
if (!object) { state = (await readDataKeys(resource, session)).state; object = state.objects.find(row => row.itemId === itemId); }
|
||||
return object?.event?.signature && object.event.kind
|
||||
? { kind: object.event.kind, ...(object.event.targetItemId ? { targetItemId: object.event.targetItemId } : {}) }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export async function signObjectDeletion(resource: SavedResource, session: ChannelSession, itemId: string): Promise<Record<string, string>> {
|
||||
const { state } = await readDataKeys(resource, session);
|
||||
const object = state.objects.find(row => row.itemId === itemId);
|
||||
const signed = object?.event;
|
||||
if (!signed?.manifestHash || !signed.frameCount || (object?.domain !== 'chat' && object?.domain !== 'files'))
|
||||
throw new Error('对象缺少已验签的密文清单,不能生成删除事件');
|
||||
const manifestHash = fromBase64Url(signed.manifestHash, 32);
|
||||
if (!manifestHash) throw new Error('对象密文清单格式无效');
|
||||
return signExistingContentEvent({ spaceId: resource.id, channelId: object.channelId, domain: object.domain,
|
||||
epoch: object.epoch, itemId, eventKind: object.domain === 'chat' ? 'chat.delete' : 'file.delete',
|
||||
frameCount: signed.frameCount, manifestHash });
|
||||
}
|
||||
|
||||
export async function verifyObjectDeletion(resource: SavedResource, session: ChannelSession, itemId: string): Promise<boolean> {
|
||||
let state = states.get(resource.id); let object = state?.objects.find(row => row.itemId === itemId);
|
||||
if (!object?.deleteEvent?.signature) {
|
||||
const refreshed = await readDataKeys(resource, session); state = refreshed.state;
|
||||
object = state.objects.find(row => row.itemId === itemId);
|
||||
}
|
||||
const deleted = object?.deleteEvent;
|
||||
if (resource.mode === 'raw' && !deleted?.signature) return true;
|
||||
if (!object || !deleted?.signature || !deleted.eventId || !deleted.kind || !deleted.signerDeviceId
|
||||
|| !deleted.signerRootPublicKey || !deleted.signerDevicePublicKey || !deleted.deviceCertificateSignature
|
||||
|| !object.authorAccount || !object.event?.manifestHash || !object.event.frameCount || (object.domain !== 'chat' && object.domain !== 'files')) return false;
|
||||
const spaceId = fromBase64Url(resource.id, 16); const itemBytes = fromBase64Url(itemId, 16);
|
||||
const eventId = fromBase64Url(deleted.eventId, 16); const manifestHash = fromBase64Url(object.event.manifestHash, 32);
|
||||
const signature = fromBase64Url(deleted.signature, 64); const certificate = fromBase64Url(deleted.deviceCertificateSignature, 64);
|
||||
if (!spaceId || !itemBytes || !eventId || !manifestHash || !signature || !certificate) return false;
|
||||
const rootJwk = JSON.parse(deleted.signerRootPublicKey) as JsonWebKey;
|
||||
const deviceJwk = JSON.parse(deleted.signerDevicePublicKey) as JsonWebKey;
|
||||
const root = await importSigningPublicKey(rootJwk);
|
||||
const { unlockedAccount } = await import('./account'); const local = unlockedAccount();
|
||||
if (object.authorAccount === local.username && local.profile.keys.signing
|
||||
&& (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false;
|
||||
await pinSigningRoot(object.authorAccount, rootJwk);
|
||||
if (!await verifyDeviceCertificate(object.authorAccount, deleted.signerDeviceId, deviceJwk, certificate, root)) return false;
|
||||
const domain = object.domain;
|
||||
const expectedKind = domain === 'chat' ? 'chat.delete' : 'file.delete';
|
||||
if (deleted.kind !== expectedKind) return false;
|
||||
const payload = contentEventPayload({ spaceId, channelId: object.channelId, username: object.authorAccount,
|
||||
deviceId: deleted.signerDeviceId, eventId, eventKind: expectedKind, domain, epoch: object.epoch,
|
||||
itemId: itemBytes, frameCount: object.event.frameCount, manifestHash });
|
||||
const deviceKey = await importSigningPublicKey(deviceJwk);
|
||||
return verifyStatement(deviceKey, 'content.event', payload, signature);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { parseLogRecords, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core';
|
||||
import { getResource, saveResource, type SavedResource } from '../database';
|
||||
import { parseLogRecords, parseEnvelope, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core';
|
||||
import { canDecryptRecord, objectEventProjection, readDataKeys, verifyObjectDeletion, verifyObjectEvent } from './channelDataKeys';
|
||||
import { getResource, cacheResource, type SavedResource } from '../database';
|
||||
import { channelCache } from './resourceMetadata';
|
||||
import { loadWorkspace } from './workspaceStructure';
|
||||
import type { SeatInfo } from '../generated/api';
|
||||
import type { FileItem, Item, Snapshot } from '../types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { blobs, readers } from './sessions';
|
||||
import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation';
|
||||
import { type ChannelWire, toChannelInfo } from './wire';
|
||||
import { apiFetch, readJson } from './http';
|
||||
import { applyControl } from './channelThreads';
|
||||
import { readResourceRecord } from './workspaceRecords';
|
||||
import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog';
|
||||
|
||||
export function mapReceived(item: ReceivedItem): Item {
|
||||
@@ -30,6 +34,10 @@ export function mapReceived(item: ReceivedItem): Item {
|
||||
}
|
||||
|
||||
export async function applyChannelRecord(resource: SavedResource, session: ChannelSession, input: Uint8Array | LogRecord): Promise<void> {
|
||||
if (!(input instanceof Uint8Array)) {
|
||||
const itemId = input.kind === 'frame' ? toBase64Url(parseEnvelope(input.envelope).itemId) : input.itemId;
|
||||
if (!(await canDecryptRecord(resource, session, itemId))) return;
|
||||
}
|
||||
let reader = readers.get(resource.id);
|
||||
if (!reader) {
|
||||
reader = session.createItemReader();
|
||||
@@ -39,7 +47,15 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'item': {
|
||||
const control = applyControl(event.item);
|
||||
if (!event.item.complete || !event.item.ciphertextManifestHash
|
||||
|| !await verifyObjectEvent(resource, session, event.item.itemId, event.item.ciphertextManifestHash)) {
|
||||
showRejected(resource.id, { type: 'rejected', itemId: event.item.itemId, seq: event.item.seq, reason: 'auth_failed' });
|
||||
break;
|
||||
}
|
||||
const signedEvent = await objectEventProjection(resource, session, event.item.itemId);
|
||||
if (signedEvent?.kind === 'chat.edit' && signedEvent.targetItemId)
|
||||
publishWorkspace(resource.id, { t: 'removed', id: signedEvent.targetItemId });
|
||||
const control = readResourceRecord(event.item);
|
||||
if (control !== false) {
|
||||
if (control && control.t === 'file-catalog') {
|
||||
const key = `${resource.id}:${control.item.id}`;
|
||||
@@ -84,6 +100,10 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
break;
|
||||
}
|
||||
case 'removed':
|
||||
if (event.reason === 'deleted' && !await verifyObjectDeletion(resource, session, event.itemId)) {
|
||||
showRejected(resource.id, { type: 'rejected', itemId: event.itemId, seq: event.seq, reason: 'auth_failed' });
|
||||
break;
|
||||
}
|
||||
visibleFiles.delete(`${resource.id}:${event.itemId}`);
|
||||
fileCatalogs.delete(`${resource.id}:${event.itemId}`);
|
||||
blobs.delete(`${resource.id}:${event.itemId}`);
|
||||
@@ -101,19 +121,16 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
}
|
||||
|
||||
export async function channelMetadata(resource: SavedResource, session: ChannelSession): Promise<void> {
|
||||
const info = await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders('read'));
|
||||
const info = await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'));
|
||||
session.verifyChannelInfo(info);
|
||||
resource.name = info.settings.name;
|
||||
resource.membersCanUpload = info.settings.members_can_upload;
|
||||
resource.expiresAt = info.expires_at;
|
||||
const latest = await getResource(resource.id);
|
||||
await saveResource({ ...latest, ...resource, localItems: latest?.localItems });
|
||||
Object.assign(resource, await cacheResource(resource, channelCache(info)));
|
||||
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
|
||||
await loadWorkspace(resource.id);
|
||||
const ownId = toBase64Url(session.endpointHash);
|
||||
const ownName = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
|
||||
let devices = [deviceInfo(ownId, Date.now(), ownName, ownId)];
|
||||
if (session.can('owner')) {
|
||||
const seats = await readJson<Array<{ endpoint_hash: string; joined_at: number; label?: string }>>(
|
||||
const seats = await readJson<SeatInfo[]>(
|
||||
`/channels/${resource.id}/seats`, session.authHeaders('owner'));
|
||||
devices = await Promise.all(seats.map(async (seat) => {
|
||||
let name: string | undefined;
|
||||
@@ -128,6 +145,7 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
|
||||
}
|
||||
|
||||
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
|
||||
if (resource.mode === 'enc') await readDataKeys(resource, session);
|
||||
publishWorkspace(resource.id, { t: 'reset' });
|
||||
clearFileCatalog(resource.id);
|
||||
readers.set(resource.id, session.createItemReader());
|
||||
|
||||
@@ -21,17 +21,12 @@ export async function sealChannelGrant(link: string, recipient: string, publicKe
|
||||
export async function openChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey,
|
||||
role: 'owner' | 'member', origin: string): Promise<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);
|
||||
}
|
||||
let grant: ChannelGrant;
|
||||
try { grant = JSON.parse(plain) as ChannelGrant; } catch { throw new Error('空间授权包格式无效'); }
|
||||
if (!grant || grant.v !== 1 || grant.channelId !== channelId || grant.role !== role || !['enc', 'raw'].includes(grant.mode)
|
||||
|| !grant.keys || typeof grant.keys !== 'object') throw new Error('空间授权包校验失败');
|
||||
const parsed = parseChannelLink(makeLink(origin, channelId, grant.keys));
|
||||
if (!parsed.ok || parsed.link.mode !== grant.mode) throw new Error('空间授权包模式不匹配');
|
||||
if (!parsed.ok || parsed.link.id !== channelId || parsed.link.isOwner !== (role === 'owner')) throw new Error('空间授权包权限不匹配');
|
||||
return parsed.link;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { IntegrityError } from '@webdrop/core';
|
||||
import { fileCatalogs, verifyFileCatalog } from './fileCatalog';
|
||||
import { dequeueItem, saveResource } from '../database';
|
||||
import { serial } from './serial';
|
||||
import { signObjectDeletion } from './channelDataKeys';
|
||||
import { accountToken } from './accountSession';
|
||||
|
||||
export function connectChannelResource(id: string): () => void {
|
||||
void connectChannel(id);
|
||||
@@ -23,7 +25,8 @@ export async function removeChannelItem(resourceId: string, itemId: string): Pro
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const role = session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read';
|
||||
await serial(`outbox:${resourceId}`, async () => {
|
||||
await apiFetch(`/channels/${resourceId}/items/${itemId}`, { method: 'DELETE', headers: session.authHeaders(role) });
|
||||
const eventHeaders = resource.kind === 'channel' && accountToken() ? await signObjectDeletion(resource, session, itemId) : {};
|
||||
await apiFetch(`/channels/${resourceId}/items/${itemId}`, { method: 'DELETE', headers: { ...session.authHeaders(role), ...eventHeaders } });
|
||||
await dequeueItem(resourceId, itemId);
|
||||
resource.localItems = resource.localItems?.filter((item) => item.id !== itemId);
|
||||
await saveResource(resource);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ChannelSession, toBase64Url } from '@webdrop/core';
|
||||
import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
|
||||
import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
|
||||
import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types';
|
||||
import { apiFetch, readJson } from './http';
|
||||
import { apiFetch, readJson, getServerInfo } from './http';
|
||||
import { channelCache, isOfflineFailure } from './resourceMetadata';
|
||||
import { accountToken } from './accountSession';
|
||||
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
|
||||
import type { ChannelSettingsPatch as SettingsRequest, SeatJoined, MemberBody } from '../generated/api';
|
||||
import { getSession, sessions } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { makeLink } from './links';
|
||||
@@ -13,26 +17,26 @@ import { publishWorkspace } from './presentation';
|
||||
import { channelMetadata } from './channelEvents';
|
||||
import { requireAccount, accountPublicKey } from './accountSession';
|
||||
import { sealChannelGrant } from './channelGrant';
|
||||
import { createThread } from './channelThreads';
|
||||
|
||||
|
||||
|
||||
export async function createChannel(options: CreateOptions, captchaToken?: string): Promise<ChannelInfo> {
|
||||
const username = requireAccount();
|
||||
const defaults = (await getServerInfo()).defaults?.channel;
|
||||
if (!defaults) throw new Error('服务端需要升级以提供统一设置');
|
||||
if (defaults.join_policy !== 'open' && defaults.join_policy !== 'invite') throw new Error('空间加入规则无效');
|
||||
const endpointSecret = crypto.getRandomValues(new Uint8Array(32));
|
||||
const { session, request } = await ChannelSession.create({
|
||||
mode: options.mode,
|
||||
endpointSecret,
|
||||
settings: {
|
||||
...defaults,
|
||||
name: options.name?.trim() || undefined,
|
||||
seats: options.seats,
|
||||
ttl_seconds: options.ttlSeconds,
|
||||
members_can_upload: options.membersCanUpload,
|
||||
delete_policy: 'own',
|
||||
max_items: 1000,
|
||||
file_max_downloads: options.fileMaxDownloads ?? 10,
|
||||
ui_restrict_copy_download: false,
|
||||
join_policy: options.joinPolicy ?? 'invite', allow_guests: options.allowGuests ?? false,
|
||||
file_max_downloads: options.fileMaxDownloads,
|
||||
join_policy: options.joinPolicy ?? defaults.join_policy, allow_guests: options.allowGuests ?? defaults.allow_guests,
|
||||
},
|
||||
});
|
||||
try {
|
||||
@@ -42,13 +46,14 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
}).then((response) => response.json() as Promise<ChannelWire>);
|
||||
session.verifyChannelInfo(info);
|
||||
const resource: SavedResource = {
|
||||
...channelCache(info),
|
||||
id: session.id,
|
||||
creatorAccount: info.creator_account,
|
||||
creatorAccount: info.creator_account ?? undefined,
|
||||
kind: 'channel', mode: session.mode, role: 'owner',
|
||||
name: info.settings.name,
|
||||
name: info.settings.name ?? undefined,
|
||||
link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload,
|
||||
seats: info.settings.seats,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: [],
|
||||
};
|
||||
const ownerLabel = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
@@ -57,7 +62,9 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
});
|
||||
await saveResource(resource);
|
||||
sessions.set(session.id, session);
|
||||
await createThread(session.id, '常规');
|
||||
attachDataKeys(session, resource);
|
||||
const workspace = await import('./workspaceStructure').then(module => module.loadWorkspace(session.id));
|
||||
if (workspace.structure.defaultChannelId) await initializeChannelKeys(resource, session, workspace.structure.defaultChannelId);
|
||||
return toChannelInfo(info, resource);
|
||||
} catch (error) {
|
||||
session.dispose();
|
||||
@@ -75,15 +82,16 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
|
||||
session.verifyChannelInfo(info);
|
||||
const joined = await apiFetch(`/channels/${id}/seats`, {
|
||||
method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }),
|
||||
}).then((response) => response.json() as Promise<{ seats_used: number }>);
|
||||
}).then((response) => response.json() as Promise<SeatJoined>);
|
||||
info.seats_used = joined.seats_used;
|
||||
const resource: SavedResource = {
|
||||
...channelCache(info),
|
||||
id, kind: 'channel', mode: session.mode, role: session.role,
|
||||
creatorAccount: info.creator_account,
|
||||
name: info.settings.name,
|
||||
creatorAccount: info.creator_account ?? undefined,
|
||||
name: info.settings.name ?? undefined,
|
||||
link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined,
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload,
|
||||
seats: info.settings.seats,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [],
|
||||
};
|
||||
const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
@@ -106,28 +114,52 @@ export async function previewChannel(link: ParsedChannelLink): Promise<ChannelIn
|
||||
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 });
|
||||
expiresAt: info.expires_at, seats: info.settings.seats });
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
|
||||
export async function channelMembers(id: string): Promise<unknown> {
|
||||
export async function channelMembers(id: string): Promise<MemberBody[]> {
|
||||
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) => ({
|
||||
let catalog: Map<string, ChannelWire> | undefined;
|
||||
if (accountToken()) {
|
||||
try {
|
||||
const rows = await apiFetch('/accounts/spaces').then(r => r.json() as Promise<ChannelWire[]>);
|
||||
catalog = new Map(rows.map(row => [row.id, row]));
|
||||
} catch (error) { if (!isOfflineFailure(error)) throw error; }
|
||||
}
|
||||
const rows = await Promise.all(saved.map(async (resource): Promise<ChannelInfo | null> => {
|
||||
try {
|
||||
if (catalog && !catalog.has(resource.id)) return null;
|
||||
const { session } = await getSession(resource.id);
|
||||
const raw = catalog?.get(resource.id) ?? await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'));
|
||||
session.verifyChannelInfo(raw);
|
||||
const info = toChannelInfo(raw, resource);
|
||||
await cacheResource(resource, channelCache(raw));
|
||||
return { ...info, dataSource: 'server' };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'status' in error && (error.status === 404 || error.status === 410)) return null;
|
||||
if (!isOfflineFailure(error)) throw error;
|
||||
if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' };
|
||||
return {
|
||||
id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, seats: resource.seats, maxDownloads: resource.maxDownloads ?? undefined,
|
||||
creatorAccount: resource.creatorAccount,
|
||||
memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
|
||||
dataSource: 'cache',
|
||||
};
|
||||
}
|
||||
}));
|
||||
return rows.filter((row): row is ChannelInfo => row !== null);
|
||||
}
|
||||
|
||||
export async function updateChannelSettings(resourceId: string, patch: ChannelSettingsPatch): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
|
||||
const body: Record<string, unknown> = {};
|
||||
const body: SettingsRequest = {};
|
||||
if (patch.name !== undefined) body.name = patch.name.trim();
|
||||
if (patch.seats !== undefined) body.seats = patch.seats;
|
||||
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
|
||||
@@ -141,13 +173,8 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
body: JSON.stringify(body),
|
||||
}).then((response) => response.json() as Promise<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) });
|
||||
const updated = await cacheResource(resource, channelCache(info));
|
||||
publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) });
|
||||
}
|
||||
|
||||
export async function renameDevice(resourceId: string, endpointHash: string, name: string): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { removeChannelItem } from './channelItems';
|
||||
import { queueFileCatalog, queueItemAuthor } from './channelThreads';
|
||||
import { queueFileCatalog } from './channelThreads';
|
||||
import { senderSnapshot } from './senderSnapshot';
|
||||
import { prepareObject } from './channelDataKeys';
|
||||
import { errorMessage } from '../errors';
|
||||
|
||||
import { saveResource } from '../database';
|
||||
import { saveResource, queueLocation } from '../database';
|
||||
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import type { FileItem, FileUploadOptions, Item } from '../types';
|
||||
@@ -16,31 +17,35 @@ import { deviceIdentity } from './presentation';
|
||||
import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer';
|
||||
import { publishWorkspace } from './presentation';
|
||||
|
||||
export async function sendText(resourceId: string, text: string, threadId?: string): Promise<{ itemId: string; uploaded: boolean }> {
|
||||
export async function sendText(resourceId: string, text: string, threadId?: string,
|
||||
event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }): Promise<{ itemId: string; uploaded: boolean }> {
|
||||
if (!threadId) throw new Error('请先选择频道');
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
|
||||
const identity = await deviceIdentity(session.endpointSecret);
|
||||
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
|
||||
session.deviceName = device.name;
|
||||
const ts = Date.now();
|
||||
const author = await senderSnapshot();
|
||||
const plan = session.encryptText(text, { dev: device.name, ts, threadId });
|
||||
const prepared = await prepareObject(resource, session, threadId, 'chat');
|
||||
const plan = session.encryptText(text, { dev: device.name, ts, threadId, itemId: prepared.itemIdBytes });
|
||||
const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}) };
|
||||
blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' }));
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
await persistPlan(resource, plan);
|
||||
if (author) await queueItemAuthor(resource, session, plan.itemId, author, threadId, ts);
|
||||
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch,
|
||||
eventKind: event?.kind ?? 'chat.create', ...(event?.targetItemId ? { targetItemId: event.targetItemId } : {}) });
|
||||
await queueLocation(resourceId, item.id, threadId);
|
||||
const uploaded = await flushOutbox(resource, session);
|
||||
publishWorkspace(resourceId, { t: 'item', item: { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } } });
|
||||
if (!uploaded) publishWorkspace(resourceId, { t: 'connection', state: 'offline' });
|
||||
return { itemId: plan.itemId, uploaded };
|
||||
}
|
||||
|
||||
export async function sendFiles(resourceId: string, files: File[], options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId?: string, folderId?: string): Promise<void> {
|
||||
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, 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 options = suppliedOptions ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 };
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
|
||||
const identity = await deviceIdentity(session.endpointSecret);
|
||||
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
|
||||
session.deviceName = device.name;
|
||||
@@ -50,7 +55,9 @@ export async function sendFiles(resourceId: string, files: File[], options: File
|
||||
const ts = Date.now();
|
||||
let item: FileItem | undefined;
|
||||
try {
|
||||
const prepared = await prepareObject(resource, session, threadId, 'files');
|
||||
const plan = session.encryptFile(file, {}, {
|
||||
itemId: prepared.itemIdBytes,
|
||||
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
|
||||
dev: device.name, ts, maxFrameData, threadId, folderId,
|
||||
});
|
||||
@@ -60,7 +67,7 @@ export async function sendFiles(resourceId: string, files: File[], options: File
|
||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
|
||||
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
||||
remaining: plan.gated
|
||||
? (options.directPreview ? resource.maxDownloads ?? 10 : options.maxDownloads) || null
|
||||
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
|
||||
: null,
|
||||
preview: previewKindOf(file.type, file.name), gated: plan.gated,
|
||||
};
|
||||
@@ -84,9 +91,10 @@ export async function sendFiles(resourceId: string, files: File[], options: File
|
||||
const planned = await persistPlan(resource, plan, (_frames) => {
|
||||
const processed = Math.min(file.size, _frames * MAX_FRAME_DATA);
|
||||
updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting');
|
||||
});
|
||||
}, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.publish' });
|
||||
const uploadedFrames = new Set<number>();
|
||||
await queueFileCatalog(resource, session, item);
|
||||
await queueLocation(resourceId, item.id, threadId, folderId);
|
||||
let uploadedBytes = 0;
|
||||
const uploadStartedAt = performance.now();
|
||||
const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => {
|
||||
@@ -120,13 +128,16 @@ export async function sendFiles(resourceId: string, files: File[], options: File
|
||||
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const name = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
|
||||
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name });
|
||||
await persistPlan(resource, plan);
|
||||
const { structure } = await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId));
|
||||
if (!structure.defaultChannelId) throw new Error('请先创建默认频道');
|
||||
const prepared = await prepareObject(resource, session, structure.defaultChannelId, 'chat');
|
||||
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name, itemId: prepared.itemIdBytes });
|
||||
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'chat.snapshot' });
|
||||
await flushOutbox(resource, session);
|
||||
}
|
||||
|
||||
export async function editChannelText(resourceId: string, itemId: string, text: string, threadId?: string): Promise<void> {
|
||||
const created = await sendText(resourceId, text, threadId);
|
||||
const created = await sendText(resourceId, text, threadId, { kind: 'chat.edit', targetItemId: itemId });
|
||||
if (!created.uploaded) throw new Error('新版本已排队,网络恢复前保留旧内容。');
|
||||
await removeChannelItem(resourceId, itemId);
|
||||
}
|
||||
|
||||
@@ -1,172 +1,55 @@
|
||||
import type { ChannelSession, ReceivedItem } from '@webdrop/core';
|
||||
import type { ChannelSession } from '@webdrop/core';
|
||||
import type { SavedResource } from '../database';
|
||||
import type { FileItem, FolderInfo, SenderInfo, ThreadInfo, WorkspaceEvent } from '../types';
|
||||
import { readSender } from '@/lib/sender';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import type { FileItem } from '../types';
|
||||
import { persistPlan } from './transfer';
|
||||
import { changeWorkspace, createWorkspaceChannel, createWorkspaceGroup } from './workspaceStructure';
|
||||
import { getSession } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { flushOutbox, persistPlan } from './transfer';
|
||||
import { initializeChannelKeys, prepareObject } from './channelDataKeys';
|
||||
import { toPublishedFile } from './fileCatalog';
|
||||
import { apiFetch } from './http';
|
||||
import { WORKSPACE_APP, type ResourceControl } from '../protocol/workspace';
|
||||
import type { WorkspaceChannelRequest, WorkspaceGroupRequest, WorkspaceNameRequest, WorkspaceFolderRequest, WorkspaceMoveRequest } from '../generated/api';
|
||||
|
||||
const CONTROL_APP = 'webdrop.workspace';
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
type Control =
|
||||
| { op: 'item.author'; itemId: string; author: SenderInfo }
|
||||
| { op: 'file.published'; item: FileItem }
|
||||
| { op: 'thread.create'; id: string; name: string; groupId?: string }
|
||||
| { op: 'group.create'; id: string; name: string }
|
||||
| { op: 'group.rename'; id: string; name: string }
|
||||
| { op: 'thread.group'; id: string; groupId: string | null }
|
||||
| { op: 'thread.rename'; id: string; name: string }
|
||||
| { op: 'folder.create'; id: string; threadId: string; parentId?: string; name: string }
|
||||
| { op: 'folder.rename'; id: string; name: string }
|
||||
| { op: 'items.move'; itemIds: string[]; threadId: string; folderId: string | null };
|
||||
|
||||
function cleanName(input: string): string {
|
||||
const name = input.trim();
|
||||
if (!name || name.length > 80 || /[\u0000-\u001f]/.test(name)) throw new Error('名称需为 1–80 个字符,不能包含控制字符');
|
||||
return name;
|
||||
}
|
||||
|
||||
async function sendControl(resourceId: string, control: Control, threadId?: string): Promise<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 } : {}) });
|
||||
export async function createThread(resourceId: string, name: string, groupId?: string): Promise<string> {
|
||||
const id = await createWorkspaceChannel(resourceId, { name, groupId } satisfies WorkspaceChannelRequest);
|
||||
const { resource, session } = await getSession(resourceId); await initializeChannelKeys(resource, session, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function createGroup(resourceId: string, input: string): Promise<string> {
|
||||
const id = crypto.randomUUID(); await sendControl(resourceId, { op: 'group.create', id, name: cleanName(input) }); return id;
|
||||
export async function createGroup(resourceId: string, name: string): Promise<string> {
|
||||
return createWorkspaceGroup(resourceId, { name } satisfies WorkspaceNameRequest);
|
||||
}
|
||||
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 renameGroup(resourceId: string, id: string, name: string): Promise<void> {
|
||||
await changeWorkspace(resourceId, 'groups/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest);
|
||||
}
|
||||
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);
|
||||
await changeWorkspace(resourceId, 'channels/' + id + '/group', 'PUT', { groupId: groupId ?? null } satisfies WorkspaceGroupRequest);
|
||||
}
|
||||
|
||||
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 renameThread(resourceId: string, id: string, name: string): Promise<void> {
|
||||
await changeWorkspace(resourceId, 'channels/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest);
|
||||
}
|
||||
|
||||
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 setChannelHistory(resourceId: string, id: string, chatHistory: boolean, filesHistory: boolean): Promise<void> {
|
||||
const { session } = await getSession(resourceId);
|
||||
await apiFetch(`/spaces/${resourceId}/channels/${id}/history`, { method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ chatHistory, filesHistory }) });
|
||||
await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId));
|
||||
}
|
||||
|
||||
export async function renameFolder(resourceId: string, id: string, input: string, threadId: string): Promise<void> {
|
||||
await sendControl(resourceId, { op: 'folder.rename', id, name: cleanName(input) }, threadId);
|
||||
export async function createFolder(resourceId: string, threadId: string, name: string, parentId?: string): Promise<void> {
|
||||
await changeWorkspace(resourceId, 'folders', 'POST', { threadId, name, parentId } satisfies WorkspaceFolderRequest);
|
||||
}
|
||||
export async function renameFolder(resourceId: string, id: string, name: string, _threadId: string): Promise<void> {
|
||||
await changeWorkspace(resourceId, 'folders/' + id, 'PATCH', { name } satisfies WorkspaceNameRequest);
|
||||
}
|
||||
|
||||
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);
|
||||
await changeWorkspace(resourceId, 'locations', 'PUT', { itemIds, threadId, folderId } satisfies WorkspaceMoveRequest);
|
||||
}
|
||||
|
||||
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: '空间结构记录格式无效,已拒绝应用。' };
|
||||
}
|
||||
if (!item.threadId) throw new Error('请先选择频道');
|
||||
const prepared = await prepareObject(resource, session, item.threadId, 'files');
|
||||
const control: ResourceControl = { op: 'file.published', item: toPublishedFile(item) };
|
||||
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: WORKSPACE_APP, v: 1, ...control })),
|
||||
{ kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true, itemId: prepared.itemIdBytes });
|
||||
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.catalog' });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto';
|
||||
import { openJson, sealJson } from './accountCrypto';
|
||||
|
||||
describe('channel data-domain object keys', () => {
|
||||
it('uses random independent file keys and binds envelopes to channel, domain, epoch and object', async () => {
|
||||
const domain = randomDomainKey();
|
||||
const context = { spaceId: 'space', channelId: 'channel-a', domain: 'files' as const, epoch: 1, itemId: 'file-a' };
|
||||
const a = await newObjectKey(domain, context); const b = await newObjectKey(domain, context);
|
||||
expect(a.key.extractable).toBe(false); expect(a.envelope).not.toBe(b.envelope);
|
||||
const ciphertext = await sealJson({ data: 'private file' }, a.key);
|
||||
const recovered = await unwrapObjectKey(domain, a.envelope, context);
|
||||
expect(await openJson(ciphertext, recovered)).toEqual({ data: 'private file' });
|
||||
await expect(openJson(ciphertext, b.key)).rejects.toThrow();
|
||||
await expect(unwrapObjectKey(randomDomainKey(), a.envelope, context)).rejects.toThrow();
|
||||
for (const changed of [{ ...context, spaceId: 'other-space' }, { ...context, channelId: 'channel-b' }, { ...context, domain: 'chat' as const }, { ...context, epoch: 2 }, { ...context, itemId: 'file-b' }])
|
||||
await expect(unwrapObjectKey(domain, a.envelope, changed)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { bytes, b64, unb64, sealJson, openJson } from './accountCrypto';
|
||||
|
||||
export interface ObjectContext { spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string }
|
||||
const label = (c: ObjectContext) => JSON.stringify(['webdrop/object-key/v1', c.spaceId, c.channelId, c.domain, c.epoch, c.itemId]);
|
||||
export const randomDomainKey = () => b64(crypto.getRandomValues(new Uint8Array(32)));
|
||||
async function aes(value: string): Promise<CryptoKey> {
|
||||
const material = unb64(value);
|
||||
try { if (material.length !== 32) throw new Error('数据域密钥格式无效'); return await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']); }
|
||||
finally { material.fill(0); }
|
||||
}
|
||||
export async function newObjectKey(domainKey: string, context: ObjectContext): Promise<{ key: CryptoKey; envelope: string }> {
|
||||
const raw = crypto.getRandomValues(new Uint8Array(32));
|
||||
try {
|
||||
const key = await crypto.subtle.importKey('raw', bytes(raw), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
return { key, envelope: await sealJson({ key: b64(raw) }, await aes(domainKey), label(context)) };
|
||||
} finally { raw.fill(0); }
|
||||
}
|
||||
export async function unwrapObjectKey(domainKey: string, envelope: string, context: ObjectContext): Promise<CryptoKey> {
|
||||
const value = await openJson(envelope, await aes(domainKey), label(context)) as { key?: unknown };
|
||||
if (typeof value.key !== 'string') throw new Error('对象密钥封装格式无效');
|
||||
return aes(value.key);
|
||||
}
|
||||
@@ -40,7 +40,8 @@ export async function storeVerifiedDownload(
|
||||
decoded: { content: ReadableStream<Uint8Array>; result: Promise<ItemDecodeResult> },
|
||||
expectedSize?: number,
|
||||
createTemporary: () => Promise<TemporaryFile | undefined> = temporaryFile,
|
||||
): Promise<{ blob: Blob; meta: ItemDecodeResult['meta'] }> {
|
||||
verifyResult?: (result: ItemDecodeResult) => Promise<void>,
|
||||
): Promise<{ blob: Blob; meta: ItemDecodeResult['meta']; ciphertextManifestHash?: Uint8Array }> {
|
||||
const reader = decoded.content.getReader();
|
||||
let temporary: TemporaryFile | undefined;
|
||||
const parts: ArrayBuffer[] = [];
|
||||
@@ -64,8 +65,9 @@ export async function storeVerifiedDownload(
|
||||
const result = await decoded.result;
|
||||
if (!result.complete) throw new Error('文件传输不完整');
|
||||
if (expectedSize !== undefined && size !== expectedSize || result.meta?.size !== undefined && size !== result.meta.size) throw new IntegrityError('bad_meta');
|
||||
await verifyResult?.(result);
|
||||
if (temporary) { await temporary.close(); closed = true; }
|
||||
return { blob: temporary ? await temporary.file() : new Blob(parts), meta: result.meta };
|
||||
return { blob: temporary ? await temporary.file() : new Blob(parts), meta: result.meta, ciphertextManifestHash: result.ciphertextManifestHash };
|
||||
} catch (error) {
|
||||
await reader.cancel(error).catch(() => {});
|
||||
if (temporary) {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { FileItem } from '../types';
|
||||
import { IntegrityError, type ItemMeta } from '@webdrop/core';
|
||||
import type { PublishedFile } from '../protocol/workspace';
|
||||
|
||||
export function toPublishedFile(item: FileItem): PublishedFile {
|
||||
if (!item.threadId) throw new Error('请先选择频道');
|
||||
return { id: item.id, kind: 'file', threadId: item.threadId, folderId: item.folderId,
|
||||
name: item.name, mime: item.mime, size: item.size, ts: item.ts, deviceId: item.deviceId,
|
||||
gated: item.gated ?? false };
|
||||
}
|
||||
|
||||
export const fileCatalogs = new Map<string, FileItem>();
|
||||
export const visibleFiles = new Set<string>();
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ChannelSession, collectFrames } from '@webdrop/core';
|
||||
import { applyControl } from './channelThreads';
|
||||
|
||||
const groupId = 'd7a19ad2-690c-44f2-bd0a-1b30b309bf92';
|
||||
const threadId = '15e7fb09-984d-47d4-8e74-477c7d69c58c';
|
||||
async function decode(control: Record<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' });
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { supportsApiContract } from '../contract';
|
||||
import { accountToken } from './accountSession';
|
||||
import type { ServerInfoBody } from '../generated/api';
|
||||
|
||||
export type ApiError = Error & { status?: number; code?: string };
|
||||
let apiBase = '/api/v1';
|
||||
let contractCheck: Promise<void> | undefined;
|
||||
let contractCheck: Promise<ServerInfoBody> | undefined;
|
||||
|
||||
export function setApiBase(value: string): void {
|
||||
if (apiBase === value) return;
|
||||
@@ -20,15 +21,16 @@ export const apiPath = (path: string): string => new URL(
|
||||
currentOrigin(),
|
||||
).toString();
|
||||
|
||||
function ensureApiContract(): Promise<void> {
|
||||
export function getServerInfo(): Promise<ServerInfoBody> {
|
||||
contractCheck ??= fetch(apiPath('/info'), {
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) throw new Error('api_info_unavailable');
|
||||
const info = await response.json() as { contract_version?: unknown };
|
||||
const info = await response.json() as ServerInfoBody;
|
||||
if (!supportsApiContract(info.contract_version)) throw new Error('api_contract_mismatch');
|
||||
return info;
|
||||
}).catch((error: unknown) => {
|
||||
contractCheck = undefined;
|
||||
throw error;
|
||||
@@ -38,7 +40,7 @@ function ensureApiContract(): Promise<void> {
|
||||
|
||||
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
try {
|
||||
if (path !== '/info') await ensureApiContract();
|
||||
if (path !== '/info') await getServerInfo();
|
||||
const headers = new Headers(init.headers);
|
||||
const token = accountToken();
|
||||
if (token && !headers.has('X-WD-Account')) headers.set('X-WD-Account', token);
|
||||
@@ -71,7 +73,7 @@ export async function apiUpload(
|
||||
onProgress: (loadedBytes: number, totalBytes: number) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ensureApiContract();
|
||||
await getServerInfo();
|
||||
const response = await new Promise<Response>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', apiPath(path));
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { openPasskeyKey, sealPasskeyKey } from './passkeyCrypto';
|
||||
import { bytes, openJson, sealJson } from './accountCrypto';
|
||||
|
||||
describe('Passkey recovery key envelopes', () => {
|
||||
it('recovers the same non-exportable vault key without exposing plaintext to the backend', async () => {
|
||||
const material = crypto.getRandomValues(new Uint8Array(32)); const prf = crypto.getRandomValues(new Uint8Array(32));
|
||||
const key = await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
const vault = await sealJson({ private: 'test-private-identity' }, key);
|
||||
const envelope = await sealPasskeyKey(material, bytes(prf), 'alice', 'credential-a');
|
||||
expect(envelope).not.toContain('test-private-identity');
|
||||
const recovered = await openPasskeyKey(envelope, bytes(prf), 'alice', 'credential-a');
|
||||
expect(recovered.extractable).toBe(false); expect(await openJson(vault, recovered)).toEqual({ private: 'test-private-identity' });
|
||||
await expect(openPasskeyKey(envelope, bytes(prf), 'bob', 'credential-a')).rejects.toThrow();
|
||||
await expect(openPasskeyKey(envelope, bytes(prf), 'alice', 'credential-b')).rejects.toThrow();
|
||||
await expect(openPasskeyKey(envelope, bytes(crypto.getRandomValues(new Uint8Array(32))), 'alice', 'credential-a')).rejects.toThrow();
|
||||
});
|
||||
it('rejects malformed PRF outputs', async () => {
|
||||
await expect(sealPasskeyKey(new Uint8Array(32), new ArrayBuffer(31), 'alice', 'credential')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { bytes, sealJson, openJson, b64, unb64 } from './accountCrypto';
|
||||
|
||||
const label = 'webdrop/passkey-recovery/v1';
|
||||
async function prfKey(result: ArrayBuffer): Promise<CryptoKey> {
|
||||
if (result.byteLength !== 32) throw new Error('Passkey恢复输出格式无效');
|
||||
const base = await crypto.subtle.importKey('raw', result, 'HKDF', false, ['deriveKey']);
|
||||
return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: new TextEncoder().encode(label) }, base,
|
||||
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
const context = (username: string, credentialId: string) => `${label}:${username}:${credentialId}`;
|
||||
export async function sealPasskeyKey(material: Uint8Array, result: ArrayBuffer, username: string, credentialId: string): Promise<string> {
|
||||
if (material.byteLength !== 32) throw new Error('账号恢复密钥格式无效');
|
||||
return sealJson({ key: b64(material) }, await prfKey(result), context(username, credentialId));
|
||||
}
|
||||
export async function openPasskeyKey(envelope: string, result: ArrayBuffer, username: string, credentialId: string): Promise<CryptoKey> {
|
||||
const value = await openJson(envelope, await prfKey(result), context(username, credentialId)) as { key?: unknown };
|
||||
if (typeof value.key !== 'string') throw new Error('账号恢复密钥格式无效');
|
||||
const material = unb64(value.key);
|
||||
try {
|
||||
if (material.length !== 32) throw new Error('账号恢复密钥格式无效');
|
||||
return await crypto.subtle.importKey('raw', bytes(material), 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
} finally { material.fill(0); }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { AccountSaltBody, AccountVaultBody, PasskeyFinishRequest, PasskeyInfoBody, PasskeyOptionsBody, PasskeySessionBody } from '../generated/api';
|
||||
import { apiFetch } from './http';
|
||||
import { b64, unb64, deriveAccountKeys, openJson } from './accountCrypto';
|
||||
import { unlockedAccount, usernameOf, unlockAuthenticatedAccount, accountStatus } from './account';
|
||||
import { localAccountDevice } from './accountPersistence';
|
||||
import { sealPasskeyKey, openPasskeyKey } from './passkeyCrypto';
|
||||
import { validateVault } from './accountVault';
|
||||
|
||||
interface Binding { username: string; material: Uint8Array; options: PasskeyOptionsBody; expiry: number; timer: ReturnType<typeof setTimeout> }
|
||||
let binding: Binding | undefined;
|
||||
let pendingLogin: PasskeySessionBody | undefined;
|
||||
export function passkeyUnlockUsername(): string | undefined { return pendingLogin?.username; }
|
||||
export function clearPasskeyState(): void {
|
||||
if (binding) { binding.material.fill(0); clearTimeout(binding.timer); }
|
||||
binding = undefined;
|
||||
const identity = pendingLogin; pendingLogin = undefined;
|
||||
if (identity) void apiFetch('/accounts/session', { method: 'DELETE', headers: { 'X-WD-Account': identity.token } }).catch(() => {});
|
||||
}
|
||||
export async function listPasskeys(): Promise<PasskeyInfoBody[]> {
|
||||
unlockedAccount(); return apiFetch('/accounts/passkeys').then(r => r.json() as Promise<PasskeyInfoBody[]>);
|
||||
}
|
||||
export async function removePasskey(id: string): Promise<void> { unlockedAccount(); await apiFetch(`/accounts/passkeys/${encodeURIComponent(id)}`, { method: 'DELETE' }); }
|
||||
export async function beginPasskeyRegistration(password: string): Promise<PasskeyOptionsBody> {
|
||||
clearPasskeyState();
|
||||
const account = unlockedAccount();
|
||||
const salt = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: account.username }) }).then(r => r.json() as Promise<AccountSaltBody>);
|
||||
const { verifier, key, material } = await deriveAccountKeys(password, unb64(salt.salt), true);
|
||||
try {
|
||||
const vault = await apiFetch('/accounts/vault').then(r => r.json() as Promise<AccountVaultBody>);
|
||||
const profile = validateVault(await openJson(vault.vault, key));
|
||||
if (profile.keys.publicKey.x !== account.profile.keys.publicKey.x || profile.keys.publicKey.y !== account.profile.keys.publicKey.y) throw new Error('账号解锁信息不匹配');
|
||||
const options = await apiFetch('/accounts/passkeys/register/options', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: account.username, verifier: b64(verifier) }) }).then(r => r.json() as Promise<PasskeyOptionsBody>);
|
||||
binding = { username: account.username, material: material!, options, expiry: Date.now() + 120_000, timer: setTimeout(clearPasskeyState, 120_000) };
|
||||
return options;
|
||||
} catch (error) { material?.fill(0); throw error; }
|
||||
finally { verifier.fill(0); }
|
||||
}
|
||||
export async function finishPasskeyRegistration(id: string, credentialId: string, credential: string, name: string, prf?: ArrayBuffer): Promise<PasskeyInfoBody> {
|
||||
const account = unlockedAccount();
|
||||
if (!binding || binding.username !== account.username || binding.options.id !== id || binding.expiry < Date.now()) throw new Error('Passkey绑定已过期,请重试');
|
||||
const keyEnvelope = prf ? await sealPasskeyKey(binding.material, prf, account.username, credentialId) : undefined;
|
||||
const result = await apiFetch('/accounts/passkeys/register', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: account.username, id, credential, name, key_envelope: keyEnvelope } satisfies PasskeyFinishRequest) }).then(r => r.json() as Promise<PasskeyInfoBody>);
|
||||
if (prf) clearPasskeyState();
|
||||
return result;
|
||||
}
|
||||
export async function beginPasskeyRecovery(): Promise<PasskeyOptionsBody> {
|
||||
unlockedAccount(); return apiFetch('/accounts/passkeys/recovery/options', { method: 'POST' }).then(r => r.json() as Promise<PasskeyOptionsBody>);
|
||||
}
|
||||
export async function finishPasskeyRecovery(id: string, credentialId: string, credential: string, prf: ArrayBuffer): Promise<void> {
|
||||
const account = unlockedAccount();
|
||||
if (!binding || binding.username !== account.username || binding.expiry < Date.now()) throw new Error('Passkey绑定已过期,请重试');
|
||||
try {
|
||||
const envelope = await sealPasskeyKey(binding.material, prf, account.username, credentialId);
|
||||
await apiFetch('/accounts/passkeys/recovery', { method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: account.username, id, credential, key_envelope: envelope }) });
|
||||
} finally { clearPasskeyState(); }
|
||||
}
|
||||
export async function beginPasskeyLogin(input: string): Promise<PasskeyOptionsBody> {
|
||||
const username = usernameOf(input);
|
||||
clearPasskeyState();
|
||||
return apiFetch('/accounts/passkeys/login/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }).then(r => r.json() as Promise<PasskeyOptionsBody>);
|
||||
}
|
||||
export async function finishPasskeyLogin(input: string, id: string, credential: string, prf?: ArrayBuffer, remember = true) {
|
||||
const username = usernameOf(input); const device = await localAccountDevice();
|
||||
const identity = await apiFetch('/accounts/passkeys/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, id, credential, device_id: device.id, device_name: device.name } satisfies PasskeyFinishRequest) }).then(r => r.json() as Promise<PasskeySessionBody>);
|
||||
if (identity.username !== username) throw new Error('Passkey账号不匹配');
|
||||
pendingLogin = identity;
|
||||
if (!prf || !identity.key_envelope) return accountStatus();
|
||||
try {
|
||||
const key = await openPasskeyKey(identity.key_envelope, prf, username, identity.credential_id);
|
||||
const status = await unlockAuthenticatedAccount(identity, key, remember); pendingLogin = undefined;
|
||||
return { ...status, unlockUsername: undefined };
|
||||
} catch (error) { clearPasskeyState(); throw error; }
|
||||
}
|
||||
export async function unlockPasskeyLogin(password: string, remember = true) {
|
||||
const identity = pendingLogin;
|
||||
if (!identity) throw new Error('请先使用Passkey登录');
|
||||
const salt = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: identity.username }) }).then(r => r.json() as Promise<AccountSaltBody>);
|
||||
const { verifier, key } = await deriveAccountKeys(password, unb64(salt.salt)); verifier.fill(0);
|
||||
try { const status = await unlockAuthenticatedAccount(identity, key, remember); pendingLogin = undefined; return { ...status, unlockUsername: undefined }; }
|
||||
catch (error) { clearPasskeyState(); throw error; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOfflineFailure } from './resourceMetadata';
|
||||
import { toPublishedFile } from './fileCatalog';
|
||||
import { exportResource } from './accountVault';
|
||||
import type { FileItem } from '../types';
|
||||
import type { SavedResource } from '../database';
|
||||
|
||||
describe('resource and rendering boundaries', () => {
|
||||
it('does not treat authorization, tampering or contract errors as offline cache hits', () => {
|
||||
expect(isOfflineFailure(new Error('网络连接失败'))).toBe(true);
|
||||
expect(isOfflineFailure(Object.assign(new Error('forbidden'), { status: 403 }))).toBe(false);
|
||||
expect(isOfflineFailure(new Error('api_contract_mismatch'))).toBe(false);
|
||||
expect(isOfflineFailure(new Error('bad_meta'))).toBe(false);
|
||||
});
|
||||
it('publishes only resource metadata, excluding UI state and download counters', () => {
|
||||
const item: FileItem = { id: 'a', threadId: 'channel', kind: 'file', name: 'secret.txt', mime: 'text/plain', size: 10, ts: 1, deviceId: 'device',
|
||||
gated: true, remaining: 5, preview: 'text', state: { kind: 'receiving', progress: 0.2 }, unseen: true, versions: [] };
|
||||
const wire = toPublishedFile(item);
|
||||
expect(wire.name).toBe('secret.txt');
|
||||
for (const field of ['state', 'preview', 'remaining', 'versions', 'unseen']) expect(wire).not.toHaveProperty(field);
|
||||
});
|
||||
it('never synchronizes draft content or server structure snapshots inside the key vault', () => {
|
||||
const resource: SavedResource = { id: 'a', kind: 'share', mode: 'enc', role: 'owner', link: 'key', createdAt: 1, expiresAt: 2,
|
||||
endpointSecret: new Uint8Array(32), seats: 0, draftItems: [], manifestEntries: [],
|
||||
workspaceMetadata: { structure: { revision: 1, channels: [], groups: [], folders: [], locations: [] }, people: [] } };
|
||||
const exported = exportResource(resource);
|
||||
for (const field of ['draftItems', 'manifestEntries', 'workspaceMetadata', 'endpointSecret']) expect(exported).not.toHaveProperty(field);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ChannelInfo, ShareInfo } from '../generated/api';
|
||||
import type { SavedResource } from '../database';
|
||||
|
||||
/** Server-owned fields only: preserve local credentials, drafts, and user edit timestamps. */
|
||||
export function channelCache(raw: ChannelInfo): Partial<SavedResource> {
|
||||
return { name: raw.settings.name ?? undefined, creatorAccount: raw.creator_account ?? undefined,
|
||||
createdAt: raw.created_at, expiresAt: raw.expires_at, seats: raw.settings.seats,
|
||||
maxDownloads: raw.settings.file_max_downloads,
|
||||
ttlSeconds: raw.settings.ttl_seconds, channelMetadata: raw };
|
||||
}
|
||||
export function shareCache(raw: ShareInfo): Partial<SavedResource> {
|
||||
if (raw.state !== 'draft' && raw.state !== 'sealed') throw new Error('分享状态无效');
|
||||
return { createdAt: raw.sealed_at ?? raw.created_at, expiresAt: raw.expires_at, state: raw.state,
|
||||
ttlSeconds: raw.settings.ttl_seconds, maxVisitors: raw.settings.max_visitors,
|
||||
maxDownloads: raw.settings.file_max_downloads || null, shareMetadata: raw };
|
||||
}
|
||||
export function isOfflineFailure(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === '网络连接失败';
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { SenderInfo } from '../types';
|
||||
import { accountDisplay } from './accountSession';
|
||||
|
||||
let lastAvatar: string | undefined;
|
||||
let thumbnail: Promise<string | undefined> | undefined;
|
||||
const thumbnails = new Map<string, Promise<string | undefined>>();
|
||||
async function compactAvatar(value: string): Promise<string | undefined> {
|
||||
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') return value.length <= 8000 ? value : undefined;
|
||||
try {
|
||||
@@ -23,7 +22,15 @@ async function compactAvatar(value: string): Promise<string | 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;
|
||||
const avatar = await avatarThumbnail(sender.avatar);
|
||||
return { username: sender.username, displayName: sender.displayName, ...(avatar ? { avatar } : {}) };
|
||||
}
|
||||
|
||||
export function avatarThumbnail(value: string): Promise<string | undefined> {
|
||||
let thumbnail = thumbnails.get(value);
|
||||
if (!thumbnail) {
|
||||
if (thumbnails.size >= 32) thumbnails.clear();
|
||||
thumbnail = compactAvatar(value); thumbnails.set(value, thumbnail);
|
||||
}
|
||||
return thumbnail;
|
||||
}
|
||||
@@ -4,12 +4,16 @@ import { fail } from './http';
|
||||
import { toCoreLink } from './links';
|
||||
import { clearFileCatalog } from './fileCatalog';
|
||||
import { clearTemporaryDownloads } from './downloadStorage';
|
||||
import { clearWorkspaceStructures } from './workspaceStructure';
|
||||
import { attachDataKeys, clearDataKeys } from './channelDataKeys';
|
||||
export const sessions = new Map<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 {
|
||||
clearDataKeys();
|
||||
clearWorkspaceStructures();
|
||||
clearFileCatalog();
|
||||
void clearTemporaryDownloads().catch(() => {});
|
||||
for (const controller of active.values()) controller.abort();
|
||||
@@ -31,5 +35,6 @@ export async function getSession(id: string): Promise<{ session: ChannelSession;
|
||||
if (resource.localScope !== currentResourceScope()) { session.dispose(); throw new Error('账号已切换'); }
|
||||
sessions.set(id, session);
|
||||
}
|
||||
attachDataKeys(session, resource);
|
||||
return { session, resource };
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export async function connectShare(id: string): Promise<void> {
|
||||
session = (await getSession(id)).session;
|
||||
if (controller.signal.aborted) return;
|
||||
const share = toShareInfo(raw, resource);
|
||||
resource.state = raw.state;
|
||||
resource.state = share.state;
|
||||
resource.expiresAt = raw.expires_at;
|
||||
await saveResource(resource);
|
||||
publishShare(id, { t: 'share', share });
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
|
||||
import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
|
||||
import { shareCache, isOfflineFailure } from './resourceMetadata';
|
||||
import type { ShareSettingsPatch } from '../generated/api';
|
||||
import { readShareInfo } from './shareState';
|
||||
import type { Share, ShareSettings } from '../types';
|
||||
|
||||
import { active, blobs, getSession, sessions } from './sessions';
|
||||
@@ -29,11 +32,12 @@ export async function createShare(settings: ShareSettings, captchaToken?: string
|
||||
});
|
||||
const raw = await response.json() as ShareWire;
|
||||
const resource: SavedResource = {
|
||||
...shareCache(raw),
|
||||
id: raw.id, kind: 'share', mode: 'enc', role: 'owner', mine: true,
|
||||
link: toShareLink(session.link(self.location.origin)),
|
||||
ownerLink: toShareLink(session.link(self.location.origin, 'owner')),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret),
|
||||
createdAt: raw.created_at, expiresAt: raw.expires_at, seats: 0, membersCanUpload: false,
|
||||
createdAt: raw.created_at, expiresAt: raw.expires_at, seats: 0,
|
||||
state: 'draft', maxVisitors: raw.settings.max_visitors,
|
||||
ttlSeconds: raw.settings.ttl_seconds,
|
||||
maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
|
||||
@@ -56,10 +60,11 @@ export async function joinShare(input: { link: { id: string; key: string } }): P
|
||||
const raw = await readJson<ShareWire>(`/shares/${input.link.id}`, session.authHeaders('read'));
|
||||
if (raw.state !== 'sealed') throw fail(404, 'not_found');
|
||||
const resource: SavedResource = {
|
||||
...shareCache(raw),
|
||||
id: input.link.id, kind: 'share', mode: 'enc', role: 'member', mine: false,
|
||||
link: toShareLink(makeLink(self.location.origin, input.link.id, { k: input.link.key })),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: raw.created_at, expiresAt: raw.expires_at,
|
||||
seats: 0, membersCanUpload: false, state: raw.state,
|
||||
seats: 0, state: raw.state,
|
||||
ttlSeconds: raw.settings.ttl_seconds,
|
||||
maxVisitors: raw.settings.max_visitors, maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
|
||||
};
|
||||
@@ -75,19 +80,15 @@ export async function joinShare(input: { link: { id: string; key: string } }): P
|
||||
export async function updateShareSettings(id: string, patch: Partial<ShareSettings>): Promise<Share> {
|
||||
const { session, resource } = await getSession(id);
|
||||
if (resource.kind !== 'share' || !session.can('owner')) throw new Error('此分享不再可修改');
|
||||
const body: Record<string, number> = {};
|
||||
const body: ShareSettingsPatch = {};
|
||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||
if (patch.maxVisitors !== undefined) body.max_visitors = patch.maxVisitors;
|
||||
if (patch.maxDownloads !== undefined) body.file_max_downloads = patch.maxDownloads ?? 0;
|
||||
const raw = await apiFetch(`/shares/${id}/settings`, {
|
||||
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
}).then((response) => response.json() as Promise<ShareWire>);
|
||||
resource.expiresAt = raw.expires_at;
|
||||
resource.ttlSeconds = raw.settings.ttl_seconds;
|
||||
resource.maxVisitors = raw.settings.max_visitors;
|
||||
resource.maxDownloads = raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads;
|
||||
await saveResource(resource);
|
||||
return toShareInfo(raw, resource);
|
||||
const updated = await cacheResource(resource, shareCache(raw));
|
||||
return toShareInfo(raw, updated);
|
||||
}
|
||||
|
||||
export async function removeShare(id: string): Promise<void> {
|
||||
@@ -101,10 +102,25 @@ export async function removeShare(id: string): Promise<void> {
|
||||
|
||||
export async function listSavedShares(): Promise<Share[]> {
|
||||
const saved = await listResources('share');
|
||||
return saved.map((resource) => ({
|
||||
const rows = await Promise.all(saved.map(async (resource): Promise<Share | null> => {
|
||||
try {
|
||||
const { session } = await getSession(resource.id);
|
||||
const raw = await readShareInfo(session, resource);
|
||||
const info = toShareInfo(raw, resource);
|
||||
await cacheResource(resource, shareCache(raw));
|
||||
return { ...info, dataSource: 'server' };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'status' in error && (error.status === 404 || error.status === 410)) return null;
|
||||
if (!isOfflineFailure(error)) throw error;
|
||||
if (resource.shareMetadata) return { ...toShareInfo(resource.shareMetadata, resource), dataSource: 'cache' };
|
||||
return {
|
||||
id: resource.id, state: resource.state ?? 'draft', mine: resource.mine ?? false,
|
||||
link: resource.link, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
|
||||
ttlSeconds: resource.ttlSeconds ?? 86400, maxVisitors: resource.maxVisitors ?? 0,
|
||||
ttlSeconds: resource.ttlSeconds ?? Math.max(0, (resource.expiresAt - resource.createdAt) / 1000), maxVisitors: resource.maxVisitors ?? 0,
|
||||
maxDownloads: resource.maxDownloads ?? null,
|
||||
dataSource: 'cache',
|
||||
};
|
||||
}
|
||||
}));
|
||||
return rows.filter((row): row is Share => row !== null);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { fromBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core';
|
||||
import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, type SavedResource } from '../database';
|
||||
import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, nextQueuedLocation, dequeueLocation, type SavedResource } from '../database';
|
||||
import { registerItemLocation } from './workspaceStructure';
|
||||
import { verifyObjectEvent } from './channelDataKeys';
|
||||
import { apiFetch, apiUpload, type ApiError } from './http';
|
||||
import { signContentEvent } from './accountSigning';
|
||||
import { accountToken } from './accountSession';
|
||||
import { serial } from './serial';
|
||||
import { storeVerifiedDownload } from './downloadStorage';
|
||||
|
||||
@@ -13,23 +17,40 @@ export async function persistPlan(
|
||||
resource: SavedResource,
|
||||
plan: ItemPlan,
|
||||
onProgress?: (frames: number, bytes: number) => void,
|
||||
signedEvent?: { channelId: string; domain: 'chat' | 'files'; epoch: number; eventKind: string; targetItemId?: string },
|
||||
): Promise<{ frames: number; bytes: number }> {
|
||||
const createdAt = Math.max(Date.now(), (await lastQueuedAt(resource.id)) + 1);
|
||||
let frames = 0;
|
||||
let bytes = 0;
|
||||
for await (const frame of plan.frames) {
|
||||
const frameDigests: Uint8Array[] = [];
|
||||
let pending: { index: number; bytes: Uint8Array; extraHeaders?: Record<string, string> } | undefined;
|
||||
const persistFrame = async (frame: NonNullable<typeof pending>, extraHeaders: Record<string, string> = {}) => {
|
||||
await queueFrame({
|
||||
key: `${resource.id}:${plan.itemId}:${String(frame.index).padStart(10, '0')}`,
|
||||
resourceId: resource.id,
|
||||
itemId: plan.itemId,
|
||||
index: frame.index,
|
||||
bytes: frame.bytes,
|
||||
extraHeaders: frame.index === 0 ? { ...plan.frame0Headers } : {},
|
||||
extraHeaders: { ...(frame.index === 0 ? plan.frame0Headers : {}), ...frame.extraHeaders, ...extraHeaders },
|
||||
createdAt: createdAt + frame.index,
|
||||
});
|
||||
frames += 1;
|
||||
bytes += frame.bytes.byteLength;
|
||||
onProgress?.(frames, bytes);
|
||||
};
|
||||
for await (const frame of plan.frames) {
|
||||
if (pending) await persistFrame(pending);
|
||||
const frameBuffer = frame.bytes.slice().buffer as ArrayBuffer;
|
||||
frameDigests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', frameBuffer)));
|
||||
pending = frame;
|
||||
}
|
||||
if (pending) {
|
||||
const headers = signedEvent && resource.kind === 'channel' && accountToken()
|
||||
? await signContentEvent({ spaceId: resource.id, channelId: signedEvent.channelId, domain: signedEvent.domain,
|
||||
epoch: signedEvent.epoch, itemId: plan.itemId, eventKind: signedEvent.eventKind,
|
||||
...(signedEvent.targetItemId ? { targetItemId: signedEvent.targetItemId } : {}), frameDigests })
|
||||
: {};
|
||||
await persistFrame(pending, headers);
|
||||
}
|
||||
return { frames, bytes };
|
||||
}
|
||||
@@ -56,7 +77,20 @@ async function flush(
|
||||
): Promise<boolean> {
|
||||
for (;;) {
|
||||
const frame = await nextQueuedFrame(resource.id);
|
||||
if (!frame) return true;
|
||||
if (!frame) {
|
||||
if (resource.kind !== 'channel') return true;
|
||||
const location = await nextQueuedLocation(resource.id);
|
||||
if (!location) return true;
|
||||
try {
|
||||
await registerItemLocation(resource.id, { id: location.itemId, threadId: location.threadId, folderId: location.folderId });
|
||||
await dequeueLocation(location.key);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === '网络连接失败') return false;
|
||||
if ((error as ApiError).status === 404 || (error as ApiError).status === 410) await dequeueLocation(location.key);
|
||||
throw error;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const role = resource.kind === 'share' ? 'owner' : writeRole(session);
|
||||
const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`;
|
||||
let maxLoadedBytes = 0;
|
||||
@@ -104,5 +138,9 @@ export async function downloadDecrypted(
|
||||
const expectedId = fromBase64Url(itemId, 16);
|
||||
if (!expectedId) throw new IntegrityError('bad_meta');
|
||||
const decoded = session.decryptItemStream(response.body, { itemId: expectedId });
|
||||
return storeVerifiedDownload(decoded, expectedSize);
|
||||
return storeVerifiedDownload(decoded, expectedSize, undefined, async result => {
|
||||
if (resource.kind === 'channel' && (!result.ciphertextManifestHash
|
||||
|| !await verifyObjectEvent(resource, session, itemId, result.ciphertextManifestHash)))
|
||||
throw new IntegrityError('auth_failed');
|
||||
});
|
||||
}
|
||||
@@ -1,40 +1,21 @@
|
||||
import type { SavedResource } from '../database';
|
||||
import type { ChannelInfo, Share, ShareSettings } from '../types';
|
||||
export interface ChannelWire {
|
||||
creator_account?: string;
|
||||
password_required?: boolean;
|
||||
id: string;
|
||||
mode: 'enc' | 'raw';
|
||||
settings: { name?: string; join_policy?: 'open' | 'invite'; allow_guests?: boolean; seats: number; members_can_upload: boolean; delete_policy: 'owner_only' | 'own' | 'anyone'; ttl_seconds: number; max_items: number; file_max_downloads: number };
|
||||
seats_used: number;
|
||||
oldest_seq: number;
|
||||
last_seq: number;
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface ShareWireItem { item_id: string; bytes: number; frames: number; complete?: boolean; max_downloads: number; downloads_left: number | null }
|
||||
export interface ShareWire {
|
||||
id: string;
|
||||
state: 'draft' | 'sealed';
|
||||
expires_at: number;
|
||||
created_at: number;
|
||||
sealed_at?: number;
|
||||
manifest_item_id?: string;
|
||||
settings: { ttl_seconds: number; max_visitors: number; file_max_downloads: number };
|
||||
items: ShareWireItem[];
|
||||
}
|
||||
import type { ChannelInfo as ChannelWire, ShareInfo as ShareWire, ShareItemInfo as ShareWireItem } from '../generated/api';
|
||||
export type { ChannelWire, ShareWire, ShareWireItem };
|
||||
export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelInfo {
|
||||
const joinPolicy = raw.settings.join_policy;
|
||||
if (joinPolicy !== 'open' && joinPolicy !== 'invite') throw new Error('空间加入规则无效');
|
||||
return {
|
||||
id: raw.id,
|
||||
creatorAccount: raw.creator_account,
|
||||
joinPolicy: raw.settings.join_policy ?? 'open', allowGuests: raw.settings.allow_guests ?? true, passwordRequired: raw.password_required ?? false,
|
||||
name: raw.settings.name,
|
||||
creatorAccount: raw.creator_account ?? undefined,
|
||||
joinPolicy, allowGuests: raw.settings.allow_guests, passwordRequired: raw.password_required,
|
||||
name: raw.settings.name ?? undefined,
|
||||
mode: raw.mode,
|
||||
role: saved.role,
|
||||
canUpload: saved.role === 'owner' || raw.settings.members_can_upload,
|
||||
role: raw.can_manage_structure ? 'owner' : 'member',
|
||||
canUpload: raw.can_upload,
|
||||
canManageStructure: raw.can_manage_structure,
|
||||
seats: raw.settings.seats,
|
||||
seatsUsed: raw.seats_used,
|
||||
membersUsed: raw.seats_used,
|
||||
membersCanUpload: raw.settings.members_can_upload,
|
||||
ttlSeconds: raw.settings.ttl_seconds,
|
||||
maxDownloads: raw.settings.file_max_downloads,
|
||||
@@ -46,6 +27,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
|
||||
}
|
||||
|
||||
export function toShareInfo(raw: ShareWire, saved: SavedResource): Share {
|
||||
if (raw.state !== 'draft' && raw.state !== 'sealed') throw new Error('分享状态无效');
|
||||
return {
|
||||
id: raw.id,
|
||||
state: raw.state,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ChannelSession, collectFrames } from '@webdrop/core';
|
||||
import { readResourceRecord } from './workspaceRecords';
|
||||
|
||||
const threadId = '15e7fb09-984d-47d4-8e74-477c7d69c58c';
|
||||
async function decode(record: Record<string, unknown>) {
|
||||
const { session } = await ChannelSession.create();
|
||||
try {
|
||||
const frames = await collectFrames(session.encryptBytes(new TextEncoder().encode(JSON.stringify({ app: 'webdrop.workspace', v: 1, ...record })),
|
||||
{ kind: 'other' }, { control: true, ts: 1 }));
|
||||
const event = (await session.createItemReader().pushFrame(frames[0]!.bytes)).find(row => row.type === 'item');
|
||||
if (!event || event.type !== 'item') throw new Error('Missing decrypted record');
|
||||
return readResourceRecord(event.item);
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
describe('encrypted resource records', () => {
|
||||
it('ignores client-defined account and structure operations', async () => {
|
||||
for (const op of ['thread.create', 'group.create', 'folder.create', 'items.move', 'item.author']) expect(await decode({ op })).toBeNull();
|
||||
});
|
||||
it('renders authenticated file metadata without accepting client-supplied identity or UI state', async () => {
|
||||
const value = await decode({ op: 'file.published', item: { id: 'AAAAAAAAAAAAAAAAAAAAAA', threadId, kind: 'file', name: 'private.txt', mime: 'text/plain', size: 10, gated: true,
|
||||
author: { username: 'fake', displayName: '伪造资料' }, state: { kind: 'tampered' } } });
|
||||
expect(value).toMatchObject({ t: 'file-catalog', item: { name: 'private.txt', state: { kind: 'complete' } } });
|
||||
if (value && value.t === 'file-catalog') expect(value.item).not.toHaveProperty('author');
|
||||
});
|
||||
it('rejects malformed encrypted file metadata', async () => {
|
||||
expect(await decode({ op: 'file.published', item: { id: 'invalid', threadId, kind: 'file', size: -1 } })).toMatchObject({ t: 'error' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReceivedItem } from '@webdrop/core';
|
||||
import type { WorkspaceEvent } from '../types';
|
||||
import { WORKSPACE_APP, type PublishedFile } from '../protocol/workspace';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
|
||||
const isId = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value);
|
||||
const isItemId = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value);
|
||||
|
||||
/** Decode encrypted resource metadata only; account and structure data come from the backend. */
|
||||
export function readResourceRecord(item: ReceivedItem): WorkspaceEvent | false | null {
|
||||
if (item.meta?.kind !== 'other') return false;
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(new TextDecoder().decode(item.content)); } catch { return false; }
|
||||
if (!value || typeof value !== 'object' || (value as { app?: unknown }).app !== WORKSPACE_APP) return false;
|
||||
const row = value as Record<string, unknown>;
|
||||
if (row.v !== 1 || typeof row.op !== 'string') return { t: 'error', message: '资源记录格式无效,已拒绝应用。' };
|
||||
if (row.op !== 'file.published') return null;
|
||||
const file = row.item as PublishedFile | undefined;
|
||||
if (!file || !isItemId(file.id) || !isId(file.threadId) || (file.folderId !== undefined && !isId(file.folderId))
|
||||
|| file.kind !== 'file' || typeof file.name !== 'string' || file.name.length > 1024 || typeof file.mime !== 'string' || file.mime.length > 256
|
||||
|| !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean')
|
||||
return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' };
|
||||
return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, folderId: file.folderId,
|
||||
name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, deviceId: item.meta.dev ?? '未知设备',
|
||||
gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest } from '../generated/api';
|
||||
import type { FileItem } from '../types';
|
||||
import { getResource, cacheResource } from '../database';
|
||||
import { getSession } from './sessions';
|
||||
import { apiFetch } from './http';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { avatarThumbnail } from './senderSnapshot';
|
||||
|
||||
const current = new Map<string, WorkspaceBody>();
|
||||
export function clearWorkspaceStructures(): void { current.clear(); }
|
||||
async function publish(id: string, body: WorkspaceBody): Promise<WorkspaceBody> {
|
||||
const previous = current.get(id);
|
||||
if (previous && previous.structure.revision > body.structure.revision) return previous;
|
||||
const previousPeople = new Map(previous?.people.map(person => [person.username, person]));
|
||||
body = { ...body, people: body.people.map(person => {
|
||||
const newer = previousPeople.get(person.username);
|
||||
return newer && newer.modifiedAt > person.modifiedAt ? newer : person;
|
||||
}) };
|
||||
current.set(id, body);
|
||||
const resource = await getResource(id);
|
||||
if (resource) await cacheResource(resource, { workspaceMetadata: body });
|
||||
const people = await Promise.all(body.people.map(async person => ({ ...person, avatar: person.avatar ? await avatarThumbnail(person.avatar) : undefined })));
|
||||
if (current.get(id) !== body) return current.get(id) ?? body;
|
||||
publishWorkspace(id, { t: 'structure', state: body.structure, people });
|
||||
return body;
|
||||
}
|
||||
export async function loadWorkspace(id: string): Promise<WorkspaceBody> {
|
||||
const { session } = await getSession(id);
|
||||
const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>);
|
||||
return publish(id, body);
|
||||
}
|
||||
export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT', value: unknown): Promise<WorkspaceBody> {
|
||||
const { session } = await getSession(id);
|
||||
const body = await apiFetch(`/spaces/${id}/${path}`, {
|
||||
method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, body: JSON.stringify(value),
|
||||
}).then(r => r.json() as Promise<WorkspaceBody>);
|
||||
await publish(id, body);
|
||||
return body;
|
||||
}
|
||||
export async function createWorkspaceChannel(id: string, value: WorkspaceChannelRequest): Promise<string> {
|
||||
const { session } = await getSession(id);
|
||||
const body = await apiFetch(`/spaces/${id}/channels`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(value) })
|
||||
.then(r => r.json() as Promise<WorkspaceChannelCreatedBody>);
|
||||
await publish(id, body.workspace);
|
||||
return body.channel.id;
|
||||
}
|
||||
export async function createWorkspaceGroup(id: string, value: WorkspaceNameRequest): Promise<string> {
|
||||
const { session } = await getSession(id);
|
||||
const body = await apiFetch(`/spaces/${id}/groups`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(value) })
|
||||
.then(r => r.json() as Promise<WorkspaceGroupCreatedBody>);
|
||||
await publish(id, body.workspace);
|
||||
return body.group.id;
|
||||
}
|
||||
export async function registerItemLocation(id: string, item: Pick<FileItem, 'id' | 'threadId' | 'folderId'>): Promise<void> {
|
||||
if (!item.threadId) return;
|
||||
await changeWorkspace(id, 'locations', 'PUT', { itemIds: [item.id], threadId: item.threadId, folderId: item.folderId } satisfies WorkspaceMoveRequest);
|
||||
}
|
||||
@@ -5,12 +5,9 @@ import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
import { ChannelInvitations } from './ChannelInvitations';
|
||||
|
||||
const TTL_OPTIONS = [
|
||||
{ label: '1 小时', value: 3600 },
|
||||
{ label: '24 小时', value: 86400 },
|
||||
{ label: '7 天', value: 604800 },
|
||||
];
|
||||
const DOWNLOAD_OPTIONS = [0, 1, 3, 5, 10, 20];
|
||||
import { useServerSettings } from '@/store/server';
|
||||
import type { ServerInfoBody } from '@/core-bridge/generated/api';
|
||||
import { ttlLabel, withCurrent } from '@/lib/settingOptions';
|
||||
|
||||
interface Props {
|
||||
channel: ChannelInfo;
|
||||
@@ -18,23 +15,28 @@ interface Props {
|
||||
onSave: (patch: ChannelSettingsPatch) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) {
|
||||
export function ChannelSettingsSheet(props: Props) {
|
||||
const { info, error, load } = useServerSettings();
|
||||
if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined || props.channel.allowGuests === undefined)
|
||||
return <Sheet title="空间设置" onClose={props.onClose}><p role={error ? 'alert' : 'status'}>{error ?? '正在读取空间设置…'}</p>
|
||||
{error ? <Button onClick={() => { void load().catch(() => {}); }}>重试</Button> : null}</Sheet>;
|
||||
return <SettingsForm {...props} info={info} />;
|
||||
}
|
||||
function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) {
|
||||
const owner = channel.role === 'owner';
|
||||
const [name, setName] = useState(channel.name ?? '');
|
||||
const [seats, setSeats] = useState(channel.seats);
|
||||
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload ?? channel.canUpload ?? true);
|
||||
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds ?? 86400);
|
||||
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads ?? 10);
|
||||
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy ?? 'open');
|
||||
const [allowGuests, setAllowGuests] = useState(channel.allowGuests ?? true);
|
||||
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!);
|
||||
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
|
||||
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
|
||||
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy!);
|
||||
const [allowGuests, setAllowGuests] = useState(channel.allowGuests!);
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const ttlOptions = TTL_OPTIONS.some((option) => option.value === ttlSeconds)
|
||||
? TTL_OPTIONS
|
||||
: [{ label: `当前设置 (${ttlSeconds} 秒)`, value: ttlSeconds }, ...TTL_OPTIONS];
|
||||
const downloadOptions = [...new Set([...DOWNLOAD_OPTIONS, fileMaxDownloads])].sort((a, b) => a - b);
|
||||
const ttlOptions = withCurrent(info.choices.ttl_seconds, ttlSeconds);
|
||||
const downloadOptions = withCurrent(info.choices.file_max_downloads, fileMaxDownloads);
|
||||
|
||||
async function save() {
|
||||
if (!owner || saving) return;
|
||||
@@ -61,14 +63,14 @@ export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) {
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>设备席位</span>
|
||||
<input type="number" min={1} max={64} step={1} value={seats} disabled={!owner} onChange={(event) => setSeats(Number(event.target.value))} />
|
||||
<span>成员人数上限</span>
|
||||
<input type="number" min={1} max={info.limits.max_seats} step={1} value={seats} disabled={!owner} onChange={(event) => setSeats(Number(event.target.value))} />
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>内容保留时间</span>
|
||||
<select value={ttlSeconds} disabled={!owner} onChange={(event) => setTtlSeconds(Number(event.target.value))}>
|
||||
{ttlOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
{ttlOptions.map(value => <option key={value} value={value}>{ttlLabel(value)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -99,7 +101,7 @@ export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) {
|
||||
{owner ? (
|
||||
<>
|
||||
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
|
||||
<Button variant="primary" disabled={saving || seats < 1 || seats > 64 || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
|
||||
<Button variant="primary" disabled={saving || seats < 1 || seats > info.limits.max_seats || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
|
||||
</>
|
||||
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,7 @@ import styles from './ThreadSidebar.module.css';
|
||||
export function ChannelSwitcher({ id, onCreate }: { id?: string; onCreate: () => void }) {
|
||||
const channels = useChannels((s) => s.list);
|
||||
const username = useAccount((s) => s.username);
|
||||
const personal = useAccount((s) => s.personal);
|
||||
return <section className={styles.panel} aria-label="我的空间导航">
|
||||
<button className={styles.identity} type="button" onClick={() => navigate('/')} aria-label="账号与设备设置">
|
||||
{personal?.avatar ? <img src={personal.avatar} className={styles.avatar} alt="" /> : <Icon name="desktop" size={20} />}
|
||||
<span>{personal?.displayName || username || '访客'}</span>
|
||||
</button>
|
||||
<div className={styles.heading}><strong>空间</strong><Button variant="ghost" icon="edit" label="新建空间" disabled={!username} onClick={onCreate} /></div>
|
||||
<nav className={styles.channels} aria-label="空间列表">{channels.map((channel) => <button key={channel.id} type="button"
|
||||
className={`${styles.thread} ${channel.id === id ? styles.selected : ''}`} aria-current={channel.id === id ? 'page' : undefined}
|
||||
|
||||
@@ -8,28 +8,31 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './ChatPane.module.css';
|
||||
|
||||
export function ChatMessage({ id, onOpenFile, onDelete }: { id: string; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const owner = useWorkspace((s) => s.channel?.role === 'owner');
|
||||
const ownDevice = useWorkspace((s) => s.devices.find((device) => device.self)?.name);
|
||||
const username = useAccount((s) => s.username);
|
||||
const download = useDownload();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
if (!item) return null;
|
||||
const sender = item.author;
|
||||
const name = sender?.displayName || sender?.username || item.deviceId || '未知用户';
|
||||
const self = sender?.username !== undefined && sender.username === username;
|
||||
const self = sender?.username !== undefined ? sender.username === username : Boolean(ownDevice && item.deviceId === ownDevice);
|
||||
const ready = item.state.kind === 'complete';
|
||||
async function save() {
|
||||
if (!item || downloading) return;
|
||||
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
|
||||
}
|
||||
return <article className={`${styles.message} ${self ? styles.mine : ''}`} aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`}>
|
||||
{sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
||||
return <article className={`${styles.message} ${self ? styles.mine : ''} ${compact ? styles.compact : ''}`} title={compact ? `${name} · ${formatTime(item.ts)}` : undefined}
|
||||
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`}>
|
||||
{compact ? <span className={styles.avatarSpacer} aria-hidden="true" /> : sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
||||
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
|
||||
<div className={styles.bubble}>
|
||||
<header><strong className={styles.senderName} title={sender ? `${sender.username} · ${item.deviceId}` : item.deviceId}>{name}</strong><time>{formatTime(item.ts)}</time>
|
||||
{owner || self ? <Button variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
|
||||
</header>
|
||||
{!compact ? <header><strong className={styles.senderName} title={sender ? `${sender.username} · ${item.deviceId}` : item.deviceId}>{name}</strong><time>{formatTime(item.ts)}</time>
|
||||
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
|
||||
</header> : null}
|
||||
{compact && (owner || self) ? <Button className={`${styles.deleteAction} ${styles.compactDelete}`} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
|
||||
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
|
||||
: <div className={styles.fileMessage}>
|
||||
<button type="button" className={styles.fileCard} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
.messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
@@ -39,22 +39,34 @@
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.mine {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.compact {
|
||||
margin-top: calc(var(--space-2) * -1);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-avatar);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarSpacer {
|
||||
width: 40px;
|
||||
height: 1px;
|
||||
flex: 0 0 40px;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -64,16 +76,10 @@
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
max-width: min(85%, 560px);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.mine .bubble {
|
||||
background: var(--accent-soft);
|
||||
max-width: min(82%, 760px);
|
||||
padding: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.message header {
|
||||
@@ -87,18 +93,58 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message header time {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.deleteAction {
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
min-height: 26px;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease-out;
|
||||
}
|
||||
|
||||
.message:hover .deleteAction,
|
||||
.message:focus-within .deleteAction {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.compactDelete {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: calc(var(--space-2) * -1);
|
||||
}
|
||||
|
||||
.messageText {
|
||||
margin: var(--space-1) 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dateDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-4) 0 var(--space-3);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dateDivider::before,
|
||||
.dateDivider::after {
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
background: var(--line);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.fileMessage {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { Fragment, useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import type { Item } from '@/core-bridge/types';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import styles from './ChatPane.module.css';
|
||||
|
||||
@@ -11,6 +14,7 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
|
||||
const [error, setError] = useState('');
|
||||
const messages = useRef<HTMLDivElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const byId = useWorkspace((s) => s.byId);
|
||||
const chronological = ids.slice().reverse();
|
||||
|
||||
useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]);
|
||||
@@ -34,7 +38,23 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
|
||||
<div className={styles.head}><strong>{threadName}</strong><span>频道聊天</span></div>
|
||||
<div className={styles.messages} ref={messages} aria-live="polite">
|
||||
{chronological.length === 0 ? <p className={styles.empty}>在这里发送消息或文件,成员可以看到你的名称和头像。</p> : null}
|
||||
{chronological.map((id) => <ChatMessage key={id} id={id} onOpenFile={onOpenFile} onDelete={onDelete} />)}
|
||||
{(() => {
|
||||
let previous: Item | undefined;
|
||||
return chronological.map((id) => {
|
||||
const item = byId[id];
|
||||
if (!item) return null;
|
||||
const dateKey = new Date(item.ts).toDateString();
|
||||
const previousDate = previous ? new Date(previous.ts).toDateString() : undefined;
|
||||
const showDate = dateKey !== previousDate;
|
||||
const sameSender = previous && (previous.author?.username ?? previous.deviceId) === (item.author?.username ?? item.deviceId);
|
||||
const grouped = Boolean(sameSender && !showDate && item.ts - previous!.ts < 5 * 60_000);
|
||||
previous = item;
|
||||
return <Fragment key={id}>
|
||||
{showDate ? <div className={styles.dateDivider} role="separator"><span>{formatDate(item.ts)}</span></div> : null}
|
||||
<ChatMessage id={id} compact={grouped} onOpenFile={onOpenFile} onDelete={onDelete} />
|
||||
</Fragment>;
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); void submit(); }}>
|
||||
{onFiles ? <><input ref={fileInput} type="file" multiple hidden onChange={(event) => { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} />
|
||||
|
||||
@@ -16,38 +16,31 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.memberHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.memberHeading .h {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.device {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.deviceRow,
|
||||
.deviceIdentity,
|
||||
.deviceActions,
|
||||
.deviceEditor,
|
||||
.deviceMetaRow {
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deviceIdentity {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.deviceMetaRow {
|
||||
min-height: 32px;
|
||||
padding-left: var(--space-6);
|
||||
min-height: 48px;
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.name {
|
||||
@@ -56,55 +49,22 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.current,
|
||||
.deviceMeta {
|
||||
.memberRole,
|
||||
.memberCurrent,
|
||||
.memberTime {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.deviceMeta {
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deviceEditor {
|
||||
padding-left: var(--space-6);
|
||||
margin-top: var(--space-1);
|
||||
.memberCurrent {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.deviceActions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.deviceActions > button {
|
||||
min-height: 36px;
|
||||
padding-inline: var(--space-2);
|
||||
}
|
||||
|
||||
.deviceInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.deviceError {
|
||||
padding-left: var(--space-6);
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
.memberRole,
|
||||
.memberTime {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.link {
|
||||
|
||||
@@ -1,106 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { formatAgo } from '@/lib/format';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { CopyButton } from '@/ui/CopyButton';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { QrCode } from '@/ui/QrCode';
|
||||
import styles from './DevicePanel.module.css';
|
||||
|
||||
export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
const devices = useWorkspace((s) => s.devices);
|
||||
const deviceCount = useWorkspace((s) => s.devices.length);
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const simulate = useWorkspace((s) => s.simulateConnection);
|
||||
const renameDevice = useWorkspace((s) => s.renameDevice);
|
||||
const releaseDevice = useWorkspace((s) => s.releaseDevice);
|
||||
const username = useAccount((s) => s.username);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
const [membersError, setMembersError] = useState('');
|
||||
const [showOwner, setShowOwner] = useState(false);
|
||||
const [showQr, setShowQr] = useState(qrOpen);
|
||||
const [editingId, setEditingId] = useState<string>();
|
||||
const [nameDraft, setNameDraft] = useState('');
|
||||
const [confirmKickId, setConfirmKickId] = useState<string>();
|
||||
const [busyId, setBusyId] = useState<string>();
|
||||
const [deviceError, setDeviceError] = useState<{ id: string; message: string }>();
|
||||
const owner = channel?.role === 'owner';
|
||||
const seatsUsed = channel?.seatsUsed ?? '—';
|
||||
const membersUsed = channel?.membersUsed ?? '—';
|
||||
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
|
||||
const refreshMembers = useCallback(async () => {
|
||||
if (!channel?.id) return;
|
||||
setMembersError('');
|
||||
try { setMembers(await channelAccessApi.members(channel.id)); }
|
||||
catch { setMembersError('成员清单读取失败'); }
|
||||
}, [channel?.id]);
|
||||
|
||||
async function saveDeviceName(id: string) {
|
||||
if (busyId) return;
|
||||
setBusyId(id);
|
||||
setDeviceError(undefined);
|
||||
try {
|
||||
await renameDevice(id, nameDraft);
|
||||
setEditingId(undefined);
|
||||
} catch {
|
||||
setDeviceError({ id, message: '设备名称保存失败,请重试。' });
|
||||
} finally {
|
||||
setBusyId(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function kickDevice(id: string) {
|
||||
if (busyId) return;
|
||||
setBusyId(id);
|
||||
setDeviceError(undefined);
|
||||
try {
|
||||
await releaseDevice(id);
|
||||
setConfirmKickId(undefined);
|
||||
} catch {
|
||||
setDeviceError({ id, message: '移除设备失败,请重试。' });
|
||||
} finally {
|
||||
setBusyId(undefined);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
void refreshMembers();
|
||||
}, [refreshMembers, deviceCount]);
|
||||
|
||||
return (
|
||||
<aside className={styles.panel}>
|
||||
<section>
|
||||
<h2 className={styles.h}>设备 <span className={styles.dim}>{seatsUsed}/{channel?.seats} 已加入</span></h2>
|
||||
<div className={styles.memberHeading}>
|
||||
<h2 className={styles.h}>成员 <span className={styles.dim}>{membersUsed}/{channel?.seats} 人</span></h2>
|
||||
<Button variant="ghost" disabled={!channel} onClick={() => { void refreshMembers(); }}>刷新</Button>
|
||||
</div>
|
||||
<ul className={styles.list}>
|
||||
{devices.map((d) => (
|
||||
<li key={d.id} className={styles.device}>
|
||||
<div className={styles.deviceRow}>
|
||||
<div className={styles.deviceIdentity}>
|
||||
<Icon name={d.kind} size={18} />
|
||||
<span className={styles.name}>{d.name}</span>
|
||||
</div>
|
||||
{(owner || d.self) && editingId !== d.id ? (
|
||||
<Button variant="ghost" icon="edit" label={`重命名 ${d.name}`} onClick={() => { setEditingId(d.id); setNameDraft(d.name); setDeviceError(undefined); }} />
|
||||
) : null}
|
||||
{owner && !d.self ? (
|
||||
confirmKickId !== d.id ? <Button variant="ghost" icon="trash" label={`移除 ${d.name}`} onClick={() => { setConfirmKickId(d.id); setDeviceError(undefined); }} /> : null
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.deviceMetaRow}>
|
||||
<span className={d.self ? styles.current : styles.deviceMeta}>
|
||||
{d.self ? '当前设备' : d.joinedAt ? `加入于${formatAgo(d.joinedAt)}` : '已加入'}
|
||||
</span>
|
||||
{owner && confirmKickId === d.id ? (
|
||||
<span className={styles.deviceActions}>
|
||||
<Button variant="ghost" disabled={busyId === d.id} onClick={() => void kickDevice(d.id)}>{busyId === d.id ? '移除中' : '确认移除'}</Button>
|
||||
<Button variant="ghost" disabled={busyId === d.id} onClick={() => setConfirmKickId(undefined)}>取消</Button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{editingId === d.id ? (
|
||||
<div className={styles.deviceEditor}>
|
||||
<input
|
||||
className={styles.deviceInput}
|
||||
type="text"
|
||||
maxLength={48}
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') void saveDeviceName(d.id); }}
|
||||
aria-label={`设备名称 ${d.name}`}
|
||||
/>
|
||||
<Button variant="ghost" disabled={busyId === d.id || !nameDraft.trim()} onClick={() => void saveDeviceName(d.id)}>{busyId === d.id ? '保存中' : '保存'}</Button>
|
||||
<Button variant="ghost" onClick={() => setEditingId(undefined)}>取消</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{deviceError?.id === d.id ? <span className={styles.deviceError} role="alert">{deviceError.message}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
{members.map((member) => <li key={member.username} className={styles.member}>
|
||||
<span className={styles.name}>{member.username}</span>
|
||||
<span className={member.username === username ? styles.memberCurrent : styles.memberRole}>{member.username === username ? '你' : member.role === 'owner' ? '空间主' : '成员'}</span>
|
||||
<time className={styles.memberTime}>{formatAgo(member.joined_at)}</time>
|
||||
</li>)}
|
||||
</ul>
|
||||
{!members.length && !membersError ? <p className={styles.note}>暂无账号成员。</p> : null}
|
||||
{membersError ? <p className={styles.note} role="alert">{membersError}</p> : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -139,16 +83,6 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
</> : null}
|
||||
</section>
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<section>
|
||||
<h2 className={styles.h}>演示</h2>
|
||||
<div className={styles.row}>
|
||||
<Button variant="ghost" onClick={() => simulate('online')}>在线</Button>
|
||||
<Button variant="ghost" onClick={() => simulate('reconnecting')}>重连中</Button>
|
||||
<Button variant="ghost" onClick={() => simulate('offline')}>离线</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ interface Props {
|
||||
selectedId?: string;
|
||||
checked: ReadonlySet<string>;
|
||||
enabled: boolean;
|
||||
structureEnabled: boolean;
|
||||
onFolder: (id?: string) => void;
|
||||
onSelect: (id?: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
@@ -71,7 +72,7 @@ export function FileExplorer(props: Props) {
|
||||
<div className={styles.toolbar}>
|
||||
<input type="file" multiple hidden ref={input} onChange={(event) => { if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} />
|
||||
<Button icon="share" disabled={!props.enabled} onClick={() => input.current?.click()}>上传文件</Button>
|
||||
<Button variant="ghost" icon="folder" disabled={!props.enabled || busy} onClick={() => beginFolder()}>新建文件夹</Button>
|
||||
<Button variant="ghost" icon="folder" disabled={!props.structureEnabled || busy} onClick={() => beginFolder()}>新建文件夹</Button>
|
||||
<Button variant="ghost" icon="eye" disabled={!props.selectedId} onClick={() => { if (props.selectedId) props.onOpen(props.selectedId); }}>预览</Button>
|
||||
</div>
|
||||
<nav className={styles.breadcrumb} aria-label="文件夹路径"><Button variant="ghost" icon="chevronLeft" label="返回上级文件夹" disabled={!props.folderId} onClick={() => navigateFolder(path[path.length - 1]?.parentId)} />
|
||||
@@ -84,7 +85,7 @@ export function FileExplorer(props: Props) {
|
||||
onClick={(event) => { if (!(event.target as HTMLElement).closest('tr,button,input')) { props.onSelection(new Set()); props.onSelect(undefined); setSelectedFolder(undefined); event.currentTarget.focus(); } }} tabIndex={-1}>
|
||||
<table className={styles.table}><thead><tr><th aria-label="选择" /><th><button type="button" onClick={() => setSort('name')}>名称{sort === 'name' ? ' ↑' : ''}</button></th>
|
||||
<th><button type="button" onClick={() => setSort('size')}>大小{sort === 'size' ? ' ↓' : ''}</button></th><th className={styles.date}><button type="button" onClick={() => setSort('date')}>时间{sort === 'date' ? ' ↓' : ''}</button></th><th aria-label="操作" /></tr></thead>
|
||||
<tbody>{children.map((folder) => <ExplorerFolderRow key={folder.id} folder={folder} selected={folder.id === selectedFolder} enabled={props.enabled}
|
||||
<tbody>{children.map((folder) => <ExplorerFolderRow key={folder.id} folder={folder} selected={folder.id === selectedFolder} enabled={props.structureEnabled}
|
||||
dropping={interaction.dropFolder === folder.id} drop={interaction.dropHandlers(folder.id)} onOpen={() => navigateFolder(folder.id)}
|
||||
onSelect={() => { interaction.dismiss(); setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); }} onRename={() => beginFolder(folder.id)}
|
||||
onMenu={(x, y) => { setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); interaction.showFolderMenu(folder.id, folder.name, x, y); }} />)}
|
||||
|
||||
@@ -7,10 +7,10 @@ export function FilePaneResize({ width, onWidth }: { width?: number; onWidth: (w
|
||||
const parent = element.parentElement!;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const navigation = parent.querySelector<HTMLElement>('[data-space-nav]');
|
||||
return { right: rect.right, maximum: Math.max(320, rect.width - (navigation?.offsetWidth ?? 232) - 266) };
|
||||
return { right: rect.right, maximum: Math.max(320, rect.width - (navigation?.offsetWidth ?? 300) - 266) };
|
||||
}
|
||||
return <div className={styles.resize} role="separator" aria-label="调整文件区宽度" aria-orientation="vertical" tabIndex={0}
|
||||
aria-valuemin={320} aria-valuemax={Math.max(320, innerWidth - (innerWidth <= 1100 ? 200 : 232) - 266)} aria-valuenow={width ?? Math.max(320, Math.round(innerWidth * 0.36))}
|
||||
aria-valuemin={320} aria-valuemax={Math.max(320, innerWidth - (innerWidth <= 1100 ? 240 : 300) - 266)} aria-valuenow={width ?? Math.max(320, Math.round(innerWidth * 0.36))}
|
||||
onPointerDown={(event) => { event.preventDefault(); drag.current = bounds(event.currentTarget); event.currentTarget.setPointerCapture(event.pointerId); }}
|
||||
onPointerMove={(event) => { if (drag.current) onWidth(Math.min(drag.current.maximum, Math.max(320, drag.current.right - event.clientX))); }}
|
||||
onPointerUp={(event) => { drag.current = undefined; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function SideRail({ open, qrOpen, onToggle }: Props) {
|
||||
return (
|
||||
<aside className={styles.open}>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.heading}>设备与分享</span>
|
||||
<span className={styles.heading}>成员与分享</span>
|
||||
<Button variant="ghost" icon="chevronRight" label="收起" onClick={onToggle} />
|
||||
</div>
|
||||
<DevicePanel qrOpen={qrOpen} />
|
||||
@@ -27,10 +27,10 @@ export function SideRail({ open, qrOpen, onToggle }: Props) {
|
||||
|
||||
return (
|
||||
<aside className={styles.rail}>
|
||||
<Button variant="ghost" icon="chevronLeft" label="展开设备与分享" onClick={onToggle} />
|
||||
<span className={styles.count} title="已加入席位 / 总席位">
|
||||
{channel?.seatsUsed ?? '—'}/{channel?.seats ?? '—'}
|
||||
<span className={styles.countLabel}>席位</span>
|
||||
<Button variant="ghost" icon="chevronLeft" label="展开成员与分享" onClick={onToggle} />
|
||||
<span className={styles.count} title="已加入成员 / 最大成员人数">
|
||||
{channel?.membersUsed ?? '—'}/{channel?.seats ?? '—'}
|
||||
<span className={styles.countLabel}>人</span>
|
||||
</span>
|
||||
<Button variant="ghost" icon="share" label="分享" onClick={onToggle} />
|
||||
</aside>
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
gap: var(--space-2);
|
||||
max-height: 28vh;
|
||||
padding-inline: var(--space-1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.group {
|
||||
min-width: 0;
|
||||
margin-inline-start: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.groupHeading,
|
||||
@@ -31,14 +32,16 @@
|
||||
.groupToggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: var(--space-1) 0;
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -49,18 +52,40 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupToggle:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.groupToggle svg,
|
||||
.groupActions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupToggle small {
|
||||
margin-inline-start: auto;
|
||||
.categoryActions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
.groupMore {
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease-out;
|
||||
}
|
||||
|
||||
.groupHeading:hover .groupMore,
|
||||
.groupHeading:focus-within .groupMore {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.categoryActions button {
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
min-height: 26px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.channelEditor {
|
||||
@@ -71,6 +96,16 @@
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.modalEditor {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.channelEditor label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -91,27 +126,6 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--bar-h);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: var(--bar-h);
|
||||
height: var(--bar-h);
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.heading,
|
||||
.folderHeading,
|
||||
.threadRow,
|
||||
@@ -124,7 +138,12 @@
|
||||
.heading,
|
||||
.folderHeading {
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
min-height: 36px;
|
||||
padding-inline: var(--space-2);
|
||||
}
|
||||
|
||||
.folderHeading {
|
||||
@@ -142,9 +161,45 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.threads {
|
||||
padding-inline-start: var(--space-4);
|
||||
}
|
||||
|
||||
.nestedThreads {
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: var(--space-4);
|
||||
border-inline-start: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.threadRow,
|
||||
.folderRow {
|
||||
min-width: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.threadRow:hover,
|
||||
.activeRow {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
min-height: 26px;
|
||||
margin-inline-end: var(--space-1);
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease-out;
|
||||
}
|
||||
|
||||
.activeRow .rowActions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.threadRow:hover .rowActions,
|
||||
.threadRow:focus-within .rowActions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.folderRow {
|
||||
@@ -158,8 +213,8 @@
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 var(--space-2);
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
@@ -192,6 +247,12 @@
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.thread.selected {
|
||||
background: var(--busy-soft);
|
||||
color: var(--busy);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty,
|
||||
.hint,
|
||||
.error {
|
||||
@@ -201,6 +262,10 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.group > .empty {
|
||||
padding: var(--space-1) 0 var(--space-1) var(--space-6);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -249,4 +314,9 @@
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rowActions,
|
||||
.groupMore {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from 'react';
|
||||
import type { ChannelGroupInfo, ThreadInfo } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './ThreadSidebar.module.css';
|
||||
|
||||
interface Props {
|
||||
@@ -9,6 +10,8 @@ interface Props {
|
||||
groups: ChannelGroupInfo[];
|
||||
selectedThreadId?: string;
|
||||
enabled: boolean;
|
||||
owner: boolean;
|
||||
onHistory: (id: string, chatHistory: boolean, filesHistory: boolean) => Promise<void>;
|
||||
onSelectThread: (id: string) => void;
|
||||
onCreateThread: (name: string, groupId?: string) => Promise<string>;
|
||||
onRenameThread: (id: string, name: string) => Promise<void>;
|
||||
@@ -17,17 +20,23 @@ interface Props {
|
||||
onGroupThread: (id: string, groupId?: string) => Promise<void>;
|
||||
}
|
||||
type Editor = { kind: 'channel' | 'group'; id?: string; groupId?: string };
|
||||
const UNGROUPED = '__ungrouped__';
|
||||
|
||||
export function ThreadSidebar(props: Props) {
|
||||
const [edit, setEdit] = useState<Editor>();
|
||||
const [name, setName] = useState('');
|
||||
const [groupId, setGroupId] = useState('');
|
||||
const [chatHistory, setChatHistory] = useState(true);
|
||||
const [filesHistory, setFilesHistory] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
||||
const knownGroups = new Set(props.groups.map((group) => group.id));
|
||||
const ungrouped = props.threads.filter((thread) => !thread.groupId || !knownGroups.has(thread.groupId));
|
||||
function begin(target: Editor, initial = '') { setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError(''); }
|
||||
function begin(target: Editor, initial = '') {
|
||||
setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError('');
|
||||
const channel = props.threads.find(t => t.id === target.id); setChatHistory(channel?.chatHistory ?? true); setFilesHistory(channel?.filesHistory ?? true);
|
||||
}
|
||||
function toggle(id: string) { setCollapsed((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||
function select(id: string) { props.onSelectThread(id); setEdit(undefined); }
|
||||
async function submit(event: FormEvent) {
|
||||
@@ -40,6 +49,7 @@ export function ThreadSidebar(props: Props) {
|
||||
const original = props.threads.find((thread) => thread.id === edit.id);
|
||||
if (original?.name !== name.trim()) await props.onRenameThread(edit.id, name);
|
||||
if ((original?.groupId ?? '') !== groupId) await props.onGroupThread(edit.id, groupId || undefined);
|
||||
if (props.owner && (original?.chatHistory !== chatHistory || original?.filesHistory !== filesHistory)) await props.onHistory(edit.id, chatHistory, filesHistory);
|
||||
} else props.onSelectThread(await props.onCreateThread(name, groupId || undefined));
|
||||
if (groupId) setCollapsed((previous) => { const next = new Set(previous); next.delete(groupId); return next; });
|
||||
setEdit(undefined);
|
||||
@@ -47,46 +57,61 @@ export function ThreadSidebar(props: Props) {
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <section className={styles.panel} aria-label="空间频道导航">
|
||||
<div className={styles.heading}><strong>频道</strong><span className={styles.groupActions}>
|
||||
<Button variant="ghost" icon="folder" label="新建频道分组" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'group' })} />
|
||||
<Button variant="ghost" icon="edit" label="新建频道" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'channel' })} />
|
||||
</span></div>
|
||||
{ungrouped.length ? <div className={styles.groupLabel}>未分组</div> : null}
|
||||
<ChannelRows threads={ungrouped} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
|
||||
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} />
|
||||
<div className={styles.heading}><strong>频道</strong><Button variant="ghost" icon="folder" label="新建频道分组" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'group' })} /></div>
|
||||
{(ungrouped.length > 0 || props.groups.length === 0) ? <section className={styles.group} aria-label="未分组频道">
|
||||
<div className={styles.groupHeading}>
|
||||
<button className={styles.groupToggle} type="button" aria-label="未分组频道" aria-expanded={!collapsed.has(UNGROUPED)} onClick={() => toggle(UNGROUPED)}>
|
||||
<Icon name={collapsed.has(UNGROUPED) ? 'chevronRight' : 'chevronDown'} size={14} /><span>未分组</span>
|
||||
</button>
|
||||
{props.enabled ? <span className={styles.categoryActions}>
|
||||
<Button variant="ghost" icon="plus" label="在未分组新建频道" disabled={busy} onClick={() => begin({ kind: 'channel' })} />
|
||||
</span> : null}
|
||||
</div>
|
||||
{!collapsed.has(UNGROUPED) ? <ChannelRows threads={ungrouped} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
|
||||
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} /> : null}
|
||||
</section> : null}
|
||||
{props.groups.map((group) => {
|
||||
const threads = props.threads.filter((thread) => thread.groupId === group.id);
|
||||
return <section className={styles.group} aria-label={`频道分组 ${group.name}`} key={group.id}>
|
||||
<div className={styles.groupHeading}><button className={styles.groupToggle} type="button" aria-label={`分组 ${group.name}`} aria-expanded={!collapsed.has(group.id)} onClick={() => toggle(group.id)}>
|
||||
<Icon name={collapsed.has(group.id) ? 'chevronRight' : 'chevronDown'} size={14} /><span>{group.name}</span><small>{threads.length}</small>
|
||||
</button>{props.enabled ? <span className={styles.groupActions}>
|
||||
<Button variant="ghost" icon="edit" label={`重命名分组 ${group.name}`} disabled={busy} onClick={() => begin({ kind: 'group', id: group.id }, group.name)} />
|
||||
<Button variant="ghost" icon="file" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} />
|
||||
<Icon name={collapsed.has(group.id) ? 'chevronRight' : 'chevronDown'} size={14} /><span>{group.name}</span>
|
||||
</button>{props.enabled ? <span className={styles.categoryActions}>
|
||||
<Button variant="ghost" icon="plus" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} />
|
||||
<Button className={styles.groupMore} variant="ghost" icon="more" label={`分组设置 ${group.name}`} disabled={busy} onClick={() => begin({ kind: 'group', id: group.id }, group.name)} />
|
||||
</span> : null}</div>
|
||||
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
|
||||
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} nested onSelect={select}
|
||||
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} /> : null}
|
||||
{!collapsed.has(group.id) && !threads.length ? <p className={styles.empty}>此分组还没有频道。</p> : null}
|
||||
</section>;
|
||||
})}
|
||||
{edit ? <form className={styles.channelEditor} onSubmit={(event) => { void submit(event); }}>
|
||||
<label>{edit.kind === 'group' ? '分组名称' : '频道名称'}<input autoFocus disabled={busy} aria-label={edit.kind === 'group' ? '分组名称' : '频道名称'} maxLength={80} value={name} onChange={(event) => setName(event.target.value)} /></label>
|
||||
{edit.kind === 'channel' ? <label>所属分组<select aria-label="所属分组" disabled={busy} value={groupId} onChange={(event) => setGroupId(event.target.value)}>
|
||||
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||
</select></label> : null}
|
||||
<div className={styles.groupActions}><Button type="submit" variant="primary" disabled={busy || !name.trim()}>{edit.id ? '保存' : '创建'}</Button>
|
||||
<Button variant="ghost" onClick={() => setEdit(undefined)}>取消</Button></div>
|
||||
</form> : null}
|
||||
{edit ? <Sheet title={`${edit.id ? '设置' : '新建'}${edit.kind === 'group' ? '频道分组' : '频道'}`} onClose={() => { if (!busy) setEdit(undefined); }}>
|
||||
<form className={`${styles.channelEditor} ${styles.modalEditor}`} onSubmit={(event) => { void submit(event); }}>
|
||||
<label>{edit.kind === 'group' ? '分组名称' : '频道名称'}<input autoFocus disabled={busy} aria-label={edit.kind === 'group' ? '分组名称' : '频道名称'} maxLength={80} value={name} placeholder={edit.kind === 'channel' ? '例如:项目讨论' : '例如:项目组'} onChange={(event) => setName(event.target.value)} /></label>
|
||||
{edit.kind === 'channel' ? <label>所属分组<select aria-label="所属分组" disabled={busy} value={groupId} onChange={(event) => setGroupId(event.target.value)}>
|
||||
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||
</select></label> : null}
|
||||
{edit.kind === 'channel' && edit.id && props.owner ? <>
|
||||
<label><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
|
||||
<label><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
|
||||
</> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
<div className={`${styles.groupActions} ${styles.modalActions}`}>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => setEdit(undefined)}>取消</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy || !name.trim()}>{busy ? '保存中…' : edit.id ? '保存' : edit.kind === 'channel' ? '创建频道' : '创建分组'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet> : null}
|
||||
{!props.threads.length && !props.groups.length ? <p className={styles.empty}>创建一个频道后开始聊天和上传文件。</p> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{!edit && error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ChannelRows({ threads, selectedId, enabled, busy, onSelect, onEdit }: {
|
||||
threads: ThreadInfo[]; selectedId?: string; enabled: boolean; busy: boolean; onSelect: (id: string) => void; onEdit: (thread: ThreadInfo) => void;
|
||||
function ChannelRows({ threads, selectedId, enabled, busy, nested = false, onSelect, onEdit }: {
|
||||
threads: ThreadInfo[]; selectedId?: string; enabled: boolean; busy: boolean; nested?: boolean; onSelect: (id: string) => void; onEdit: (thread: ThreadInfo) => void;
|
||||
}) {
|
||||
return <ul className={styles.threads}>{threads.map((thread) => <li className={styles.threadRow} key={thread.id}>
|
||||
return <ul className={`${styles.threads} ${nested ? styles.nestedThreads : ''}`}>{threads.map((thread) => <li className={`${styles.threadRow} ${thread.id === selectedId ? styles.activeRow : ''}`} key={thread.id}>
|
||||
<button className={`${styles.thread} ${thread.id === selectedId ? styles.selected : ''}`} type="button" disabled={busy} aria-current={thread.id === selectedId ? 'page' : undefined}
|
||||
onClick={() => onSelect(thread.id)}><span aria-hidden="true">#</span><span>{thread.name}</span></button>
|
||||
{enabled && thread.id === selectedId ? <Button variant="ghost" icon="more" disabled={busy} label={`频道设置 ${thread.name}`} onClick={() => onEdit(thread)} /> : null}
|
||||
{enabled ? <Button className={styles.rowActions} variant="ghost" icon="more" disabled={busy} label={`频道设置 ${thread.name}`} onClick={() => onEdit(thread)} /> : null}
|
||||
</li>)}</ul>;
|
||||
}
|
||||
@@ -118,7 +118,7 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* 窄屏(如 iPhone SE、小屏 Android):收起席位数字与徽标文字 */
|
||||
/* 窄屏(如 iPhone SE、小屏 Android):收起成员人数与徽标文字 */
|
||||
@media (width < 360px) {
|
||||
.count {
|
||||
display: none;
|
||||
|
||||
@@ -8,7 +8,7 @@ const CONNECTION_TEXT = { online: '在线', reconnecting: '重连中', offline:
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
/** 手机上点开设备与分享面板;桌面端面板在右侧栏,不传 */
|
||||
/** 手机上点开成员与分享面板;桌面端面板在右侧栏,不传 */
|
||||
onOpenPanel?: () => void;
|
||||
onOpenSettings: () => void;
|
||||
searchOpen: boolean;
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filtering, onToggleSearch }: Props) {
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const connection = useWorkspace((s) => s.connection);
|
||||
const seatCount = `${channel?.seatsUsed ?? '—'}/${channel?.seats ?? '—'}`;
|
||||
const memberCount = `${channel?.membersUsed ?? '—'}/${channel?.seats ?? '—'}`;
|
||||
const raw = channel?.mode === 'raw';
|
||||
|
||||
return (
|
||||
@@ -39,8 +39,8 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filter
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className={styles.presence} onClick={onOpenPanel} disabled={!onOpenPanel}>
|
||||
<span className={styles.count} title="已加入席位 / 总席位" aria-label={`已加入 ${channel?.seatsUsed ?? '未知'} 个设备,共 ${channel?.seats ?? '未知'} 个席位`}>
|
||||
{seatCount} <span className={styles.countLabel}>席位</span>
|
||||
<span className={styles.count} title="已加入成员 / 最大成员人数" aria-label={`已加入 ${channel?.membersUsed ?? '未知'} 人,最多 ${channel?.seats ?? '未知'} 人`}>
|
||||
{memberCount} <span className={styles.countLabel}>人</span>
|
||||
</span>
|
||||
</button>
|
||||
<span className={`${styles.conn} ${styles[connection]}`} role="status">
|
||||
@@ -56,7 +56,7 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filter
|
||||
/>
|
||||
<ThemeMenu />
|
||||
<Button variant="ghost" icon="settings" label="空间设置" onClick={onOpenSettings} />
|
||||
{onOpenPanel ? <Button variant="ghost" icon="share" label="设备与分享" onClick={onOpenPanel} /> : null}
|
||||
{onOpenPanel ? <Button variant="ghost" icon="share" label="成员与分享" onClick={onOpenPanel} /> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './UploadOptionsSheet.module.css';
|
||||
|
||||
const LIMITS = [0, 1, 3, 5, 10, 20];
|
||||
import { useServerSettings } from '@/store/server';
|
||||
import { withCurrent } from '@/lib/settingOptions';
|
||||
|
||||
interface Props {
|
||||
encrypted?: boolean;
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUpload, encrypted = true }: Props) {
|
||||
const { info } = useServerSettings();
|
||||
const [directPreview, setDirectPreview] = useState(true);
|
||||
const [maxDownloads, setMaxDownloads] = useState(defaultMaxDownloads);
|
||||
|
||||
@@ -22,7 +24,7 @@ export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUplo
|
||||
<Sheet
|
||||
title={`上传 ${files.length} 个文件`}
|
||||
onClose={onClose}
|
||||
actions={<Button variant="primary" onClick={() => onUpload({ directPreview, maxDownloads })}>继续上传</Button>}
|
||||
actions={<Button variant="primary" disabled={!info} onClick={() => onUpload({ directPreview, maxDownloads })}>继续上传</Button>}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<label className={styles.check}>
|
||||
@@ -36,7 +38,7 @@ export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUplo
|
||||
<label className={styles.field}>
|
||||
<span>每个文件最多下载次数</span>
|
||||
<select value={maxDownloads} onChange={(e) => setMaxDownloads(Number(e.target.value))}>
|
||||
{Array.from(new Set([...LIMITS, defaultMaxDownloads])).sort((a, b) => a - b).map((n) => (
|
||||
{withCurrent(info?.choices.file_max_downloads ?? [], defaultMaxDownloads).map((n) => (
|
||||
<option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.desktop {
|
||||
--nav-width: 232px;
|
||||
--nav-width: 320px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--nav-width) - 266px));
|
||||
@@ -29,6 +29,12 @@
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@media (width >= 900px) {
|
||||
.navigation {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.files {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
@@ -84,7 +90,7 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.legacy {
|
||||
.pendingCatalog {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--text-dim);
|
||||
@@ -119,6 +125,6 @@
|
||||
|
||||
@media (width >= 900px) and (width <= 1100px) {
|
||||
.desktop {
|
||||
--nav-width: 200px;
|
||||
--nav-width: 272px;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const threads = useWorkspace((s) => s.threads);
|
||||
const groups = useWorkspace((s) => s.groups);
|
||||
const setChannelHistory = useWorkspace(s => s.setChannelHistory);
|
||||
const createGroup = useWorkspace((s) => s.createGroup);
|
||||
const renameGroup = useWorkspace((s) => s.renameGroup);
|
||||
const groupThread = useWorkspace((s) => s.groupThread);
|
||||
@@ -64,7 +65,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const activeThreadId = activeThread?.id;
|
||||
const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId);
|
||||
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
|
||||
const enabled = Boolean(activeThreadId && channel?.canUpload !== false);
|
||||
const enabled = Boolean(activeThreadId && channel?.canUpload);
|
||||
const files = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
||||
const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId
|
||||
&& (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter);
|
||||
@@ -101,19 +102,19 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
function drop(event: DragEvent) { event.preventDefault(); setDragging(false); requestFiles([...event.dataTransfer.files]); }
|
||||
function deleteItem(id: string) { void deleteFiles([id]).catch((cause: unknown) => useWorkspace.setState({ error: cause instanceof Error ? cause.message : '删除失败' })); }
|
||||
const navigation = <><ChannelSwitcher id={channel?.id} onCreate={onCreateChannel} />
|
||||
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={channel?.canUpload !== false} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
|
||||
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
|
||||
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
|
||||
{desktop ? <Notepad /> : null}<Button variant="ghost" icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button></>;
|
||||
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
|
||||
const explorer = <>
|
||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
||||
<FileExplorer key={activeThreadId} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'} folders={activeFolders} folderId={activeFolderId} ids={files}
|
||||
selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked}
|
||||
selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} structureEnabled={Boolean(activeThreadId && channel?.canManageStructure)} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked}
|
||||
onFiles={(incoming, target) => requestFiles(incoming, { folderId: target })}
|
||||
onCreateFolder={(name) => activeThreadId ? createFolder(activeThreadId, name, activeFolderId) : Promise.reject(new Error('请先选择频道'))}
|
||||
onRenameFolder={(id, name) => activeThreadId ? renameFolder(id, activeThreadId, name) : Promise.reject(new Error('请先选择频道'))}
|
||||
onDownloadItems={downloadItems} onDeleteItems={deleteFiles} onMoveItems={async (ids, target) => { if (activeThreadId) { await moveItems(ids, activeThreadId, target); setChecked(new Set()); setSelectedId(undefined); } }} />
|
||||
{restricted.length ? <div className={styles.legacy}><span>尚未同步目录的旧文件</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
|
||||
{restricted.length ? <div className={styles.pendingCatalog}><span>等待同步文件目录</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
|
||||
</>;
|
||||
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
|
||||
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
|
||||
@@ -124,7 +125,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||
{panelOpen ? <Sheet title="设备与账号邀请" onClose={() => setPanelOpen(false)}><DevicePanel /></Sheet> : null}
|
||||
{settingsOpen && channel ? <ChannelSettingsSheet channel={channel} onClose={() => setSettingsOpen(false)} onSave={updateSettings} /> : null}
|
||||
{upload ? <UploadOptionsSheet files={upload.files} encrypted={channel?.mode === 'enc'} defaultMaxDownloads={channel?.maxDownloads ?? 10} onClose={() => setUpload(undefined)} onUpload={confirmUpload} /> : null}
|
||||
{upload && channel?.maxDownloads !== undefined ? <UploadOptionsSheet files={upload.files} encrypted={channel?.mode === 'enc'} defaultMaxDownloads={channel.maxDownloads} onClose={() => setUpload(undefined)} onUpload={confirmUpload} /> : null}
|
||||
<Suspense fallback={null}>{previewId ? <PreviewSheet id={previewId} onClose={() => setPreviewId(undefined)} onDelete={deleteItem} /> : null}
|
||||
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onChannel={(resource) => navigate(channelPath(resource.id))} onShare={(resource) => navigate(sharePath(resource.id))} /> : null}</Suspense>
|
||||
{dragging ? <div className={styles.drop}>松开以上传到当前频道</div> : null}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MenuAction } from '@/ui/ContextMenu';
|
||||
|
||||
interface Options {
|
||||
scope: string; threadId?: string; ids: string[]; checked: ReadonlySet<string>; selectedId?: string; folderId?: string; enabled: boolean;
|
||||
structureEnabled: boolean;
|
||||
onSelect: (id?: string) => void; onSelection: (ids: ReadonlySet<string>) => void; onOpen: (id: string) => void;
|
||||
onDownload: (ids: string[]) => Promise<void>; onDelete: (ids: string[]) => Promise<void>; onMove: (ids: string[], folder?: string) => Promise<void>;
|
||||
onFiles: (files: File[], folder?: string) => void; onFolder: (id?: string) => void;
|
||||
@@ -87,9 +88,9 @@ export function useExplorerInteractions(options: Options) {
|
||||
{ label: '删除', icon: 'trash', disabled: !options.enabled || unavailable, action: () => run(options.onDelete(menu.targets)) });
|
||||
} else if (menu?.kind === 'folder' && menu.id) {
|
||||
actions.push({ label: '打开文件夹', icon: 'folder', action: () => { dismiss(); options.onFolder(menu.id); } },
|
||||
{ label: '重命名', icon: 'edit', disabled: !options.enabled, action: () => options.onRenameFolder(menu.id!) });
|
||||
{ label: '重命名', icon: 'edit', disabled: !options.structureEnabled, action: () => options.onRenameFolder(menu.id!) });
|
||||
} else if (menu) actions.push({ label: '上传文件', icon: 'share', disabled: !options.enabled, action: options.onUpload },
|
||||
{ label: '新建文件夹', icon: 'folder', disabled: !options.enabled, action: options.onCreateFolder },
|
||||
{ label: '新建文件夹', icon: 'folder', disabled: !options.structureEnabled, action: options.onCreateFolder },
|
||||
{ label: '全选文件', icon: 'check', disabled: !options.ids.length, action: () => options.onSelection(new Set(options.ids)) });
|
||||
return { menu, actions, hover, moveIds, setMoveIds, dropFolder, error, dismiss, leave, select, showFileMenu, showFolderMenu, blankMenu, hoverFile, drag, dropHandlers, keyDown };
|
||||
}
|
||||
@@ -22,17 +22,23 @@ export function AccountDevices() {
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : '设备更新失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <section aria-label="账号设备"><strong>我的设备</strong>
|
||||
return <section aria-label="账号设备">
|
||||
<div className={styles.sectionHeader}><strong>我的设备</strong>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => { void refresh().catch(() => setError('设备清单读取失败')); }}>刷新设备</Button>
|
||||
</div>
|
||||
<p className={styles.note}>同一账号可登录多台设备。移除设备会撤销它的登录与空间席位,保留账号的空间成员资格。</p>
|
||||
{devices.map((device) => <div className={styles.actions} key={device.id}>
|
||||
<input aria-label={`${device.name} 的设备名称`} maxLength={80} value={names[device.id] ?? device.name} onChange={(e) => setNames((values) => ({ ...values, [device.id]: e.target.value }))} />
|
||||
<span>{device.current ? '本机' : '其他设备'}</span>
|
||||
<Button disabled={busy} onClick={() => { void change(device, false); }}>重命名</Button>
|
||||
<Button variant="ghost" disabled={busy || device.current} onClick={() => {
|
||||
if (window.confirm(`移除设备「${device.name}」并撤销它的登录?`)) void change(device, true);
|
||||
}}>移除设备</Button>
|
||||
{devices.map((device) => <div className={styles.deviceRow} key={device.id}>
|
||||
<label className={styles.deviceName}>
|
||||
<input aria-label={`${device.name} 的设备名称`} maxLength={80} value={names[device.id] ?? device.name} onChange={(e) => setNames((values) => ({ ...values, [device.id]: e.target.value }))} />
|
||||
<span className={styles.deviceType}>{device.current ? '本机' : '其他设备'}</span>
|
||||
</label>
|
||||
<div className={styles.deviceActions}>
|
||||
<Button disabled={busy} onClick={() => { void change(device, false); }}>重命名</Button>
|
||||
<Button variant="ghost" disabled={busy || device.current} onClick={() => {
|
||||
if (window.confirm(`移除设备「${device.name}」并撤销它的登录?`)) void change(device, true);
|
||||
}}>移除设备</Button>
|
||||
</div>
|
||||
</div>)}
|
||||
<Button variant="ghost" disabled={busy} onClick={() => { void refresh().catch(() => setError('设备清单读取失败')); }}>刷新设备</Button>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
.dock {
|
||||
position: fixed;
|
||||
z-index: 24;
|
||||
left: max(var(--space-4), env(safe-area-inset-left));
|
||||
bottom: max(var(--space-4), env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: min(288px, calc(100vw - var(--space-6)));
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profile:hover,
|
||||
.profile:focus-visible {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.profile:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.avatarFallback {
|
||||
display: grid;
|
||||
flex: 0 0 36px;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-avatar);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profileText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.profileName,
|
||||
.username {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profileName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.settings {
|
||||
flex: 0 0 44px;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
.dock {
|
||||
left: max(var(--space-2), env(safe-area-inset-left));
|
||||
bottom: calc(88px + env(safe-area-inset-bottom));
|
||||
gap: var(--space-1);
|
||||
width: auto;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.profile {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
min-height: 40px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.avatarFallback {
|
||||
flex-basis: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.profileText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings {
|
||||
flex-basis: 40px;
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { AccountPanel } from './AccountPanel';
|
||||
import styles from './AccountDock.module.css';
|
||||
|
||||
export function AccountDock() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const username = useAccount((s) => s.username);
|
||||
const personal = useAccount((s) => s.personal);
|
||||
if (!username) return null;
|
||||
|
||||
const displayName = personal?.displayName || username;
|
||||
const subtitle = personal?.displayName && personal.displayName !== username ? username : '已登录';
|
||||
const initial = Array.from(displayName)[0]?.toLocaleUpperCase() ?? '?';
|
||||
const openSettings = () => setSettingsOpen(true);
|
||||
|
||||
return <>
|
||||
<section className={styles.dock} aria-label="当前账号">
|
||||
<button className={styles.profile} type="button" onClick={openSettings} aria-label={`打开 ${displayName} 的账号设置`}>
|
||||
{personal?.avatar ? <img className={styles.avatar} src={personal.avatar} alt="" /> : <span className={styles.avatarFallback}>{initial}</span>}
|
||||
<span className={styles.profileText}><strong className={styles.profileName}>{displayName}</strong><span className={styles.username}>{subtitle}</span></span>
|
||||
</button>
|
||||
<Button className={styles.settings} variant="ghost" icon="settings" label="账号设置" onClick={openSettings} />
|
||||
</section>
|
||||
{settingsOpen ? <Sheet title="账号设置" wide onClose={() => setSettingsOpen(false)}><AccountPanel variant="settings" /></Sheet> : null}
|
||||
</>;
|
||||
}
|
||||
@@ -4,9 +4,60 @@
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.authPanel {
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6) 32px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.settingsPanel {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
padding-block: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.subsection h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.syncBlock {
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
min-height: 40px;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.sectionHeader strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.invitationRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: var(--bar-h);
|
||||
height: var(--bar-h);
|
||||
@@ -26,6 +77,10 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.authPanel .heading h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.heading p,
|
||||
.note,
|
||||
.message,
|
||||
@@ -36,15 +91,66 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.settingsPanel .heading {
|
||||
align-items: center;
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.settingsPanel .heading h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.syncBlock .note {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.user {
|
||||
color: var(--ok);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.authMode {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.authMode button {
|
||||
min-height: 42px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.authMode button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.authMode button[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.authMode button:not([aria-pressed="true"]):hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form label {
|
||||
@@ -52,11 +158,25 @@
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form input {
|
||||
.deviceName input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form input:not([type="checkbox"]) {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -66,6 +186,19 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form .remember input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.form input:not([type="checkbox"]):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -77,16 +210,69 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.authPanel .form .actions > button[type="submit"] {
|
||||
flex: 1 0 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.form .remember {
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.form .remember input {
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
.avatarField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.filePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.fileHint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.deviceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding-block: var(--space-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.deviceName {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deviceType {
|
||||
flex: 0 0 auto;
|
||||
min-width: 56px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.deviceActions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -98,11 +284,21 @@
|
||||
}
|
||||
|
||||
@media (width <= 560px) {
|
||||
.form {
|
||||
grid-template-columns: 1fr;
|
||||
.authPanel {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.form .actions {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.deviceRow {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.deviceActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,18 @@ import { navigate, channelPath } from '@/app/navigation';
|
||||
import { InvitationInbox } from './InvitationInbox';
|
||||
import { PersonalSettings } from './PersonalSettings';
|
||||
import { AccountDevices } from './AccountDevices';
|
||||
import { PasskeySettings } from './PasskeySettings';
|
||||
import { passkeysAvailable } from '@/core-bridge/passkeys';
|
||||
|
||||
export function AccountPanel() {
|
||||
export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'auth' | 'settings' }) {
|
||||
const account = useAccount((s) => s.username);
|
||||
const busy = useAccount((s) => s.busy);
|
||||
const error = useAccount((s) => s.error);
|
||||
const login = useAccount((s) => s.login);
|
||||
const passkeyLogin = useAccount(s => s.passkeyLogin);
|
||||
const unlockPasskey = useAccount(s => s.unlockPasskey);
|
||||
const unlockUsername = useAccount(s => s.unlockUsername);
|
||||
const cancelPasskey = useAccount(s => s.cancelPasskey);
|
||||
const logout = useAccount((s) => s.logout);
|
||||
const sync = useAccount((s) => s.sync);
|
||||
const remembered = useAccount((s) => s.remembered);
|
||||
@@ -51,7 +57,7 @@ export function AccountPanel() {
|
||||
if (busy) return;
|
||||
setMessage('');
|
||||
try {
|
||||
await login(username, password, register, remember);
|
||||
if (unlockUsername) await unlockPasskey(password, remember); else await login(username, password, register, remember);
|
||||
setPassword('');
|
||||
await Promise.all([refreshChannels(), refreshShares()]);
|
||||
setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。');
|
||||
@@ -62,40 +68,52 @@ export function AccountPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.panel} aria-labelledby="account-title">
|
||||
<section className={`${styles.panel} ${variant === 'auth' ? styles.authPanel : ''} ${variant === 'settings' ? styles.settingsPanel : ''}`} aria-labelledby="account-title">
|
||||
<div className={styles.heading}>
|
||||
<div>
|
||||
<h2 id="account-title">账号与同步</h2>
|
||||
<p>登录后可在其他设备恢复本账号保存的空间和分享。</p>
|
||||
<h2 id="account-title">{variant === 'auth' && !account ? unlockUsername ? '解锁资源保险库' : register ? '创建账号' : '登录账号' : '账号与同步'}</h2>
|
||||
{variant === 'default' ? <p>登录后可在其他设备恢复本账号保存的空间和分享。</p> : null}
|
||||
</div>
|
||||
{account ? <span className={styles.user}>{account}</span> : null}
|
||||
</div>
|
||||
{account ? (
|
||||
<div className={styles.actions}>
|
||||
<Button disabled={busy} onClick={() => { void sync().then(() => setMessage('资源保险库已同步。')).catch(() => {}); }}>{busy ? '同步中…' : '立即同步'}</Button>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => { void logout().then(() => navigate('/')); setMessage(''); }}>退出登录</Button>
|
||||
{remembered ? <Button variant="ghost" disabled={busy} onClick={() => { void clearSaved(); }}>清除本机保存</Button> : null}
|
||||
<div className={styles.syncBlock}>
|
||||
<div className={styles.actions}>
|
||||
<Button disabled={busy} onClick={() => { void sync().then(() => setMessage('资源保险库已同步。')).catch(() => {}); }}>{busy ? '同步中…' : '立即同步'}</Button>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => { void logout().then(() => navigate('/')); setMessage(''); }}>退出登录</Button>
|
||||
{remembered ? <Button variant="ghost" disabled={busy} onClick={() => { void clearSaved(); }}>清除本机保存</Button> : null}
|
||||
</div>
|
||||
<p className={styles.note}>{remembered ? '已开启本机记住登录。' : '仅本次登录,刷新或关闭页面后需重新登录。'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form className={styles.form} onSubmit={(event) => { void submit(event); }}>
|
||||
<label>用户名<input name="username" autoComplete="username" minLength={3} maxLength={32} value={username} onChange={(event) => { edited.current = true; setUsername(event.target.value); }} required /></label>
|
||||
{variant === 'auth' && !unlockUsername ? <div className={styles.authMode} role="group" aria-label="选择登录或注册">
|
||||
<button type="button" aria-pressed={!register} onClick={() => { edited.current = true; setRegister(false); setMessage(''); }}>登录</button>
|
||||
<button type="button" aria-pressed={register} onClick={() => { edited.current = true; setRegister(true); setMessage(''); }}>创建账号</button>
|
||||
</div> : null}
|
||||
{unlockUsername ? <p className={styles.note}>已用Passkey确认账号,请输入账号密码解锁资源。</p> : null}
|
||||
<label>用户名<input name="username" autoComplete="username" minLength={3} maxLength={32} value={unlockUsername ?? username} disabled={Boolean(unlockUsername)} onChange={(event) => { edited.current = true; setUsername(event.target.value); }} required /></label>
|
||||
<label>密码<input name="password" autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => { edited.current = true; setPassword(event.target.value); }} required /></label>
|
||||
<label className={styles.remember}><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />记住账号和密码(本机)</label>
|
||||
<p className={styles.note}>仅在自己的设备使用。退出登录后仍可自动填充;“清除本机保存”会删除已保存资料。</p>
|
||||
<div className={styles.actions}>
|
||||
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : register ? '创建账号' : '登录并同步'}</Button>
|
||||
<Button variant="ghost" onClick={() => { edited.current = true; setRegister((value) => !value); setMessage(''); }}>{register ? '已有账号' : '创建账号'}</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : unlockUsername ? '解锁资源' : register ? '创建账号' : '登录并同步'}</Button>
|
||||
{variant !== 'auth' ? <Button variant="ghost" onClick={() => { edited.current = true; setRegister((value) => !value); setMessage(''); }}>{register ? '已有账号' : '创建账号'}</Button> : null}
|
||||
{saved ? <Button variant="ghost" disabled={busy} onClick={() => { void clearSaved(); }}>清除本机保存</Button> : null}
|
||||
{!register && !unlockUsername ? <Button variant="ghost" disabled={busy || !username || !passkeysAvailable()} onClick={() => {
|
||||
edited.current = true; setPassword(''); void passkeyLogin(username, remember).then(() => {
|
||||
const pending = useChannels.getState().pendingLink; if (pending && useAccount.getState().username) navigate(channelPath(pending.id));
|
||||
}).catch(() => {});
|
||||
}}>使用Passkey登录</Button> : null}
|
||||
{unlockUsername ? <Button variant="ghost" onClick={() => { void cancelPasskey(); }}>取消解锁</Button> : null}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{message ? <p className={styles.message} role="status">{message}</p> : null}
|
||||
{account ? <InvitationInbox /> : null}
|
||||
{account ? <PersonalSettings /> : null}
|
||||
{account ? <AccountDevices /> : null}
|
||||
<p className={styles.note}>资源清单在浏览器加密后再同步,服务端无法读取空间链接。密码没有找回功能;忘记密码将无法解密账号保险库。</p>
|
||||
{account ? <div className={styles.subsection}><InvitationInbox /></div> : null}
|
||||
{account ? <div className={styles.subsection}><PersonalSettings /></div> : null}
|
||||
{account ? <div className={styles.subsection}><AccountDevices /></div> : null}
|
||||
{account ? <div className={styles.subsection}><PasskeySettings /></div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,11 @@ import { AccountPanel } from './AccountPanel';
|
||||
import { ThemeMenu } from '@/ui/ThemeMenu';
|
||||
import styles from './Home.module.css';
|
||||
export function AccountScreen() {
|
||||
return <div className={styles.page}><div className={styles.appearance}><ThemeMenu /></div><main className={styles.main}>
|
||||
<header className={styles.head}><h1 className={styles.logo}>WebDrop</h1><p className={styles.sub}>注册或登录后,管理你的空间和分享。</p></header>
|
||||
<AccountPanel />
|
||||
return <div className={styles.page}><div className={styles.appearance}><ThemeMenu /></div><main className={`${styles.main} ${styles.authMain}`}>
|
||||
<header className={styles.authIntro}>
|
||||
<h1 className={styles.authLogo}>WebDrop</h1>
|
||||
<p>登录后,可在其他设备恢复本账号保存的空间和分享。</p>
|
||||
</header>
|
||||
<AccountPanel variant="auth" />
|
||||
</main></div>;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
<button type="button" className={styles.open} onClick={() => onOpen(c)}>
|
||||
<span className={styles.id}>{'state' in c ? c.id.slice(0, 8) : c.name || c.id.slice(0, 8)}…</span>
|
||||
<span className={styles.meta}>
|
||||
{c.dataSource === 'cache' ? <span>离线缓存</span> : null}
|
||||
{'state' in c ? (
|
||||
<>
|
||||
<span className={styles.enc}><Icon name="lock" size={12} /> 加密</span>
|
||||
@@ -45,7 +46,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
{c.mode === 'enc' ? <><Icon name="lock" size={12} /> 加密</> : '明文'}
|
||||
</span>
|
||||
<span>{c.role === 'owner' ? '所有者' : '成员'}</span>
|
||||
<span>{c.seats} 台设备</span>
|
||||
<span>最多 {c.seats} 人</span>
|
||||
<span>{formatAgo(c.createdAt)}创建</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,35 +6,39 @@ import { Sheet } from '@/ui/Sheet';
|
||||
import { solveCreationCaptcha } from '@/core-bridge/captcha';
|
||||
import styles from './CreateSheet.module.css';
|
||||
|
||||
const SEATS = [2, 3, 5, 10];
|
||||
const DOWNLOAD_LIMITS = [0, 1, 3, 5, 10, 20];
|
||||
const TTLS = [
|
||||
{ label: '1 小时', value: 3600 },
|
||||
{ label: '24 小时', value: 86400 },
|
||||
{ label: '7 天', value: 604800 },
|
||||
];
|
||||
|
||||
import { useServerSettings } from '@/store/server';
|
||||
import { creationOptions } from '@/core-bridge/settings';
|
||||
import type { ServerInfoBody } from '@/core-bridge/generated/api';
|
||||
import { ttlLabel } from '@/lib/settingOptions';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: (c: ChannelInfo) => void;
|
||||
}
|
||||
|
||||
/** 新建空间:协议 §12 里与创建相关的设置子集 */
|
||||
export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
export function CreateSheet(props: Props) {
|
||||
const { info, error, load } = useServerSettings();
|
||||
return info ? <CreateForm {...props} info={info} /> : <Sheet title="新建空间" onClose={props.onClose}>
|
||||
<p role={error ? 'alert' : 'status'}>{error ?? '正在读取服务端设置…'}</p>
|
||||
{error ? <Button onClick={() => { void load().catch(() => {}); }}>重试</Button> : null}
|
||||
</Sheet>;
|
||||
}
|
||||
function CreateForm({ onClose, onCreated, info }: Props & { info: ServerInfoBody }) {
|
||||
const create = useChannels((s) => s.create);
|
||||
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 10, ttlSeconds: 86400, membersCanUpload: true, fileMaxDownloads: 10, joinPolicy: 'invite', allowGuests: false });
|
||||
const [opts, setOpts] = useState<CreateOptions>(() => creationOptions(info.defaults));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [failed, setFailed] = useState('');
|
||||
const set = <K extends keyof CreateOptions>(k: K, v: CreateOptions[K]) => setOpts((o) => ({ ...o, [k]: v }));
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
setFailed('');
|
||||
try {
|
||||
const captchaToken = await solveCreationCaptcha();
|
||||
onCreated(await create(opts, captchaToken));
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} catch (cause) {
|
||||
setFailed(errorMessage(cause));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +48,7 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
<div className={styles.form}>
|
||||
<label className={styles.field}>
|
||||
<span>空间名称(加入者和服务端可见)</span>
|
||||
<input type="text" maxLength={48} value={opts.name ?? ''} placeholder="例如:工作设备" onChange={(e) => set('name', e.target.value)} />
|
||||
<input type="text" value={opts.name ?? ''} placeholder="例如:工作设备" onChange={(e) => set('name', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className={styles.check}><input type="checkbox" checked={opts.mode === 'enc'} onChange={(event) => set('mode', event.target.checked ? 'enc' : 'raw')} /><span>启用端到端加密</span></label>
|
||||
@@ -52,23 +56,23 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
<p className={styles.secure}>空间属于当前账号,并自动创建“常规”频道。默认通过账号邀请加入,成员在自己的设备上自动领取密钥包。</p>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>设备数(席位)</span>
|
||||
<span>成员人数上限</span>
|
||||
<select value={opts.seats} onChange={(e) => set('seats', Number(e.target.value))}>
|
||||
{SEATS.map((n) => <option key={n} value={n}>{n} 台</option>)}
|
||||
{info.choices.seats.map((n) => <option key={n} value={n}>{n} 人</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>内容保留时间</span>
|
||||
<select value={opts.ttlSeconds} onChange={(e) => set('ttlSeconds', Number(e.target.value))}>
|
||||
{TTLS.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
{info.choices.ttl_seconds.map(value => <option key={value} value={value}>{ttlLabel(value)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>每个文件的受限下载次数</span>
|
||||
<select value={opts.fileMaxDownloads ?? 10} onChange={(e) => set('fileMaxDownloads', Number(e.target.value))}>
|
||||
{DOWNLOAD_LIMITS.map((n) => <option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>)}
|
||||
<select value={opts.fileMaxDownloads} onChange={(e) => set('fileMaxDownloads', Number(e.target.value))}>
|
||||
{info.choices.file_max_downloads.map((n) => <option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -77,7 +81,7 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
<span>允许成员上传</span>
|
||||
</label>
|
||||
|
||||
{failed ? <p className={styles.warn} role="alert">创建失败,请重试。</p> : null}
|
||||
{failed ? <p className={styles.warn} role="alert">{failed}</p> : null}
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit()}>{busy ? '创建中…' : '创建'}</Button>
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
@@ -14,15 +14,14 @@
|
||||
}
|
||||
|
||||
.buildInfo {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
right: max(var(--space-4), env(safe-area-inset-right));
|
||||
bottom: max(var(--space-3), env(safe-area-inset-bottom));
|
||||
position: static;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
justify-content: center;
|
||||
gap: var(--space-1) var(--space-3);
|
||||
max-width: calc(100vw - var(--space-6));
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
@@ -40,11 +39,55 @@
|
||||
padding: var(--space-6) var(--space-4) calc(var(--space-6) + 32px);
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--space-6);
|
||||
width: min(100%, 1480px);
|
||||
max-width: none;
|
||||
padding: 32px var(--space-6) 96px;
|
||||
}
|
||||
|
||||
.dashboard.hasShares {
|
||||
grid-template-columns: minmax(0, 1.12fr) minmax(360px, 0.88fr);
|
||||
}
|
||||
|
||||
.dashboardHead {
|
||||
grid-column: 1 / -1;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.primaryColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard:not(.hasShares) .primaryColumn {
|
||||
width: 100%;
|
||||
max-width: 1120px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.secondaryColumn {
|
||||
min-width: 0;
|
||||
padding-inline-start: var(--space-4);
|
||||
border-inline-start: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.head {
|
||||
padding: var(--space-4) 0 var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.head.dashboardHead {
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
@@ -56,6 +99,15 @@
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.dashboardHead .logo {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.dashboardHead .sub {
|
||||
margin-top: var(--space-1);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -91,16 +143,107 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.authMain {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
||||
align-items: center;
|
||||
gap: clamp(40px, 7vw, 96px);
|
||||
width: min(100%, 1120px);
|
||||
max-width: none;
|
||||
min-height: 100%;
|
||||
padding: 48px 32px;
|
||||
}
|
||||
|
||||
.authIntro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding-inline-start: var(--space-4);
|
||||
}
|
||||
|
||||
.authLogo {
|
||||
margin: 0;
|
||||
font-size: clamp(40px, 4vw, 56px);
|
||||
font-weight: 650;
|
||||
letter-spacing: -1.5px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.authIntro p {
|
||||
max-width: 34ch;
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (width >= 900px) {
|
||||
.main {
|
||||
padding-top: 8vh;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.authMain {
|
||||
padding-top: 48px;
|
||||
}
|
||||
|
||||
.head {
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.dashboard {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: min(100%, 760px);
|
||||
}
|
||||
|
||||
.dashboard.hasShares {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dashboardHead {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.secondaryColumn {
|
||||
padding: var(--space-4) 0 0;
|
||||
border-top: 1px solid var(--line);
|
||||
border-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.dashboard {
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6) var(--space-3) 88px;
|
||||
}
|
||||
|
||||
.authMain {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-6);
|
||||
width: min(100%, 560px);
|
||||
padding: 72px var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
.authIntro {
|
||||
gap: var(--space-2);
|
||||
padding-inline: var(--space-2);
|
||||
}
|
||||
|
||||
.authLogo {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.authIntro p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: calc(var(--space-3) * -1) 0 0;
|
||||
color: var(--danger);
|
||||
|
||||
@@ -4,14 +4,12 @@ import { buildInfo } from '@/lib/buildInfo';
|
||||
import type { ChannelInfo, Share } from '@/core-bridge/types';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { useGlobalPaste } from '@/lib/useGlobalPaste';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useShares } from '@/store/shares';
|
||||
import { solveCreationCaptcha } from '@/core-bridge/captcha';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ThemeMenu } from '@/ui/ThemeMenu';
|
||||
import { ChannelList } from './ChannelList';
|
||||
import { AccountPanel } from './AccountPanel';
|
||||
import { CreateSheet } from './CreateSheet';
|
||||
import { QuickShare } from './QuickShare';
|
||||
import styles from './Home.module.css';
|
||||
@@ -21,8 +19,7 @@ const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({
|
||||
/** 初始状态:什么都没创建时看到的页面。核心是「快速分享」 */
|
||||
export function Home() {
|
||||
const createDraft = useShares((s) => s.createDraft);
|
||||
const loadDemo = useChannels((s) => s.loadDemo);
|
||||
const loadShareDemo = useShares((s) => s.loadDemo);
|
||||
const shares = useShares((s) => s.list);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -62,43 +59,40 @@ export function Home() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.appearance}><ThemeMenu /></div>
|
||||
<main className={styles.main}>
|
||||
<header className={styles.head}>
|
||||
<main className={`${styles.main} ${styles.dashboard} ${shares.length ? styles.hasShares : ''}`}>
|
||||
<header className={`${styles.head} ${styles.dashboardHead}`}>
|
||||
<h1 className={styles.logo}>WebDrop</h1>
|
||||
<p className={styles.sub}>在自己的设备之间快速传文件和文本,也可以分享给别人。</p>
|
||||
</header>
|
||||
|
||||
<QuickShare
|
||||
busy={busy}
|
||||
text={shareText}
|
||||
files={shareFiles}
|
||||
onTextChange={setShareText}
|
||||
onFilesChange={setShareFiles}
|
||||
onShare={share}
|
||||
/>
|
||||
{failedMessage ? <p role="alert" className={styles.error}>创建分享失败:{failedMessage}</p> : null}
|
||||
<div className={styles.primaryColumn}>
|
||||
<QuickShare
|
||||
busy={busy}
|
||||
text={shareText}
|
||||
files={shareFiles}
|
||||
onTextChange={setShareText}
|
||||
onFilesChange={setShareFiles}
|
||||
onShare={share}
|
||||
/>
|
||||
{failedMessage ? <p role="alert" className={styles.error}>创建分享失败:{failedMessage}</p> : null}
|
||||
|
||||
<AccountPanel />
|
||||
<section className={styles.channels} aria-labelledby="channels-title">
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="channels-title">空间工作区</h2>
|
||||
<p>同一账号跨设备使用,也可邀请其他账号共同使用频道与文件。</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建空间</Button>
|
||||
<Button icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button>
|
||||
</div>
|
||||
|
||||
<ChannelList kind="share" title="我的分享" onOpen={open} />
|
||||
<ChannelList kind="channel" title="我的空间" onOpen={open} />
|
||||
<p className={styles.note}>空间密钥按账号加密分发,登录其他设备后可自动恢复。已授权成员可读取空间历史。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className={styles.channels} aria-labelledby="channels-title">
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="channels-title">空间工作区</h2>
|
||||
<p>同一账号跨设备使用,也可邀请其他账号共同使用频道与文件。</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建空间</Button>
|
||||
<Button icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button>
|
||||
</div>
|
||||
{shares.length ? <aside className={styles.secondaryColumn} aria-label="我的分享"><ChannelList kind="share" title="我的分享" onOpen={open} /></aside> : null}
|
||||
|
||||
<ChannelList kind="channel" title="我的空间" onOpen={open} />
|
||||
<p className={styles.note}>空间密钥按账号加密分发,登录其他设备后可自动恢复。已授权成员可读取空间历史。</p>
|
||||
</section>
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
<Button variant="ghost" onClick={() => { loadDemo(); loadShareDemo(); }}>载入演示数据</Button>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
<aside className={styles.buildInfo} aria-label="构建信息">
|
||||
|
||||
@@ -15,9 +15,11 @@ export function InvitationInbox() {
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '加入失败'); }
|
||||
finally { setBusy(undefined); }
|
||||
}
|
||||
return <div><strong>空间邀请</strong>
|
||||
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }}>刷新邀请</Button>
|
||||
{invitations.length === 0 ? <p className={styles.note}>暂无邀请。</p> : invitations.map((row) => <div className={styles.actions} key={row.channel_id}>
|
||||
return <div>
|
||||
<div className={styles.sectionHeader}><strong>空间邀请</strong>
|
||||
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }}>刷新</Button>
|
||||
</div>
|
||||
{invitations.length === 0 ? <p className={styles.note}>暂无邀请。</p> : invitations.map((row) => <div className={styles.invitationRow} key={row.channel_id}>
|
||||
<span>空间 {row.channel_id.slice(0, 8)}…</span><Button disabled={Boolean(busy)} onClick={() => { void accept(row.channel_id); }}>{busy === row.channel_id ? '加入中…' : '接受邀请'}</Button>
|
||||
</div>)}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { bindPasskey, passkeyApi, passkeysAvailable } from '@/core-bridge/passkeys';
|
||||
import type { PasskeyInfoBody } from '@/core-bridge/generated/api';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './AccountPanel.module.css';
|
||||
|
||||
export function PasskeySettings() {
|
||||
const [items, setItems] = useState<PasskeyInfoBody[]>([]);
|
||||
const [name, setName] = useState('我的Passkey');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
useEffect(() => { let active = true; void passkeyApi.list().then(value => { if (active) setItems(value); }).catch(() => { if (active) setMessage('Passkey列表读取失败'); }); return () => { active = false; }; }, []);
|
||||
async function bind(event: FormEvent) {
|
||||
event.preventDefault(); if (busy) return;
|
||||
setBusy(true); setMessage('');
|
||||
try { await bindPasskey(password, name); setItems(await passkeyApi.list()); setMessage('Passkey已绑定。支持恢复的凭据可在其他设备上解锁账号。'); }
|
||||
catch (error) { setMessage(error instanceof Error && /[\u4e00-\u9fff]/.test(error.message) ? error.message : 'Passkey操作已取消或不可用,请重试'); }
|
||||
finally { setPassword(''); setBusy(false); }
|
||||
}
|
||||
async function remove(id: string) {
|
||||
setBusy(true); setMessage('');
|
||||
try { await passkeyApi.remove(id); setItems(await passkeyApi.list()); setMessage('Passkey已撤销'); }
|
||||
catch { setMessage('Passkey撤销失败'); } finally { setBusy(false); }
|
||||
}
|
||||
return <section aria-label="Passkey设置">
|
||||
<h3>Passkey</h3>
|
||||
<p className={styles.note}>可使用设备解锁或密码管理器登录。支持解密恢复的Passkey可在忘记密码时解锁资源;仅登录的Passkey仍需账号密码解密。</p>
|
||||
{items.map(item => <div className={styles.actions} key={item.id}><span>{item.name} · {item.can_recover ? '登录与解密恢复' : '仅登录'}</span>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => { void remove(item.id); }}>撤销 {item.name}</Button></div>)}
|
||||
<form className={styles.form} onSubmit={event => { void bind(event); }}>
|
||||
<label>Passkey名称<input value={name} maxLength={80} onChange={event => setName(event.target.value)} required /></label>
|
||||
<label>验证账号密码<input type="password" autoComplete="current-password" value={password} onChange={event => setPassword(event.target.value)} required /></label>
|
||||
<Button type="submit" disabled={busy || !passkeysAvailable()}>{busy ? '验证中…' : '绑定Passkey'}</Button>
|
||||
</form>
|
||||
{!passkeysAvailable() ? <p className={styles.note}>当前环境不支持Passkey,请使用支持的浏览器和HTTPS或localhost地址。</p> : null}
|
||||
{message ? <p className={styles.note} role="status">{message}</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './AccountPanel.module.css';
|
||||
@@ -8,6 +8,7 @@ export function PersonalSettings() {
|
||||
const username = useAccount((s) => s.username);
|
||||
const busy = useAccount((s) => s.busy);
|
||||
const update = useAccount((s) => s.updatePersonal);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState(personal?.displayName ?? '');
|
||||
const [avatar, setAvatar] = useState(personal?.avatar);
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -33,12 +34,19 @@ export function PersonalSettings() {
|
||||
}
|
||||
return <form className={styles.form} onSubmit={(event) => { void submit(event); }} aria-label="个人资料">
|
||||
<label>个人名称<input maxLength={48} value={name} placeholder={username ?? ''} onChange={(e) => setName(e.target.value)} /></label>
|
||||
<label>头像<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(e) => { void choose(e.target.files?.[0]); e.target.value = ''; }} /></label>
|
||||
<div className={styles.avatarField}>
|
||||
<span className={styles.fieldLabel}>头像</span>
|
||||
<input ref={fileInput} type="file" accept="image/png,image/jpeg,image/webp" aria-label="选择头像文件" hidden onChange={(e) => { void choose(e.target.files?.[0]); e.target.value = ''; }} />
|
||||
<div className={styles.filePicker}>
|
||||
<Button icon="image" onClick={() => fileInput.current?.click()}>选择头像</Button>
|
||||
<span className={styles.fileHint}>PNG、JPEG 或 WebP,最大 250 KB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actions}>{avatar ? <img className={styles.avatar} src={avatar} alt="个人头像" /> : null}
|
||||
<Button type="submit" disabled={busy}>保存个人资料</Button>
|
||||
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除头像</Button> : null}
|
||||
</div>
|
||||
<p className={styles.note}>用户名是固定的账号身份。个人名称与头像会加密同步,设备名称单独管理。</p>
|
||||
<p className={styles.note}>用户名是固定的账号身份。个人名称与头像由服务端保存并在设备间同步,设备名称单独管理。</p>
|
||||
{message ? <p className={styles.note} role="status">{message}</p> : null}
|
||||
</form>;
|
||||
}
|
||||
@@ -12,21 +12,12 @@ import { DraftPreview } from './DraftPreview';
|
||||
import { ShareBar } from './ShareBar';
|
||||
import styles from './ShareDraft.module.css';
|
||||
|
||||
const TTLS = [
|
||||
{ label: '1 小时', value: 3600 },
|
||||
{ label: '24 小时', value: 86400 },
|
||||
{ label: '7 天', value: 604800 },
|
||||
];
|
||||
const SEATS = [2, 5, 10, 20];
|
||||
const DOWNLOADS = [
|
||||
{ label: '不限', value: 0 },
|
||||
{ label: '1 次', value: 1 },
|
||||
{ label: '3 次', value: 3 },
|
||||
{ label: '10 次', value: 10 },
|
||||
];
|
||||
import { useServerSettings } from '@/store/server';
|
||||
import { ttlLabel, withCurrent } from '@/lib/settingOptions';
|
||||
|
||||
/** 分享草稿:创建者还握着所有者密钥,可以添加、删除、调整设置;点「完成分享」后无人可改 */
|
||||
export function ShareDraft() {
|
||||
const { info, error: settingsError } = useServerSettings();
|
||||
const share = useShareSession((s) => s.share);
|
||||
const ids = useShareSession((s) => s.order);
|
||||
const error = useShareSession((s) => s.error);
|
||||
@@ -120,23 +111,24 @@ export function ShareDraft() {
|
||||
<section className={styles.settings} aria-label="分享设置">
|
||||
<label>
|
||||
<span>有效期</span>
|
||||
<select value={share.ttlSeconds} onChange={(e) => patch({ ttlSeconds: Number(e.target.value) })}>
|
||||
{TTLS.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
<select disabled={!info} value={share.ttlSeconds} onChange={(e) => patch({ ttlSeconds: Number(e.target.value) })}>
|
||||
{withCurrent(info?.choices.ttl_seconds ?? [], share.ttlSeconds).map(value => <option key={value} value={value}>{ttlLabel(value)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>最多访客</span>
|
||||
<select value={share.maxVisitors} onChange={(e) => patch({ maxVisitors: Number(e.target.value) })}>
|
||||
{SEATS.map((n) => <option key={n} value={n}>{n} 人</option>)}
|
||||
{withCurrent(info?.choices.max_visitors ?? [], share.maxVisitors).map((n) => <option key={n} value={n}>{n === 0 ? '不限' : `${n} 人`}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>每个文件下载</span>
|
||||
<select value={share.maxDownloads ?? 0} onChange={(e) => patch({ maxDownloads: Number(e.target.value) || null })}>
|
||||
{DOWNLOADS.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
|
||||
{withCurrent(info?.choices.file_max_downloads ?? [], share.maxDownloads ?? 0).map(value => <option key={value} value={value}>{value === 0 ? '不限' : `${value} 次`}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
{settingsError ? <p className={styles.error} role="alert">{settingsError}</p> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{sealError ? <p className={styles.error} role="alert">{sealError}</p> : null}
|
||||
</main>
|
||||
|
||||
@@ -23,11 +23,16 @@ export function formatTransferProgress(state: {
|
||||
}
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const dateFmt = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
|
||||
export function formatTime(ts: number): string {
|
||||
return timeFmt.format(ts);
|
||||
}
|
||||
|
||||
export function formatDate(ts: number): string {
|
||||
return dateFmt.format(ts);
|
||||
}
|
||||
|
||||
export function formatAgo(ts: number, now = Date.now()): string {
|
||||
const m = Math.max(0, Math.round((now - ts) / 60000));
|
||||
if (m < 1) return '刚刚';
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readSender } from './sender';
|
||||
|
||||
describe('sender display snapshots', () => {
|
||||
it('accepts account identity and safe local avatar data', () => {
|
||||
expect(readSender({ username: 'alice', displayName: '小白', avatar: 'data:image/webp;base64,AA==' })).toEqual({ username: 'alice', displayName: '小白', avatar: 'data:image/webp;base64,AA==' });
|
||||
expect(readSender({ username: 'alice', displayName: ' Alice ' })).toEqual({ username: 'alice', displayName: 'Alice' });
|
||||
});
|
||||
it.each([
|
||||
{ username: 'alice', displayName: 'Alice', avatar: 'https://tracker.test/avatar.png' },
|
||||
{ username: 'alice', displayName: 'Alice', avatar: 'data:image/svg+xml;base64,AA==' },
|
||||
{ username: 'alice', displayName: 'Alice', avatar: `data:image/png;base64,${'A'.repeat(8000)}` },
|
||||
{ username: 'alice', displayName: '\u0000Alice' },
|
||||
{ username: 'alice', displayName: '' }, { username: 'invalid user', displayName: 'Alice' }, null,
|
||||
])('rejects invalid or executable identity data %#', (value) => { expect(readSender(value)).toBeUndefined(); });
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { SenderInfo } from '@/core-bridge/types';
|
||||
|
||||
export function readSender(value: unknown): SenderInfo | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const row = value as Record<string, unknown>;
|
||||
if (typeof row.username !== 'string' || !/^[a-z0-9_-]{3,32}$/.test(row.username)
|
||||
|| typeof row.displayName !== 'string' || !row.displayName.trim() || row.displayName.length > 48 || /[\u0000-\u001f]/.test(row.displayName)
|
||||
|| row.avatar !== undefined && (typeof row.avatar !== 'string' || row.avatar.length > 8000 || !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/.test(row.avatar))) return undefined;
|
||||
return { username: row.username, displayName: row.displayName.trim(), ...(row.avatar ? { avatar: row.avatar as string } : {}) };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ttlLabel, withCurrent } from './settingOptions';
|
||||
|
||||
describe('server setting presentation', () => {
|
||||
it('formats arbitrary deployment-provided retention times', () => {
|
||||
expect(ttlLabel(90)).toBe('90 秒'); expect(ttlLabel(120)).toBe('2 分钟'); expect(ttlLabel(3600)).toBe('1 小时');
|
||||
});
|
||||
it('keeps existing settings visible when deployment choices change', () => {
|
||||
expect(withCurrent([60, 120, 120], 90)).toEqual([60, 90, 120]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export function ttlLabel(seconds: number): string {
|
||||
if (seconds % 86400 === 0) return `${seconds / 86400} 天`;
|
||||
if (seconds % 3600 === 0) return `${seconds / 3600} 小时`;
|
||||
if (seconds % 60 === 0) return `${seconds / 60} 分钟`;
|
||||
return `${seconds} 秒`;
|
||||
}
|
||||
export function withCurrent(values: number[], current: number): number[] {
|
||||
return [...new Set([...values, current])].sort((a, b) => a - b);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useChannels } from '@/store/channels';
|
||||
import { useShares } from '@/store/shares';
|
||||
import { initTheme } from '@/lib/theme';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { useServer } from '@/store/server';
|
||||
import '@/styles/tokens.css';
|
||||
import '@/styles/base.css';
|
||||
import '@/styles/transitions.css';
|
||||
@@ -16,6 +17,7 @@ if (link?.kind === 'channel') useChannels.getState().setPendingLink(link.link);
|
||||
if (link?.kind === 'share') useShares.getState().setPendingLink(link.link);
|
||||
|
||||
initTheme();
|
||||
void useServer.getState().load().catch(() => {});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -7,11 +7,15 @@ import { useWorkspace } from './workspace';
|
||||
import { initialData } from './reduce';
|
||||
import { useShareSession } from './shareSession';
|
||||
import { initialShareData } from './shareReduce';
|
||||
import { loginWithPasskey, passkeyApi } from '@/core-bridge/passkeys';
|
||||
|
||||
interface AccountState extends AccountStatus {
|
||||
busy: boolean;
|
||||
hydrated: boolean;
|
||||
login: (username: string, password: string, create: boolean, remember?: boolean) => Promise<void>;
|
||||
passkeyLogin: (username: string, remember: boolean) => Promise<void>;
|
||||
unlockPasskey: (password: string, remember: boolean) => Promise<void>;
|
||||
cancelPasskey: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
clearSavedLogin: () => Promise<void>;
|
||||
@@ -19,7 +23,11 @@ interface AccountState extends AccountStatus {
|
||||
updatePersonal: (displayName: string, avatar?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
async function refreshResources(): Promise<void> { await Promise.all([useChannels.getState().refresh(), useShares.getState().refresh()]); }
|
||||
let lastResourceRefresh = 0;
|
||||
async function refreshResources(): Promise<void> {
|
||||
await Promise.all([useChannels.getState().refresh(), useShares.getState().refresh()]);
|
||||
lastResourceRefresh = Date.now();
|
||||
}
|
||||
function clearViews(): void { useWorkspace.setState(initialData); useShareSession.setState(initialShareData); useShares.setState({ pendingPayload: null }); }
|
||||
export const useAccount = create<AccountState>()((set, get) => ({
|
||||
username: null,
|
||||
@@ -27,6 +35,17 @@ export const useAccount = create<AccountState>()((set, get) => ({
|
||||
error: undefined,
|
||||
busy: false,
|
||||
hydrated: false,
|
||||
passkeyLogin: async (username, remember) => {
|
||||
set({ busy: true, error: undefined });
|
||||
try { const status = await loginWithPasskey(username, remember); if (status.username) { clearViews(); await refreshResources(); } set({ ...status, busy: false, hydrated: true }); }
|
||||
catch (error) { set({ busy: false, error: error instanceof Error ? error.message : 'Passkey登录失败' }); throw error; }
|
||||
},
|
||||
unlockPasskey: async (password, remember) => {
|
||||
set({ busy: true, error: undefined });
|
||||
try { const status = await passkeyApi.unlock(password, remember); clearViews(); await refreshResources(); set({ ...status, unlockUsername: undefined, busy: false }); }
|
||||
catch (error) { set({ busy: false, unlockUsername: undefined, error: error instanceof Error ? error.message : '账号解锁失败' }); throw error; }
|
||||
},
|
||||
cancelPasskey: async () => { await passkeyApi.cancel(); set({ unlockUsername: undefined, error: undefined }); },
|
||||
login: async (username, password, create, remember = true) => {
|
||||
set({ busy: true, error: undefined });
|
||||
try {
|
||||
@@ -42,8 +61,8 @@ export const useAccount = create<AccountState>()((set, get) => ({
|
||||
try {
|
||||
const status = await accountApi.status();
|
||||
if (status.username !== get().username) clearViews();
|
||||
if (!get().hydrated || status.username !== get().username || status.revision !== get().revision) await refreshResources();
|
||||
set({ ...status, personal: status.personal, error: status.error, hydrated: true });
|
||||
if (!get().hydrated || status.username !== get().username || status.revision !== get().revision || Date.now() - lastResourceRefresh >= 30_000) await refreshResources();
|
||||
set({ ...status, personal: status.personal, unlockUsername: status.unlockUsername, error: status.error, hydrated: true });
|
||||
} catch { set({ hydrated: true, error: '账号状态读取失败' }); }
|
||||
},
|
||||
logout: async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
|
||||
interface ChannelsState {
|
||||
/** 我的空间(原型:只在内存里,刷新即清空;真实实现存 IndexedDB,architecture §6) */
|
||||
/** 服务端空间列表的界面投影,IndexedDB 提供离线缓存。 */
|
||||
list: ChannelInfo[];
|
||||
hydrated: boolean;
|
||||
/** 从链接进来、还没确认加入的空间;含密钥,只放内存 */
|
||||
@@ -17,7 +17,6 @@ interface ChannelsState {
|
||||
hydrate: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
forget: (id: string) => Promise<void>;
|
||||
loadDemo: () => void;
|
||||
setPendingLink: (link: ParsedChannelLink | null) => void;
|
||||
clearJustCreated: () => void;
|
||||
}
|
||||
@@ -50,10 +49,6 @@ export const useChannels = create<ChannelsState>()((set) => ({
|
||||
await api.forget(id);
|
||||
set((s) => ({ list: s.list.filter((c) => c.id !== id) }));
|
||||
},
|
||||
loadDemo: () => {
|
||||
const demo = api.loadDemo?.() ?? [];
|
||||
set((s) => ({ list: demo.reduce(upsert, s.list) }));
|
||||
},
|
||||
setPendingLink: (link) => set({ pendingLink: link }),
|
||||
clearJustCreated: () => set({ justCreatedId: null }),
|
||||
}));
|
||||
@@ -7,56 +7,21 @@ const text = (id: string, ts: number, body = id): TextItem => ({
|
||||
});
|
||||
|
||||
describe('reduce', () => {
|
||||
it('preserves sender identity across late content and download updates', () => {
|
||||
const author = { username: 'alice', displayName: '小白', avatar: 'data:image/png;base64,AA==' };
|
||||
let state = reduce(initialData, { t: 'item-author', id: 'message-a', author });
|
||||
state = reduce(state, { t: 'item', item: text('message-a', 1) });
|
||||
expect(state.byId['message-a']?.author).toEqual(author);
|
||||
state = reduce(state, { t: 'item', item: { ...text('message-a', 1), state: { kind: 'complete' } } });
|
||||
expect(state.byId['message-a']?.author).toEqual(author);
|
||||
state = reduce(state, { t: 'removed', id: 'message-a' });
|
||||
expect(state.authors['message-a']).toBeUndefined();
|
||||
});
|
||||
it('replays groups and channel membership without changing content locations', () => {
|
||||
const thread = { id: 'channel-a', name: '常规', createdAt: 1, createdBy: 'alice' };
|
||||
let state = reduce(initialData, { t: 'thread-created', thread });
|
||||
state = reduce(state, { t: 'item', item: { ...text('file-a', 1), threadId: thread.id } });
|
||||
const content = state.byId;
|
||||
state = reduce(state, { t: 'group-created', group: { id: 'group-a', name: '工作', createdAt: 1 } });
|
||||
state = reduce(state, { t: 'thread-grouped', id: thread.id, groupId: 'group-a' });
|
||||
state = reduce(state, { t: 'group-renamed', id: 'group-a', name: '项目' });
|
||||
expect(state.groups[0]?.name).toBe('项目'); expect(state.threads[0]?.groupId).toBe('group-a');
|
||||
expect(state.byId).toBe(content); expect(state.byId['file-a']?.threadId).toBe(thread.id);
|
||||
});
|
||||
it('late channel creation cannot undo a group move or ungrouping', () => {
|
||||
const thread = { id: 'channel-a', name: '常规', createdAt: 1, createdBy: 'alice', groupId: 'old-group' };
|
||||
let state = reduce(initialData, { t: 'thread-grouped', id: thread.id, groupId: 'new-group' });
|
||||
state = reduce(state, { t: 'thread-created', thread });
|
||||
expect(state.threads[0]?.groupId).toBe('new-group');
|
||||
state = reduce(state, { t: 'thread-grouped', id: thread.id });
|
||||
state = reduce(state, { t: 'thread-created', thread });
|
||||
expect(state.threads[0]?.groupId).toBeUndefined(); expect(state.threads).toHaveLength(1);
|
||||
});
|
||||
it('group creation is idempotent and resetting a space clears all groups', () => {
|
||||
const event = { t: 'group-created' as const, group: { id: 'group-a', name: '工作', createdAt: 1 } };
|
||||
let state = reduce(reduce(initialData, event), event);
|
||||
expect(state.groups).toHaveLength(1);
|
||||
state = reduce(state, { t: 'reset' });
|
||||
expect(state.groups).toEqual([]); expect(state.threadGroups).toEqual({});
|
||||
});
|
||||
it('late item updates preserve the latest folder move', () => {
|
||||
let state = reduce(initialData, { t: 'item', item: { ...text('a', 1), threadId: 'thread-a' } });
|
||||
state = reduce(state, { t: 'items-moved', itemIds: ['a'], threadId: 'thread-b', folderId: 'folder-b' });
|
||||
state = reduce(state, { t: 'item', item: { ...text('a', 1), threadId: 'thread-a' } });
|
||||
expect(state.byId.a?.threadId).toBe('thread-b'); expect(state.byId.a?.folderId).toBe('folder-b');
|
||||
});
|
||||
it('a hidden restricted item reveals its latest location only after metadata is read', () => {
|
||||
let state = reduce(initialData, { t: 'items-moved', itemIds: ['a'], threadId: 'thread-b', folderId: 'folder-b' });
|
||||
const file = { id: 'a', kind: 'file' as const, ts: 1, deviceId: '', state: { kind: 'incomplete' as const }, name: '受限下载内容', mime: '', size: 0, remaining: null, preview: 'none' as const, metadataHidden: true };
|
||||
state = reduce(state, { t: 'item', item: file });
|
||||
expect(state.byId.a?.folderId).toBeUndefined();
|
||||
state = reduce(state, { t: 'item', item: { ...file, name: 'real.txt', metadataHidden: false, threadId: 'old' } });
|
||||
expect(state.byId.a?.folderId).toBe('folder-b');
|
||||
it('renders server structure and preserves server author and location across late decrypted content', () => {
|
||||
const stateEvent = { t: 'structure' as const, state: { revision: 1,
|
||||
channels: [{ id: 'channel-a', name: '常规', groupId: 'group-a', createdAt: 1, createdBy: 'alice', chatEpoch: 1, filesEpoch: 1, chatHistory: true, filesHistory: true }],
|
||||
groups: [{ id: 'group-a', name: '项目', createdAt: 1 }],
|
||||
folders: [{ id: 'folder-a', name: '目录', threadId: 'channel-a' }],
|
||||
locations: [{ itemId: 'a', threadId: 'channel-a', folderId: 'folder-a', authorAccount: 'alice' }] },
|
||||
people: [{ username: 'alice', displayName: '服务端名称', modifiedAt: 1 }] };
|
||||
let state = reduce(initialData, stateEvent);
|
||||
state = reduce(state, { t: 'item', item: { ...text('a', 1), threadId: 'old', author: { username: 'mallory', displayName: '伪造名称' } } });
|
||||
expect(state.byId.a?.threadId).toBe('channel-a'); expect(state.byId.a?.folderId).toBe('folder-a');
|
||||
expect(state.byId.a?.author?.username).toBe('alice'); expect(state.groups[0]?.name).toBe('项目');
|
||||
expect(state.byId.a?.author?.username).toBe('alice');
|
||||
const ungrouped = { ...stateEvent, state: { ...stateEvent.state, channels: [{ ...stateEvent.state.channels[0]!, groupId: null }] } };
|
||||
state = reduce(state, ungrouped); expect(state.threads[0]?.groupId).toBeUndefined();
|
||||
state = reduce(state, { t: 'reset' }); expect(state.groups).toEqual([]);
|
||||
});
|
||||
it('新条目按时间倒序,新的在前', () => {
|
||||
let s = reduce(initialData, { t: 'item', item: text('a', 1) });
|
||||
|
||||
+16
-46
@@ -18,9 +18,8 @@ export interface WorkspaceData {
|
||||
devices: Device[];
|
||||
threads: ThreadInfo[];
|
||||
groups: ChannelGroupInfo[];
|
||||
threadGroups: Record<string, string | null>;
|
||||
folders: FolderInfo[];
|
||||
locations: Record<string, { threadId: string; folderId?: string }>;
|
||||
locations: Record<string, { threadId: string; folderId?: string; authorAccount?: string }>;
|
||||
byId: Record<string, Item>;
|
||||
authors: Record<string, SenderInfo>;
|
||||
/** 新的在前 */
|
||||
@@ -36,7 +35,6 @@ export const initialData: WorkspaceData = {
|
||||
devices: [],
|
||||
threads: [],
|
||||
groups: [],
|
||||
threadGroups: {},
|
||||
folders: [],
|
||||
locations: {},
|
||||
byId: {},
|
||||
@@ -55,6 +53,20 @@ function insertByTime(order: string[], byId: Record<string, Item>, item: Item):
|
||||
|
||||
export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
|
||||
switch (e.t) {
|
||||
case 'structure': {
|
||||
const people = new Map(e.people.map(person => [person.username, person]));
|
||||
const locations = Object.fromEntries(e.state.locations.map(location => [location.itemId, { threadId: location.threadId, folderId: location.folderId ?? undefined, authorAccount: location.authorAccount ?? undefined }]));
|
||||
const authors = { ...s.authors };
|
||||
for (const location of e.state.locations) {
|
||||
const person = location.authorAccount ? people.get(location.authorAccount) : undefined;
|
||||
if (person) authors[location.itemId] = { username: person.username, displayName: person.displayName || person.username, avatar: person.avatar ?? undefined };
|
||||
}
|
||||
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => [id, {
|
||||
...item, ...(locations[id] ?? {}), ...(authors[id] ? { author: authors[id] } : {}),
|
||||
}]));
|
||||
return { ...s, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups,
|
||||
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
|
||||
}
|
||||
case 'reset':
|
||||
return { ...initialData, connection: s.connection };
|
||||
case 'error':
|
||||
@@ -67,56 +79,14 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
|
||||
return { ...s, channel: e.info };
|
||||
case 'devices':
|
||||
return { ...s, devices: e.devices };
|
||||
case 'thread-created': {
|
||||
const thread = { ...e.thread };
|
||||
if (thread.id in s.threadGroups) {
|
||||
const groupId = s.threadGroups[thread.id];
|
||||
if (groupId) thread.groupId = groupId;
|
||||
else delete thread.groupId;
|
||||
}
|
||||
return { ...s, threads: [thread, ...s.threads.filter((entry) => entry.id !== thread.id)] };
|
||||
}
|
||||
case 'thread-renamed':
|
||||
return { ...s, threads: s.threads.map((thread) => thread.id === e.id ? { ...thread, name: e.name } : thread) };
|
||||
case 'group-created':
|
||||
return { ...s, groups: [...s.groups.filter((group) => group.id !== e.group.id), e.group] };
|
||||
case 'group-renamed':
|
||||
return { ...s, groups: s.groups.map((group) => group.id === e.id ? { ...group, name: e.name } : group) };
|
||||
case 'thread-grouped':
|
||||
return { ...s, threadGroups: { ...s.threadGroups, [e.id]: e.groupId ?? null },
|
||||
threads: s.threads.map((thread) => thread.id === e.id ? { ...thread, groupId: e.groupId } : thread) };
|
||||
case 'folder-created':
|
||||
return { ...s, folders: [e.folder, ...s.folders.filter((folder) => folder.id !== e.folder.id)] };
|
||||
case 'folder-renamed':
|
||||
return { ...s, folders: s.folders.map((folder) => folder.id === e.id ? { ...folder, name: e.name } : folder) };
|
||||
case 'items-moved': {
|
||||
const byId = { ...s.byId };
|
||||
const locations = { ...s.locations };
|
||||
for (const id of e.itemIds) {
|
||||
locations[id] = { threadId: e.threadId, ...(e.folderId ? { folderId: e.folderId } : {}) };
|
||||
const item = byId[id];
|
||||
if (!item) continue;
|
||||
if (item.kind === 'file' && item.metadataHidden) continue;
|
||||
const moved = { ...item, threadId: e.threadId };
|
||||
if (e.folderId === undefined) delete moved.folderId;
|
||||
else moved.folderId = e.folderId;
|
||||
byId[id] = moved;
|
||||
}
|
||||
return { ...s, byId, locations };
|
||||
}
|
||||
case 'snapshot':
|
||||
return { ...s, snapshot: e.snapshot };
|
||||
case 'item-author': {
|
||||
const item = s.byId[e.id];
|
||||
return { ...s, authors: { ...s.authors, [e.id]: e.author },
|
||||
byId: item ? { ...s.byId, [e.id]: { ...item, author: e.author } } : s.byId };
|
||||
}
|
||||
case 'file-catalog':
|
||||
case 'item': {
|
||||
const known = e.item.id in s.byId;
|
||||
let item = e.item;
|
||||
const current = s.byId[item.id];
|
||||
const author = item.author ?? s.authors[item.id] ?? current?.author;
|
||||
const author = s.authors[item.id] ?? item.author ?? current?.author;
|
||||
if (author) item = { ...item, author };
|
||||
if (e.t === 'item' && item.kind === 'file' && item.metadataHidden && current?.kind === 'file' && !current.metadataHidden) return s;
|
||||
const location = s.locations[item.id];
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from 'zustand';
|
||||
import { useEffect } from 'react';
|
||||
import { serverApi } from '@/core-bridge/server';
|
||||
import type { ServerInfoBody } from '@/core-bridge/generated/api';
|
||||
|
||||
interface ServerState {
|
||||
info?: ServerInfoBody;
|
||||
error?: string;
|
||||
load: () => Promise<ServerInfoBody>;
|
||||
}
|
||||
let loading: Promise<ServerInfoBody> | undefined;
|
||||
export const useServer = create<ServerState>()((set, get) => ({
|
||||
load: () => {
|
||||
const info = get().info;
|
||||
if (info) return Promise.resolve(info);
|
||||
loading ??= serverApi.info().then((value) => {
|
||||
if (!value.defaults || !value.choices) throw new Error('服务端需要升级以提供统一设置');
|
||||
set({ info: value, error: undefined });
|
||||
return value;
|
||||
}).catch((error: unknown) => {
|
||||
set({ error: error instanceof Error ? error.message : '服务端设置读取失败' });
|
||||
throw error;
|
||||
}).finally(() => { loading = undefined; });
|
||||
return loading;
|
||||
},
|
||||
}));
|
||||
|
||||
export function useServerSettings() {
|
||||
const info = useServer(s => s.info);
|
||||
const error = useServer(s => s.error);
|
||||
const load = useServer(s => s.load);
|
||||
useEffect(() => { void load().catch(() => {}); }, [load]);
|
||||
return { info, error, load };
|
||||
}
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user