feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
- chat: left-aligned discord-style messages with grouped senders, hover actions, an auto-growing composer and file cards - send: render messages and files locally first and replace the placeholder when the worker publishes the real item; failures keep the text with a retry action - navigation: space rail plus a flatter channel list with the space name header and collapsible groups - members: right pane tabs (files / members) grouped by online, offline and pending users, with a prominent invite action and a top bar shortcut - notifications: toast host for invite results; invitees get a toast with an accept action, and the invitation payload now carries the space name and inviter inside the encrypted grant - users over devices: drop device names, the device list and device rename/release; senders show the account (guests show as guest) and the share panel only keeps links - follow contract 7.0.0 (max_members, members_used, members_full, /join) - docs: ui-design and frontend updated for the new layout and concepts
This commit is contained in:
1 parent
5fd4ddd411
commit
4b8d0b5a5f
83 files changed
+1291
-661
No files matched your search
@@ -6,7 +6,7 @@ const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
|
||||
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 type AccountInvitation = InvitationBody & { channelName?: string; invitedBy?: string };
|
||||
|
||||
export const accountApi = {
|
||||
register: (username: string, password: string, remember = true) => callWorker<AccountStatus>('registerAccount', { username, password, remember, apiBase: API_BASE }),
|
||||
|
||||
@@ -11,9 +11,7 @@ export interface WorkspaceApi {
|
||||
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
|
||||
/** 使用现有空间 settings 接口更新空间设置。 */
|
||||
updateChannelSettings(patch: ChannelSettingsPatch): Promise<void>;
|
||||
renameDevice(endpointHash: string, name: string): Promise<void>;
|
||||
releaseDevice(endpointHash: string): Promise<void>;
|
||||
/** 通过链接加入:先 GET 空间信息预览,再 POST seats */
|
||||
/** 通过链接加入:先 GET 空间信息预览,再 POST join */
|
||||
joinChannel(link: ParsedChannelLink, password?: string): Promise<ChannelInfo>;
|
||||
inviteUser(username: string): Promise<void>;
|
||||
listInvitations(): Promise<AccountInvitation[]>;
|
||||
@@ -24,8 +22,8 @@ export interface WorkspaceApi {
|
||||
|
||||
/** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */
|
||||
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
|
||||
sendText(text: string, threadId?: string): Promise<void>;
|
||||
sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void>;
|
||||
sendText(text: string, threadId?: string, localId?: string): Promise<void>;
|
||||
sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise<void>;
|
||||
createThread(name: string, groupId?: string): Promise<string>;
|
||||
createGroup(name: string): Promise<string>;
|
||||
renameGroup(id: string, name: string): Promise<void>;
|
||||
|
||||
@@ -3,15 +3,16 @@ import { supportsApiContract } from './contract';
|
||||
|
||||
describe('API contract compatibility', () => {
|
||||
it('accepts compatible SemVer releases in the supported major', () => {
|
||||
expect(supportsApiContract('6.0.0')).toBe(true);
|
||||
expect(supportsApiContract('6.8.2')).toBe(true);
|
||||
expect(supportsApiContract('6.2.0-rc.1')).toBe(true);
|
||||
expect(supportsApiContract('7.0.0')).toBe(true);
|
||||
expect(supportsApiContract('7.8.2')).toBe(true);
|
||||
expect(supportsApiContract('7.2.0-rc.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a breaking major and malformed versions', () => {
|
||||
expect(supportsApiContract('3.0.0')).toBe(false);
|
||||
expect(supportsApiContract('4.0.0')).toBe(false);
|
||||
expect(supportsApiContract('5.0.0')).toBe(false);
|
||||
expect(supportsApiContract('6.1.0')).toBe(false);
|
||||
expect(supportsApiContract('01.0.0')).toBe(false);
|
||||
expect(supportsApiContract(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SUPPORTED_API_CONTRACT_MAJOR = 6;
|
||||
export const SUPPORTED_API_CONTRACT_MAJOR = 7;
|
||||
|
||||
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface SavedResource {
|
||||
kind: 'channel' | 'share';
|
||||
/** 服务端空间设置名称的本地缓存,打开空间时会用最新设置刷新。 */
|
||||
name?: string;
|
||||
/** 本终端在席位列表中的自定义显示名称。 */
|
||||
deviceLabel?: string;
|
||||
mode: 'enc' | 'raw';
|
||||
/** Keys stay in IndexedDB. The page URL and localStorage never retain them. */
|
||||
link: string;
|
||||
@@ -30,7 +28,7 @@ export interface SavedResource {
|
||||
mine?: boolean;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
seats: number;
|
||||
maxMembers: number;
|
||||
state?: 'draft' | 'sealed';
|
||||
maxVisitors?: number;
|
||||
maxDownloads?: number | null;
|
||||
@@ -137,7 +135,7 @@ export async function importResources(resources: SavedResource[]): Promise<void>
|
||||
const existing = await tx.store.get(resource.id);
|
||||
await tx.store.put({ ...resource, localScope: resourceScope,
|
||||
endpointSecret: existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32)),
|
||||
deviceLabel: existing?.deviceLabel, localItems: existing?.localItems,
|
||||
localItems: existing?.localItems,
|
||||
pendingManifestId: existing?.pendingManifestId,
|
||||
needsJoin: existing?.needsJoin ?? (!existing && resource.kind === 'channel'),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const messages: Record<string, string> = {
|
||||
not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。',
|
||||
seats_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。',
|
||||
members_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。',
|
||||
visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。',
|
||||
forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。',
|
||||
too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。',
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface VaultResource {
|
||||
expiresAt: number;
|
||||
creatorAccount?: string;
|
||||
name?: string;
|
||||
seats: number;
|
||||
maxMembers: number;
|
||||
ttlSeconds?: number;
|
||||
maxDownloads?: number | null;
|
||||
maxVisitors?: number;
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface PublishedFile {
|
||||
mime: string;
|
||||
size: number;
|
||||
ts: number;
|
||||
deviceId: string;
|
||||
gated: boolean;
|
||||
}
|
||||
export type ResourceControl = { op: 'file.published'; item: PublishedFile };
|
||||
|
||||
@@ -17,8 +17,6 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) =
|
||||
export const realApi: WorkspaceApi = {
|
||||
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
|
||||
updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker<void>('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }),
|
||||
renameDevice: (endpointHash, name) => callWorker<void>('renameDevice', { id: currentChannelId(), endpointHash, name, apiBase: API_BASE }),
|
||||
releaseDevice: (endpointHash) => callWorker<void>('releaseDevice', { id: currentChannelId(), endpointHash, apiBase: API_BASE }),
|
||||
joinChannel: (link: ParsedChannelLink, password?: string) => callWorker<ChannelInfo>('joinChannel', { link, password, apiBase: API_BASE }),
|
||||
inviteUser: (username) => callWorker<void>('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }),
|
||||
listInvitations: () => callWorker<AccountInvitation[]>('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }),
|
||||
@@ -37,8 +35,8 @@ export const realApi: WorkspaceApi = {
|
||||
};
|
||||
},
|
||||
|
||||
sendText: (text, threadId) => callWorker<void>('sendText', { id: currentChannelId(), text, threadId, apiBase: API_BASE }),
|
||||
sendFiles: (files, options, threadId, folderId) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, apiBase: API_BASE }),
|
||||
sendText: (text, threadId, localId) => callWorker<void>('sendText', { id: currentChannelId(), text, threadId, localId, apiBase: API_BASE }),
|
||||
sendFiles: (files, options, threadId, folderId, localIds) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, localIds, apiBase: API_BASE }),
|
||||
createThread: (name, groupId) => callWorker<string>('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }),
|
||||
createGroup: (name) => callWorker<string>('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }),
|
||||
renameGroup: (groupId, name) => callWorker<void>('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }),
|
||||
|
||||
@@ -3,6 +3,6 @@ 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,
|
||||
return { mode: defaults.channel_mode, maxMembers: settings.max_members, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
|
||||
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests };
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
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';
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DeviceKind;
|
||||
self?: boolean;
|
||||
/** 正在编辑便签 */
|
||||
editing?: boolean;
|
||||
joinedAt?: number;
|
||||
}
|
||||
|
||||
/** 常驻空间:自己多台设备之间长期同步 */
|
||||
export interface ChannelInfo {
|
||||
dataSource?: 'server' | 'cache';
|
||||
@@ -26,7 +15,7 @@ export interface ChannelInfo {
|
||||
name?: string;
|
||||
mode: ChannelMode;
|
||||
role: 'owner' | 'member';
|
||||
seats: number;
|
||||
maxMembers: number;
|
||||
membersUsed?: number;
|
||||
membersCanUpload?: boolean;
|
||||
ttlSeconds?: number;
|
||||
@@ -48,7 +37,7 @@ export interface CreateOptions {
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
mode: ChannelMode;
|
||||
seats: number;
|
||||
maxMembers: number;
|
||||
ttlSeconds: number;
|
||||
membersCanUpload: boolean;
|
||||
fileMaxDownloads?: number;
|
||||
@@ -65,7 +54,7 @@ export interface ChannelSettingsPatch {
|
||||
allowGuests?: boolean;
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
seats?: number;
|
||||
maxMembers?: number;
|
||||
membersCanUpload?: boolean;
|
||||
ttlSeconds?: number;
|
||||
fileMaxDownloads?: number;
|
||||
@@ -111,10 +100,11 @@ interface ItemBase {
|
||||
author?: SenderInfo;
|
||||
id: string;
|
||||
ts: number;
|
||||
deviceId: string;
|
||||
state: ItemState;
|
||||
threadId?: string;
|
||||
folderId?: string;
|
||||
/** 乐观渲染:本地占位项的 id,worker 发布真实条目时带回,reduce 据此替换占位项 */
|
||||
localId?: string;
|
||||
}
|
||||
|
||||
export type SenderInfo = Pick<AccountProfile, 'username' | 'displayName'> & { avatar?: string };
|
||||
@@ -123,8 +113,8 @@ export interface TextItem extends ItemBase {
|
||||
kind: 'text';
|
||||
text: string;
|
||||
editedAt?: number;
|
||||
/** 另一台设备同时改过(后写者胜,见 ui-design §8) */
|
||||
conflict?: { deviceId: string; text: string };
|
||||
/** 另一位用户同时改过(后写者胜,见 ui-design §8) */
|
||||
conflict?: { author?: SenderInfo; text: string };
|
||||
}
|
||||
|
||||
export interface FileItem extends ItemBase {
|
||||
@@ -144,14 +134,14 @@ export interface FileItem extends ItemBase {
|
||||
* 客户端据此把它们折叠成同一个文件的历史。后端不解析 meta。
|
||||
*/
|
||||
versions?: FileVersion[];
|
||||
/** 当前版本是别的设备改的,而你还没看过 */
|
||||
/** 当前版本是别人改的,而你还没看过 */
|
||||
unseen?: boolean;
|
||||
}
|
||||
|
||||
export interface FileVersion {
|
||||
id: string;
|
||||
ts: number;
|
||||
deviceId: string;
|
||||
author?: SenderInfo;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@@ -181,7 +171,6 @@ export type WorkspaceEvent =
|
||||
| { t: 'ready' }
|
||||
| { t: 'connection'; state: ConnectionState }
|
||||
| { t: 'channel'; info: ChannelInfo }
|
||||
| { t: 'devices'; devices: Device[] }
|
||||
| { t: 'item'; item: Item }
|
||||
| { t: 'file-catalog'; item: FileItem }
|
||||
| { t: 'removed'; id: string }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { createChannel, forgetChannel, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
|
||||
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
|
||||
import { stopChannel } from './worker/channelTransport';
|
||||
import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft';
|
||||
@@ -68,8 +68,6 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
case 'createFolder': return createFolder(id, String(payload.threadId ?? ''), String(payload.name ?? ''), typeof payload.parentId === 'string' ? payload.parentId : undefined);
|
||||
case 'renameFolder': return renameFolder(id, String(payload.folderId ?? ''), String(payload.name ?? ''), String(payload.threadId ?? ''));
|
||||
case 'moveItems': return moveItems(id, payload.itemIds as string[], String(payload.threadId ?? ''), typeof payload.folderId === 'string' ? payload.folderId : undefined);
|
||||
case 'renameDevice': return renameDevice(id, String(payload.endpointHash ?? ''), String(payload.name ?? ''));
|
||||
case 'releaseDevice': return releaseDevice(id, String(payload.endpointHash ?? ''));
|
||||
case 'joinChannel': return joinChannel(payload as Parameters<typeof joinChannel>[0]);
|
||||
case 'previewChannel': return previewChannel(payload.link as Parameters<typeof previewChannel>[0]);
|
||||
case 'channelMembers': return channelMembers(id);
|
||||
@@ -77,8 +75,8 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
case 'forgetChannel': return forgetChannel(id);
|
||||
case 'connectChannel': void connectChannelResource(id); return true;
|
||||
case 'stopChannel': stopChannel(id); return true;
|
||||
case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined);
|
||||
case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters<typeof sendFiles>[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined);
|
||||
case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined, undefined, typeof payload.localId === 'string' ? payload.localId : undefined);
|
||||
case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters<typeof sendFiles>[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined, Array.isArray(payload.localIds) ? payload.localIds as string[] : undefined);
|
||||
case 'removeChannelItem': return removeChannelItem(id, String(payload.itemId));
|
||||
case 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined);
|
||||
case 'updateSnapshot': return updateSnapshot(id, String(payload.text ?? ''));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { setAccountIdentity, requireAccount } from './accountSession';
|
||||
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 { openChannelGrant, peekChannelGrant, 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';
|
||||
@@ -26,7 +26,7 @@ interface AccountSession {
|
||||
controller: AbortController; dirty: boolean; syncing?: Promise<void>; error?: string;
|
||||
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
export type Invitation = InvitationBody;
|
||||
export type Invitation = InvitationBody & { channelName?: string; invitedBy?: string };
|
||||
let account: AccountSession | undefined;
|
||||
export function unlockedAccount() {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
@@ -246,7 +246,13 @@ export async function revokeAccountDevice(id: string): Promise<void> {
|
||||
if ((await localAccountDevice()).id === id) await logoutAccount();
|
||||
}
|
||||
export async function listAccountInvitations(): Promise<Invitation[]> {
|
||||
requireAccount(); return apiFetch('/accounts/invitations').then((r) => r.json() as Promise<Invitation[]>);
|
||||
requireAccount();
|
||||
const rows = await apiFetch('/accounts/invitations').then((r) => r.json() as Promise<InvitationBody[]>);
|
||||
const session = account;
|
||||
return Promise.all(rows.map(async (row): Promise<Invitation> => {
|
||||
const meta = session ? await peekChannelGrant(row.payload, session.username, row.channel_id, session.profile.keys.privateKey) : {};
|
||||
return { ...row, ...(meta.name ? { channelName: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
|
||||
}));
|
||||
}
|
||||
export async function acceptInvitation(channelId: string): Promise<void> {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
@@ -262,7 +268,7 @@ export async function inviteChannelUser(channelId: string, input: string): Promi
|
||||
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);
|
||||
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey, { name: resource.name, invitedBy: account?.username });
|
||||
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 }) });
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function recoverAccountChannels(username: string, keys: InvitationK
|
||||
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, maxDownloads: info.settings.file_max_downloads };
|
||||
maxMembers: info.settings.max_members, maxDownloads: info.settings.file_max_downloads };
|
||||
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
|
||||
await importResources([resource]);
|
||||
} finally { session.dispose(); }
|
||||
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest';
|
||||
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, name: `version-${modifiedAt}` });
|
||||
link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), localScope: 'account:alice',
|
||||
createdAt: 1, modifiedAt, expiresAt: 100, maxMembers: 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());
|
||||
expect(exported).not.toHaveProperty('endpointSecret'); expect(exported).not.toHaveProperty('deviceLabel');
|
||||
expect(exported).not.toHaveProperty('endpointSecret');
|
||||
expect(exported).not.toHaveProperty('localScope'); expect(exported.link).toContain('secret');
|
||||
});
|
||||
it('a stale device does not overwrite a newer remote resource', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export function exportResource(resource: SavedResource): VaultResource {
|
||||
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,
|
||||
maxMembers: resource.maxMembers, ttlSeconds: resource.ttlSeconds,
|
||||
maxDownloads: resource.maxDownloads, maxVisitors: resource.maxVisitors, mine: resource.mine, state: resource.state };
|
||||
}
|
||||
export function validateVault(value: unknown): VaultProfile {
|
||||
|
||||
@@ -3,11 +3,10 @@ import { canDecryptRecord, objectEventProjection, readDataKeys, verifyObjectDele
|
||||
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 { showRejected, publishWorkspace } from './presentation';
|
||||
import { type ChannelWire, toChannelInfo } from './wire';
|
||||
import { apiFetch, readJson } from './http';
|
||||
import { readResourceRecord } from './workspaceRecords';
|
||||
@@ -15,12 +14,11 @@ import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog';
|
||||
|
||||
export function mapReceived(item: ReceivedItem): Item {
|
||||
const timestamp = item.meta?.ts ?? Date.now();
|
||||
const deviceId = item.meta?.dev ?? '未知设备';
|
||||
const state = item.complete ? { kind: 'complete' as const } : { kind: 'incomplete' as const };
|
||||
if (item.kind === 'file') {
|
||||
const name = item.meta?.name ?? '加密文件';
|
||||
return {
|
||||
id: item.itemId, kind: 'file', ts: timestamp, deviceId, state, name,
|
||||
id: item.itemId, kind: 'file', ts: timestamp, state, name,
|
||||
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
|
||||
...(item.meta?.folderId ? { folderId: item.meta.folderId } : {}),
|
||||
mime: item.meta?.mime ?? 'application/octet-stream', size: item.meta?.size ?? item.content.byteLength,
|
||||
@@ -28,7 +26,7 @@ export function mapReceived(item: ReceivedItem): Item {
|
||||
gated: item.gated,
|
||||
};
|
||||
}
|
||||
return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state,
|
||||
return { id: item.itemId, kind: 'text', ts: timestamp, state,
|
||||
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
|
||||
text: new TextDecoder().decode(item.content) };
|
||||
}
|
||||
@@ -70,11 +68,11 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
const item = mapReceived(event.item);
|
||||
if (item.kind === 'file') visibleFiles.add(`${resource.id}:${item.id}`);
|
||||
if (event.item.snapshot) {
|
||||
let snapshot: Snapshot = { text: '', updatedBy: item.deviceId, updatedAt: item.ts };
|
||||
let snapshot: Snapshot = { text: '', updatedBy: '', updatedAt: item.ts };
|
||||
if (item.kind === 'text') {
|
||||
try {
|
||||
const value = JSON.parse(item.text) as Partial<Snapshot>;
|
||||
if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? item.deviceId, updatedAt: value.updatedAt ?? item.ts };
|
||||
if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? '', updatedAt: value.updatedAt ?? item.ts };
|
||||
else snapshot.text = item.text;
|
||||
} catch { snapshot.text = item.text; }
|
||||
}
|
||||
@@ -93,7 +91,7 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
if (catalog) { publishWorkspace(resource.id, { t: 'file-catalog', item: catalog }); break; }
|
||||
const local = (await getResource(resource.id))?.localItems?.find((item) => item.id === event.itemId);
|
||||
const placeholder: FileItem = local?.kind === 'file' ? local : {
|
||||
id: event.itemId, kind: 'file', ts: event.createdAt, deviceId: '未知设备', state: { kind: 'incomplete' },
|
||||
id: event.itemId, kind: 'file', ts: event.createdAt, state: { kind: 'incomplete' },
|
||||
name: '受限下载内容', mime: 'application/octet-stream', size: 0, remaining: null, preview: 'none',
|
||||
gated: true, metadataHidden: true,
|
||||
};
|
||||
@@ -128,22 +126,6 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
|
||||
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<SeatInfo[]>(
|
||||
`/channels/${resource.id}/seats`, session.authHeaders('owner'));
|
||||
devices = await Promise.all(seats.map(async (seat) => {
|
||||
let name: string | undefined;
|
||||
if (seat.label) {
|
||||
try { name = await session.openLabel(seat.label, seat.endpoint_hash); }
|
||||
catch { name = '设备名校验失败'; }
|
||||
}
|
||||
return deviceInfo(seat.endpoint_hash, seat.joined_at, name ?? (seat.endpoint_hash === ownId ? ownName : undefined), ownId);
|
||||
}));
|
||||
}
|
||||
publishWorkspace(resource.id, { t: 'devices', devices });
|
||||
}
|
||||
|
||||
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
|
||||
|
||||
@@ -8,16 +8,30 @@ interface ChannelGrant {
|
||||
mode: 'enc' | 'raw';
|
||||
role: 'owner' | 'member';
|
||||
keys: ParsedChannelLink['keys'];
|
||||
/** 仅供被邀请方在收件箱显示,在加密 payload 内,服务端不可见 */
|
||||
name?: string;
|
||||
invitedBy?: string;
|
||||
}
|
||||
|
||||
export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey): Promise<string> {
|
||||
export interface InvitationMeta { name?: string; invitedBy?: string }
|
||||
|
||||
export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey, meta: InvitationMeta = {}): Promise<string> {
|
||||
const parsed = parseChannelLink(link);
|
||||
if (!parsed.ok) throw new Error('空间凭据格式无效');
|
||||
const grant: ChannelGrant = { v: 1, channelId: parsed.link.id, mode: parsed.link.mode,
|
||||
role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys };
|
||||
role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys,
|
||||
...(meta.name ? { name: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
|
||||
return encryptInvitation(JSON.stringify(grant), recipient, grant.channelId, publicKey);
|
||||
}
|
||||
|
||||
/** 只读取显示用的元信息;解不开或没有这些字段时返回空,调用方回退到空间 ID */
|
||||
export async function peekChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey): Promise<InvitationMeta> {
|
||||
try {
|
||||
const grant = JSON.parse(await decryptInvitation(payload, recipient, channelId, privateKey)) as Partial<ChannelGrant>;
|
||||
return { ...(typeof grant.name === 'string' ? { name: grant.name } : {}), ...(typeof grant.invitedBy === 'string' ? { invitedBy: grant.invitedBy } : {}) };
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function getChannelBlob(resourceId: string, itemId: string, version
|
||||
verifyFileCatalog(fileCatalogs.get(`${resourceId}:${itemId}`), result.meta, result.blob.size);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), deviceId: '', text: '', state: { kind: 'tampered' } } });
|
||||
if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), text: '', state: { kind: 'tampered' } } });
|
||||
throw error;
|
||||
}
|
||||
const fileBlob = result.meta?.kind === 'file' && typeof File !== 'undefined'
|
||||
@@ -58,7 +58,7 @@ export async function getChannelBlob(resourceId: string, itemId: string, version
|
||||
blobs.set(`${resourceId}:${itemId}`, fileBlob);
|
||||
if (result.meta?.kind === 'file') {
|
||||
const item: FileItem = {
|
||||
id: itemId, kind: 'file', ts: result.meta.ts, deviceId: result.meta.dev ?? '未知设备', state: { kind: 'complete' },
|
||||
id: itemId, kind: 'file', ts: result.meta.ts, state: { kind: 'complete' },
|
||||
...(result.meta.threadId ? { threadId: result.meta.threadId } : {}),
|
||||
...(result.meta.folderId ? { folderId: result.meta.folderId } : {}),
|
||||
name: result.meta.name ?? '加密文件', mime: result.meta.mime ?? result.blob.type, size: fileBlob.size,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChannelSession, toBase64Url } from '@webdrop/core';
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
|
||||
@@ -7,14 +7,12 @@ 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 type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '../generated/api';
|
||||
import { getSession, sessions } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { makeLink } from './links';
|
||||
import { toChannelInfo, type ChannelWire } from './wire';
|
||||
import { stopChannel } from './channelTransport';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { channelMetadata } from './channelEvents';
|
||||
import { requireAccount, accountPublicKey } from './accountSession';
|
||||
import { sealChannelGrant } from './channelGrant';
|
||||
|
||||
@@ -32,7 +30,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
settings: {
|
||||
...defaults,
|
||||
name: options.name?.trim() || undefined,
|
||||
seats: options.seats,
|
||||
max_members: options.maxMembers,
|
||||
ttl_seconds: options.ttlSeconds,
|
||||
members_can_upload: options.membersCanUpload,
|
||||
file_max_downloads: options.fileMaxDownloads,
|
||||
@@ -53,13 +51,9 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
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,
|
||||
maxMembers: info.settings.max_members,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: [],
|
||||
};
|
||||
const ownerLabel = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${session.id}/seats/me`, {
|
||||
method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, body: ownerLabel,
|
||||
});
|
||||
await saveResource(resource);
|
||||
sessions.set(session.id, session);
|
||||
attachDataKeys(session, resource);
|
||||
@@ -80,10 +74,10 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
|
||||
try {
|
||||
const info = await readJson<ChannelWire>(`/channels/${id}`, session.authHeaders('read'));
|
||||
session.verifyChannelInfo(info);
|
||||
const joined = await apiFetch(`/channels/${id}/seats`, {
|
||||
const joined = await apiFetch(`/channels/${id}/join`, {
|
||||
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<SeatJoined>);
|
||||
info.seats_used = joined.seats_used;
|
||||
}).then((response) => response.json() as Promise<JoinResult>);
|
||||
info.members_used = joined.members_used;
|
||||
const resource: SavedResource = {
|
||||
...channelCache(info),
|
||||
id, kind: 'channel', mode: session.mode, role: session.role,
|
||||
@@ -91,13 +85,9 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
|
||||
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,
|
||||
maxMembers: info.settings.max_members,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [],
|
||||
};
|
||||
const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${id}/seats/me`, {
|
||||
method: 'PUT', headers: { ...session.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, body: label,
|
||||
});
|
||||
await saveResource(resource);
|
||||
sessions.set(id, session);
|
||||
return toChannelInfo(info, resource);
|
||||
@@ -114,7 +104,7 @@ 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 });
|
||||
expiresAt: info.expires_at, maxMembers: info.settings.max_members });
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
|
||||
@@ -146,7 +136,7 @@ export async function listChannels(): Promise<ChannelInfo[]> {
|
||||
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,
|
||||
id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, maxMembers: resource.maxMembers, maxDownloads: resource.maxDownloads ?? undefined,
|
||||
creatorAccount: resource.creatorAccount,
|
||||
memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
|
||||
dataSource: 'cache',
|
||||
@@ -161,7 +151,7 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
|
||||
const body: SettingsRequest = {};
|
||||
if (patch.name !== undefined) body.name = patch.name.trim();
|
||||
if (patch.seats !== undefined) body.seats = patch.seats;
|
||||
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
|
||||
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
|
||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
|
||||
@@ -177,36 +167,6 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
||||
publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) });
|
||||
}
|
||||
|
||||
export async function renameDevice(resourceId: string, endpointHash: string, name: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const ownEndpointHash = toBase64Url(session.endpointHash);
|
||||
const isSelf = endpointHash === ownEndpointHash;
|
||||
if (!isSelf && !session.can('owner')) throw new Error('只有所有者可以重命名其他设备');
|
||||
const normalized = name.trim();
|
||||
if (!normalized) throw new Error('设备名称不能为空');
|
||||
const label = await session.sealLabel(normalized, { endpointHash });
|
||||
const path = isSelf
|
||||
? `/channels/${resourceId}/seats/me`
|
||||
: `/channels/${resourceId}/seats/${endpointHash}/label`;
|
||||
const role = !isSelf || session.can('owner') ? 'owner' : 'read';
|
||||
await apiFetch(path, {
|
||||
method: 'PUT', headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream' }, body: label,
|
||||
});
|
||||
if (isSelf) {
|
||||
resource.deviceLabel = normalized;
|
||||
await saveResource(resource);
|
||||
}
|
||||
await channelMetadata(resource, session);
|
||||
}
|
||||
|
||||
export async function releaseDevice(resourceId: string, endpointHash: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (!session.can('owner')) throw new Error('只有所有者可以移除设备');
|
||||
if (endpointHash === toBase64Url(session.endpointHash)) throw new Error('不能移除当前设备');
|
||||
await apiFetch(`/channels/${resourceId}/seats/${endpointHash}`, { method: 'DELETE', headers: session.authHeaders('owner') });
|
||||
await channelMetadata(resource, session);
|
||||
}
|
||||
|
||||
export async function forgetChannel(id: string): Promise<void> {
|
||||
stopChannel(id);
|
||||
sessions.get(id)?.dispose();
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { FileItem, FileUploadOptions, Item } from '../types';
|
||||
|
||||
import { blobs, getSession } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { accountDisplay } from './accountSession';
|
||||
|
||||
|
||||
|
||||
@@ -18,18 +19,15 @@ import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPla
|
||||
import { publishWorkspace } from './presentation';
|
||||
|
||||
export async function sendText(resourceId: string, text: string, threadId?: string,
|
||||
event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }): Promise<{ itemId: string; uploaded: boolean }> {
|
||||
event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }, localId?: 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'))) 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 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 } : {}) };
|
||||
const plan = session.encryptText(text, { ts, threadId, itemId: prepared.itemIdBytes });
|
||||
const item: Item = { id: plan.itemId, kind: 'text', ts, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}), ...(localId ? { localId } : {}) };
|
||||
blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' }));
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch,
|
||||
@@ -41,17 +39,16 @@ export async function sendText(resourceId: string, text: string, threadId?: stri
|
||||
return { itemId: plan.itemId, uploaded };
|
||||
}
|
||||
|
||||
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void> {
|
||||
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise<void> {
|
||||
if (!threadId) throw new Error('请先选择频道');
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
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;
|
||||
const device = await deviceIdentity(session.endpointSecret);
|
||||
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||
const author = await senderSnapshot();
|
||||
for (const file of files) {
|
||||
for (const [index, file] of files.entries()) {
|
||||
const localId = localIds?.[index];
|
||||
const ts = Date.now();
|
||||
let item: FileItem | undefined;
|
||||
try {
|
||||
@@ -59,12 +56,12 @@ export async function sendFiles(resourceId: string, files: File[], suppliedOptio
|
||||
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,
|
||||
ts, maxFrameData, threadId, folderId,
|
||||
});
|
||||
item = {
|
||||
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
|
||||
id: plan.itemId, kind: 'file', ts, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
|
||||
...(author ? { author } : {}),
|
||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
|
||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
|
||||
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
||||
remaining: plan.gated
|
||||
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
|
||||
@@ -127,11 +124,11 @@ export async function sendFiles(resourceId: string, files: File[], suppliedOptio
|
||||
|
||||
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 name = accountDisplay()?.username ?? '';
|
||||
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 });
|
||||
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, itemId: prepared.itemIdBytes });
|
||||
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'chat.snapshot' });
|
||||
await flushOutbox(resource, session);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,6 @@ export async function queueFileCatalog(resource: SavedResource, session: Channel
|
||||
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 });
|
||||
{ kind: 'other', threadId: item.threadId }, { 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' });
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { flushOutbox } from './transfer';
|
||||
import { socketStream, sseStream, delay, type StreamConsumer } from './transports';
|
||||
import { errorMessage } from '../errors';
|
||||
import { saveResource } from '../database';
|
||||
import { deviceIdentity } from './presentation';
|
||||
|
||||
export async function connectChannel(id: string): Promise<void> {
|
||||
active.get(id)?.abort();
|
||||
@@ -21,11 +20,9 @@ export async function connectChannel(id: string): Promise<void> {
|
||||
if (signal.aborted) return;
|
||||
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
|
||||
// Device sessions can be revoked independently of account membership; rejoin idempotently.
|
||||
await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
|
||||
await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
|
||||
if (resource.needsJoin) {
|
||||
if (session.can('owner')) await channelMetadata(resource, session);
|
||||
const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${id}/seats/me`, { method: 'PUT', headers: session.authHeaders('read'), body: label, signal });
|
||||
resource.needsJoin = false;
|
||||
await saveResource(resource);
|
||||
}
|
||||
@@ -35,7 +32,7 @@ export async function connectChannel(id: string): Promise<void> {
|
||||
async control(value) {
|
||||
if (value.t === 'settings') {
|
||||
await Promise.all([channelMetadata(resource, session), loadWorkspace(id)]);
|
||||
} else if (value.t === 'seats') await channelMetadata(resource, session);
|
||||
} else if (value.t === 'members') await channelMetadata(resource, session);
|
||||
},
|
||||
online() { if (!signal.aborted) publishWorkspace(id, { t: 'connection', state: 'online' }); },
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ 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,
|
||||
name: item.name, mime: item.mime, size: item.size, ts: item.ts,
|
||||
gated: item.gated ?? false };
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('API contract preflight', () => {
|
||||
});
|
||||
|
||||
it('blocks API requests when the server has a newer breaking major', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }));
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setApiBase('http://api.test/api/v1');
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('API contract preflight', () => {
|
||||
|
||||
it('checks the contract once and then sends the API request', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '6.0.0' }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ItemEvent } from '@webdrop/core';
|
||||
import type { Device, FileItem, ShareEvent, WorkspaceEvent } from '../types';
|
||||
import type { FileItem, ShareEvent, WorkspaceEvent } from '../types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { publish } from './port';
|
||||
export type TerminalKind = 'desktop' | 'phone' | 'tablet';
|
||||
|
||||
interface ClientHints {
|
||||
mobile?: boolean;
|
||||
model?: string;
|
||||
@@ -28,7 +30,7 @@ function version(value: string | undefined): string | undefined {
|
||||
return value?.split('.').slice(0, 2).join('.') || undefined;
|
||||
}
|
||||
|
||||
export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: Device['kind'] }> {
|
||||
export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: TerminalKind }> {
|
||||
const ua = navigator.userAgent;
|
||||
const hints = await readClientHints();
|
||||
const android = /Android/i.test(ua) || hints.platform === 'Android';
|
||||
@@ -36,7 +38,7 @@ export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name
|
||||
const ipad = /iPad/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const tablet = ipad || (android && (hints.mobile === false || (!hints.mobile && !/Mobile/i.test(ua))));
|
||||
const mobile = !tablet && (android || iphone || /Mobile/i.test(ua));
|
||||
const kind: Device['kind'] = tablet ? 'tablet' : mobile ? 'phone' : 'desktop';
|
||||
const kind: TerminalKind = tablet ? 'tablet' : mobile ? 'phone' : 'desktop';
|
||||
const iosVersion = ua.match(/(?:iPhone )?OS ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
|
||||
const windowsVersion = version(hints.platformVersion);
|
||||
const macVersion = ua.match(/Mac OS X ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
|
||||
@@ -66,24 +68,16 @@ export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name
|
||||
return { name: `${identity} · ${suffix}`, kind };
|
||||
}
|
||||
|
||||
export function deviceInfo(id: string, joinedAt: number, label: string | undefined, selfId: string): Device {
|
||||
const name = label ?? '未知设备';
|
||||
const kind: Device['kind'] = /iPhone|iPod/i.test(name) ? 'phone'
|
||||
: /iPad|Galaxy Tab|Pixel Tablet|Android.*Tablet/i.test(name) ? 'tablet'
|
||||
: /Android|Pixel|Galaxy|OnePlus|Xiaomi|Redmi|OPPO|vivo|Huawei/i.test(name) ? 'phone'
|
||||
: 'desktop';
|
||||
return { id, name, kind, self: id === selfId, joinedAt };
|
||||
}
|
||||
export function showRejected(resourceId: string, event: Extract<ItemEvent, { type: 'rejected' }>, share = false): void {
|
||||
if (share) publish(resourceId, { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' });
|
||||
else if (event.itemId) publish(resourceId, {
|
||||
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), deviceId: '未知设备', state: { kind: 'tampered' }, text: '' },
|
||||
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), state: { kind: 'tampered' }, text: '' },
|
||||
});
|
||||
else publish(resourceId, { t: 'error', message: '内容校验失败,已拒绝该记录。' });
|
||||
}
|
||||
|
||||
export function makeFileItem(id: string, name: string, mime: string, size: number, ts: number, state: FileItem['state'], remaining: number | null = null): FileItem {
|
||||
return { id, kind: 'file', ts, deviceId: '创建者', state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
|
||||
return { id, kind: 'file', ts, state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
|
||||
}
|
||||
export function publishWorkspace(resourceId: string, event: WorkspaceEvent): void {
|
||||
publish(resourceId, event);
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('resource and rendering boundaries', () => {
|
||||
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',
|
||||
const item: FileItem = { id: 'a', threadId: 'channel', kind: 'file', name: 'secret.txt', mime: 'text/plain', size: 10, ts: 1,
|
||||
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');
|
||||
@@ -21,7 +21,7 @@ describe('resource and rendering boundaries', () => {
|
||||
});
|
||||
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: [],
|
||||
endpointSecret: new Uint8Array(32), maxMembers: 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);
|
||||
|
||||
@@ -4,7 +4,7 @@ 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,
|
||||
createdAt: raw.created_at, expiresAt: raw.expires_at, maxMembers: raw.settings.max_members,
|
||||
maxDownloads: raw.settings.file_max_downloads,
|
||||
ttlSeconds: raw.settings.ttl_seconds, channelMetadata: raw };
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ function newTextEntry(text: string): { item: TextItem; entry: Extract<ManifestEn
|
||||
const ts = Date.now();
|
||||
const localId = `draft-text-${crypto.randomUUID()}`;
|
||||
return {
|
||||
item: { id: localId, kind: 'text', ts, deviceId: '创建者', state: { kind: 'complete' }, text },
|
||||
item: { id: localId, kind: 'text', ts, state: { kind: 'complete' }, text },
|
||||
entry: { t: 'text', localId, text, ts },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ async function loadSealedManifest(resource: SavedResource, session: ChannelSessi
|
||||
const entry = manifest.items[index];
|
||||
if (!entry) throw new Error('invalid_manifest_item');
|
||||
if (entry.t === 'text' && typeof entry.text === 'string' && Number.isSafeInteger(entry.ts)) {
|
||||
const item: TextItem = { id: `manifest-text-${index}`, kind: 'text', ts: entry.ts, deviceId: '创建者', state: { kind: 'complete' }, text: entry.text };
|
||||
const item: TextItem = { id: `manifest-text-${index}`, kind: 'text', ts: entry.ts, state: { kind: 'complete' }, text: entry.text };
|
||||
publishShare(resource.id, { t: 'item', item });
|
||||
} else if (entry.t === 'file' && typeof entry.id === 'string' && typeof entry.size === 'number' && Number.isSafeInteger(entry.size)) {
|
||||
const detail = byId.get(entry.id);
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function createShare(settings: ShareSettings, captchaToken?: string
|
||||
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,
|
||||
createdAt: raw.created_at, expiresAt: raw.expires_at, maxMembers: 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,
|
||||
@@ -64,7 +64,7 @@ export async function joinShare(input: { link: { id: string; key: string } }): P
|
||||
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, state: raw.state,
|
||||
maxMembers: 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,
|
||||
};
|
||||
|
||||
@@ -14,8 +14,8 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
|
||||
role: raw.can_manage_structure ? 'owner' : 'member',
|
||||
canUpload: raw.can_upload,
|
||||
canManageStructure: raw.can_manage_structure,
|
||||
seats: raw.settings.seats,
|
||||
membersUsed: raw.seats_used,
|
||||
maxMembers: raw.settings.max_members,
|
||||
membersUsed: raw.members_used,
|
||||
membersCanUpload: raw.settings.members_can_upload,
|
||||
ttlSeconds: raw.settings.ttl_seconds,
|
||||
maxDownloads: raw.settings.file_max_downloads,
|
||||
|
||||
@@ -21,6 +21,6 @@ export function readResourceRecord(item: ReceivedItem): WorkspaceEvent | false |
|
||||
|| !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 ?? '未知设备',
|
||||
name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts,
|
||||
gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } };
|
||||
}
|
||||
Reference in new issue
Block a user