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

+25 -14
View File
@@ -1,9 +1,10 @@
import { removeChannelItem } from './channelItems';
import { queueFileCatalog, queueItemAuthor } from './channelThreads';
import { queueFileCatalog } from './channelThreads';
import { senderSnapshot } from './senderSnapshot';
import { prepareObject } from './channelDataKeys';
import { errorMessage } from '../errors';
import { saveResource } from '../database';
import { saveResource, queueLocation } from '../database';
import { previewKindOf } from '@/lib/fileKind';
import type { FileItem, FileUploadOptions, Item } from '../types';
@@ -16,31 +17,35 @@ import { deviceIdentity } from './presentation';
import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer';
import { publishWorkspace } from './presentation';
export async function sendText(resourceId: string, text: string, threadId?: string): Promise<{ itemId: string; uploaded: boolean }> {
export async function sendText(resourceId: string, text: string, threadId?: string,
event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }): Promise<{ itemId: string; uploaded: boolean }> {
if (!threadId) throw new Error('请先选择频道');
const { session, resource } = await getSession(resourceId);
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
const identity = await deviceIdentity(session.endpointSecret);
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
session.deviceName = device.name;
const ts = Date.now();
const author = await senderSnapshot();
const plan = session.encryptText(text, { dev: device.name, ts, threadId });
const prepared = await prepareObject(resource, session, threadId, 'chat');
const plan = session.encryptText(text, { dev: device.name, ts, threadId, itemId: prepared.itemIdBytes });
const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}) };
blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' }));
publishWorkspace(resourceId, { t: 'item', item });
await persistPlan(resource, plan);
if (author) await queueItemAuthor(resource, session, plan.itemId, author, threadId, ts);
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch,
eventKind: event?.kind ?? 'chat.create', ...(event?.targetItemId ? { targetItemId: event.targetItemId } : {}) });
await queueLocation(resourceId, item.id, threadId);
const uploaded = await flushOutbox(resource, session);
publishWorkspace(resourceId, { t: 'item', item: { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } } });
if (!uploaded) publishWorkspace(resourceId, { t: 'connection', state: 'offline' });
return { itemId: plan.itemId, uploaded };
}
export async function sendFiles(resourceId: string, files: File[], options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId?: string, folderId?: string): Promise<void> {
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void> {
if (!threadId) throw new Error('请先选择频道');
const { session, resource } = await getSession(resourceId);
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
const options = suppliedOptions ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 };
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
const identity = await deviceIdentity(session.endpointSecret);
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
session.deviceName = device.name;
@@ -50,7 +55,9 @@ export async function sendFiles(resourceId: string, files: File[], options: File
const ts = Date.now();
let item: FileItem | undefined;
try {
const prepared = await prepareObject(resource, session, threadId, 'files');
const plan = session.encryptFile(file, {}, {
itemId: prepared.itemIdBytes,
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
dev: device.name, ts, maxFrameData, threadId, folderId,
});
@@ -60,7 +67,7 @@ export async function sendFiles(resourceId: string, files: File[], options: File
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
remaining: plan.gated
? (options.directPreview ? resource.maxDownloads ?? 10 : options.maxDownloads) || null
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
: null,
preview: previewKindOf(file.type, file.name), gated: plan.gated,
};
@@ -84,9 +91,10 @@ export async function sendFiles(resourceId: string, files: File[], options: File
const planned = await persistPlan(resource, plan, (_frames) => {
const processed = Math.min(file.size, _frames * MAX_FRAME_DATA);
updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting');
});
}, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.publish' });
const uploadedFrames = new Set<number>();
await queueFileCatalog(resource, session, item);
await queueLocation(resourceId, item.id, threadId, folderId);
let uploadedBytes = 0;
const uploadStartedAt = performance.now();
const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => {
@@ -120,13 +128,16 @@ export async function sendFiles(resourceId: string, files: File[], options: File
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
const { session, resource } = await getSession(resourceId);
const name = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name });
await persistPlan(resource, plan);
const { structure } = await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId));
if (!structure.defaultChannelId) throw new Error('请先创建默认频道');
const prepared = await prepareObject(resource, session, structure.defaultChannelId, 'chat');
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name, itemId: prepared.itemIdBytes });
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'chat.snapshot' });
await flushOutbox(resource, session);
}
export async function editChannelText(resourceId: string, itemId: string, text: string, threadId?: string): Promise<void> {
const created = await sendText(resourceId, text, threadId);
const created = await sendText(resourceId, text, threadId, { kind: 'chat.edit', targetItemId: itemId });
if (!created.uploaded) throw new Error('新版本已排队,网络恢复前保留旧内容。');
await removeChannelItem(resourceId, itemId);
}