feat(web): add account workspaces and signed content flows

This commit is contained in:
justme committed 2026-10-14 04:06:03 +08:00
1 parent 7b8d61d1ba
commit b5cf3d5958
117 files changed
+2789 -1674

No files matched your search

+29 -11
View File
@@ -1,12 +1,16 @@
import { parseLogRecords, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core';
import { getResource, saveResource, type SavedResource } from '../database';
import { parseLogRecords, parseEnvelope, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core';
import { canDecryptRecord, objectEventProjection, readDataKeys, verifyObjectDeletion, verifyObjectEvent } from './channelDataKeys';
import { getResource, cacheResource, type SavedResource } from '../database';
import { channelCache } from './resourceMetadata';
import { loadWorkspace } from './workspaceStructure';
import type { SeatInfo } from '../generated/api';
import type { FileItem, Item, Snapshot } from '../types';
import { previewKindOf } from '@/lib/fileKind';
import { blobs, readers } from './sessions';
import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation';
import { type ChannelWire, toChannelInfo } from './wire';
import { apiFetch, readJson } from './http';
import { applyControl } from './channelThreads';
import { readResourceRecord } from './workspaceRecords';
import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog';
export function mapReceived(item: ReceivedItem): Item {
@@ -30,6 +34,10 @@ export function mapReceived(item: ReceivedItem): Item {
}
export async function applyChannelRecord(resource: SavedResource, session: ChannelSession, input: Uint8Array | LogRecord): Promise<void> {
if (!(input instanceof Uint8Array)) {
const itemId = input.kind === 'frame' ? toBase64Url(parseEnvelope(input.envelope).itemId) : input.itemId;
if (!(await canDecryptRecord(resource, session, itemId))) return;
}
let reader = readers.get(resource.id);
if (!reader) {
reader = session.createItemReader();
@@ -39,7 +47,15 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
for (const event of events) {
switch (event.type) {
case 'item': {
const control = applyControl(event.item);
if (!event.item.complete || !event.item.ciphertextManifestHash
|| !await verifyObjectEvent(resource, session, event.item.itemId, event.item.ciphertextManifestHash)) {
showRejected(resource.id, { type: 'rejected', itemId: event.item.itemId, seq: event.item.seq, reason: 'auth_failed' });
break;
}
const signedEvent = await objectEventProjection(resource, session, event.item.itemId);
if (signedEvent?.kind === 'chat.edit' && signedEvent.targetItemId)
publishWorkspace(resource.id, { t: 'removed', id: signedEvent.targetItemId });
const control = readResourceRecord(event.item);
if (control !== false) {
if (control && control.t === 'file-catalog') {
const key = `${resource.id}:${control.item.id}`;
@@ -84,6 +100,10 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
break;
}
case 'removed':
if (event.reason === 'deleted' && !await verifyObjectDeletion(resource, session, event.itemId)) {
showRejected(resource.id, { type: 'rejected', itemId: event.itemId, seq: event.seq, reason: 'auth_failed' });
break;
}
visibleFiles.delete(`${resource.id}:${event.itemId}`);
fileCatalogs.delete(`${resource.id}:${event.itemId}`);
blobs.delete(`${resource.id}:${event.itemId}`);
@@ -101,19 +121,16 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
}
export async function channelMetadata(resource: SavedResource, session: ChannelSession): Promise<void> {
const info = await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders('read'));
const info = await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'));
session.verifyChannelInfo(info);
resource.name = info.settings.name;
resource.membersCanUpload = info.settings.members_can_upload;
resource.expiresAt = info.expires_at;
const latest = await getResource(resource.id);
await saveResource({ ...latest, ...resource, localItems: latest?.localItems });
Object.assign(resource, await cacheResource(resource, channelCache(info)));
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
await loadWorkspace(resource.id);
const ownId = toBase64Url(session.endpointHash);
const ownName = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
let devices = [deviceInfo(ownId, Date.now(), ownName, ownId)];
if (session.can('owner')) {
const seats = await readJson<Array<{ endpoint_hash: string; joined_at: number; label?: string }>>(
const seats = await readJson<SeatInfo[]>(
`/channels/${resource.id}/seats`, session.authHeaders('owner'));
devices = await Promise.all(seats.map(async (seat) => {
let name: string | undefined;
@@ -128,6 +145,7 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
}
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
if (resource.mode === 'enc') await readDataKeys(resource, session);
publishWorkspace(resource.id, { t: 'reset' });
clearFileCatalog(resource.id);
readers.set(resource.id, session.createItemReader());