Files
WebDrop/web/src/core-bridge/worker/shareRead.ts
T
justme 4b8d0b5a5f
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
- 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
2026-10-14 22:19:25 +08:00

130 lines
6.1 KiB
TypeScript

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 = share.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<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, 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 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<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;
}