feat: integrate client and server sharing workflows
This commit is contained in:
1 parent
42b93ae2d8
commit
7406e2fe37
101 files changed
+3153
-253
No files matched your search
@@ -0,0 +1,122 @@
|
||||
import { ChannelSession, IntegrityError } from '@webdrop/core';
|
||||
import { getDraftFile, saveResource, type SavedResource } from '../database';
|
||||
import type { FileItem, TextItem } from '../types';
|
||||
import { makeFileItem } from './presentation';
|
||||
import { active, blobs, getSession } from './sessions';
|
||||
import { fail, readJson } from './http';
|
||||
import { toShareInfo, type ShareWire } from './wire';
|
||||
|
||||
import { flushOutbox, downloadDecrypted } from './transfer';
|
||||
import { publishShare } from './presentation';
|
||||
import { fetchManifest, type ManifestFile } from './shareManifest';
|
||||
import { readShareInfo } from './shareState';
|
||||
import { errorMessage } from '../errors';
|
||||
|
||||
export async function connectShare(id: string): Promise<void> {
|
||||
active.get(id)?.abort();
|
||||
const controller = new AbortController();
|
||||
active.set(id, controller);
|
||||
try {
|
||||
const opened = await getSession(id);
|
||||
let { session } = opened;
|
||||
const { resource } = opened;
|
||||
if (controller.signal.aborted) return;
|
||||
if (resource.kind !== 'share') throw new Error('资源类型不匹配');
|
||||
const raw = await readShareInfo(session, resource);
|
||||
session = (await getSession(id)).session;
|
||||
if (controller.signal.aborted) return;
|
||||
const share = toShareInfo(raw, resource);
|
||||
resource.state = raw.state;
|
||||
resource.expiresAt = raw.expires_at;
|
||||
await saveResource(resource);
|
||||
publishShare(id, { t: 'share', share });
|
||||
|
||||
if (raw.state === 'draft' && resource.mine) {
|
||||
const resumed = await flushOutbox(resource, session);
|
||||
if (resumed) resource.draftItems = resource.draftItems?.map((item) => item.state.kind === 'incomplete' ? { ...item, state: { kind: 'complete' } } : item);
|
||||
await saveResource(resource);
|
||||
if (controller.signal.aborted) return;
|
||||
for (const item of resource.draftItems ?? []) publishShare(id, { t: 'item', item });
|
||||
publishShare(id, { t: 'ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
await loadSealedManifest(resource, session, raw, controller.signal);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
publishShare(id, error instanceof IntegrityError
|
||||
? { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' }
|
||||
: { t: 'error', message: errorMessage(error) });
|
||||
publishShare(id, { t: 'ready' });
|
||||
} finally {
|
||||
if (active.get(id) === controller) active.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopShare(id: string): void {
|
||||
active.get(id)?.abort();
|
||||
active.delete(id);
|
||||
}
|
||||
|
||||
async function loadSealedManifest(resource: SavedResource, session: ChannelSession, raw: ShareWire, signal: AbortSignal): Promise<void> {
|
||||
try {
|
||||
const manifest = await fetchManifest(session, resource.id, raw, signal);
|
||||
const byId = new Map(raw.items.map((item) => [item.item_id, item]));
|
||||
for (let index = 0; index < manifest.items.length; index++) {
|
||||
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 };
|
||||
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);
|
||||
const item = makeFileItem(entry.id, entry.name ?? '加密文件', entry.mime ?? 'application/octet-stream', entry.size,
|
||||
entry.ts, { kind: 'complete' }, detail?.downloads_left ?? (detail ? null : 0));
|
||||
item.gated = true;
|
||||
publishShare(resource.id, { t: 'item', item });
|
||||
} else throw new Error('invalid_manifest_item');
|
||||
}
|
||||
} catch {
|
||||
publishShare(resource.id, { t: 'integrity', message: '分享清单格式不正确,内容已拒绝显示。' });
|
||||
}
|
||||
publishShare(resource.id, { t: 'ready' });
|
||||
}
|
||||
|
||||
export async function getShareBlob(id: string, itemId: string): Promise<Blob> {
|
||||
const cached = blobs.get(`${id}:${itemId}`);
|
||||
if (cached) return cached;
|
||||
const { session, resource } = await getSession(id);
|
||||
if (resource.kind !== 'share') throw new Error('资源类型不匹配');
|
||||
if (resource.state === 'draft' && resource.mine) {
|
||||
const draftFile = await getDraftFile(id, itemId);
|
||||
if (draftFile) {
|
||||
blobs.set(`${id}:${itemId}`, draftFile);
|
||||
return draftFile;
|
||||
}
|
||||
}
|
||||
const raw = await readJson<ShareWire>(`/shares/${id}`, session.authHeaders('read'));
|
||||
const manifest = await fetchManifest(session, id, raw);
|
||||
if (itemId.startsWith('manifest-text-')) {
|
||||
const entry = manifest.items[Number(itemId.slice(14))];
|
||||
if (entry?.t !== 'text') throw fail(404, 'not_found');
|
||||
return new Blob([entry.text], { type: 'text/plain' });
|
||||
}
|
||||
const entry = manifest.items.find((candidate): candidate is ManifestFile => candidate.t === 'file' && candidate.id === itemId);
|
||||
if (!entry) throw fail(404, 'not_found');
|
||||
let result: Awaited<ReturnType<typeof downloadDecrypted>>;
|
||||
try { result = await downloadDecrypted(session, resource, itemId, entry.size); }
|
||||
catch (error) {
|
||||
if (error instanceof IntegrityError) publishShare(id, { t: 'integrity', message: '文件校验失败,内容已丢弃。' });
|
||||
throw error;
|
||||
}
|
||||
blobs.set(`${id}:${itemId}`, result.blob);
|
||||
const fileInfo = raw.items.find((value) => value.item_id === itemId);
|
||||
const remaining = fileInfo?.downloads_left === null || fileInfo?.downloads_left === undefined
|
||||
? null
|
||||
: Math.max(0, fileInfo.downloads_left - 1);
|
||||
const item: FileItem = makeFileItem(itemId, entry.name ?? '加密文件', entry.mime ?? 'application/octet-stream', entry.size, entry.ts,
|
||||
{ kind: 'complete' }, remaining);
|
||||
publishShare(id, { t: 'item', item });
|
||||
return result.blob;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user