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 { 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) { await flushOutbox(resource, session); const current = await readShareInfo(session, resource); resource.draftItems = resource.draftItems?.map((item) => item.kind === 'file' ? { ...item, state: { kind: current.items.some((row) => row.item_id === item.id && row.complete) ? 'complete' : 'incomplete' } } : 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 { 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 { 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 entry = resource.manifestEntries?.find((row) => row.t === 'file' && row.id === itemId); if (!entry || entry.t !== 'file') throw fail(404, 'not_found'); const result = await downloadDecrypted(session, resource, itemId, entry.size); blobs.set(`${id}:${itemId}`, result.blob); return result.blob; } const raw = await readJson(`/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>; 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; }