feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

- 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:
justme committed 2026-10-14 22:19:25 +08:00
1 parent 5fd4ddd411
commit 4b8d0b5a5f
83 files changed
+1291 -661

No files matched your search

+1 -1
View File
@@ -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 }),
+3 -5
View File
@@ -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>;
+4 -3
View File
@@ -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 -1
View File
@@ -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-]+)*)?$/;
+2 -4
View File
@@ -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 -1
View File
@@ -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: '请求过于频繁,请稍后重试。',
+1 -1
View File
@@ -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 };
+2 -4
View File
@@ -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 }),
+1 -1
View File
@@ -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 };
}
+9 -20
View File
@@ -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 }
+3 -5
View File
@@ -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 ?? ''));
+10 -4
View File
@@ -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', () => {
+1 -1
View File
@@ -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 {
+6 -24
View File
@@ -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> {
+16 -2
View File
@@ -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);
+2 -2
View File
@@ -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,
+11 -51
View File
@@ -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();
+13 -16
View File
@@ -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);
}
+1 -1
View File
@@ -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' }); },
};
+1 -1
View File
@@ -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 };
}
+2 -2
View File
@@ -8,7 +8,7 @@ describe('API contract preflight', () => {
});
it('blocks API requests when the server has a newer breaking major', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '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);
+7 -13
View File
@@ -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 };
}
+1 -1
View File
@@ -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 },
};
}
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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,
};
+2 -2
View File
@@ -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' } } };
}