Files
WebDrop/web/src/core-bridge/worker/channelGrant.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

47 lines
2.6 KiB
TypeScript

import { parseChannelLink, type ParsedChannelLink } from '@/lib/channelLink';
import { makeLink } from './links';
import { decryptInvitation, encryptInvitation } from './accountCrypto';
interface ChannelGrant {
v: 1;
channelId: string;
mode: 'enc' | 'raw';
role: 'owner' | 'member';
keys: ParsedChannelLink['keys'];
/** 仅供被邀请方在收件箱显示,在加密 payload 内,服务端不可见 */
name?: string;
invitedBy?: string;
}
export interface InvitationMeta { name?: string; invitedBy?: string }
export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey, meta: InvitationMeta = {}): Promise<string> {
const parsed = parseChannelLink(link);
if (!parsed.ok) throw new Error('空间凭据格式无效');
const grant: ChannelGrant = { v: 1, channelId: parsed.link.id, mode: parsed.link.mode,
role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys,
...(meta.name ? { name: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
return encryptInvitation(JSON.stringify(grant), recipient, grant.channelId, publicKey);
}
/** 只读取显示用的元信息;解不开或没有这些字段时返回空,调用方回退到空间 ID */
export async function peekChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey): Promise<InvitationMeta> {
try {
const grant = JSON.parse(await decryptInvitation(payload, recipient, channelId, privateKey)) as Partial<ChannelGrant>;
return { ...(typeof grant.name === 'string' ? { name: grant.name } : {}), ...(typeof grant.invitedBy === 'string' ? { invitedBy: grant.invitedBy } : {}) };
} catch { return {}; }
}
export async function openChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey,
role: 'owner' | 'member', origin: string): Promise<ParsedChannelLink> {
const plain = await decryptInvitation(payload, recipient, channelId, privateKey);
let grant: ChannelGrant;
try { grant = JSON.parse(plain) as ChannelGrant; } catch { throw new Error('空间授权包格式无效'); }
if (!grant || grant.v !== 1 || grant.channelId !== channelId || grant.role !== role || !['enc', 'raw'].includes(grant.mode)
|| !grant.keys || typeof grant.keys !== 'object') throw new Error('空间授权包校验失败');
const parsed = parseChannelLink(makeLink(origin, channelId, grant.keys));
if (!parsed.ok || parsed.link.mode !== grant.mode) throw new Error('空间授权包模式不匹配');
if (!parsed.ok || parsed.link.id !== channelId || parsed.link.isOwner !== (role === 'owner')) throw new Error('空间授权包权限不匹配');
return parsed.link;
}