import { ChannelSession, toBase64Url } from '@webdrop/core'; import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; import type { ParsedChannelLink } from '@/lib/channelLink'; import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types'; import { apiFetch, readJson } from './http'; import { getSession, sessions } from './sessions'; import { deviceIdentity } from './presentation'; import { makeLink } from './links'; import { toChannelInfo, type ChannelWire } from './wire'; import { stopChannel } from './channelTransport'; import { publishWorkspace } from './presentation'; import { channelMetadata } from './channelEvents'; import { requireAccount, accountPublicKey } from './accountSession'; import { sealChannelGrant } from './channelGrant'; import { createThread } from './channelThreads'; export async function createChannel(options: CreateOptions, captchaToken?: string): Promise { const username = requireAccount(); const endpointSecret = crypto.getRandomValues(new Uint8Array(32)); const { session, request } = await ChannelSession.create({ mode: options.mode, endpointSecret, settings: { name: options.name?.trim() || undefined, seats: options.seats, ttl_seconds: options.ttlSeconds, members_can_upload: options.membersCanUpload, delete_policy: 'own', max_items: 1000, file_max_downloads: options.fileMaxDownloads ?? 10, ui_restrict_copy_download: false, join_policy: options.joinPolicy ?? 'invite', allow_guests: options.allowGuests ?? false, }, }); try { const keyPackage = await sealChannelGrant(session.link(self.location.origin, 'owner'), username, accountPublicKey()); const info = await apiFetch('/channels', { method: 'POST', headers: { ...request.headers, 'Content-Type': 'application/json', ...(captchaToken ? { 'X-WD-Cap': captchaToken } : {}) }, body: JSON.stringify({ ...request.body, key_package: keyPackage, join_password: options.joinPassword || undefined }), }).then((response) => response.json() as Promise); session.verifyChannelInfo(info); const resource: SavedResource = { id: session.id, creatorAccount: info.creator_account, kind: 'channel', mode: session.mode, role: 'owner', name: info.settings.name, link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'), endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at, seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, maxDownloads: info.settings.file_max_downloads, localItems: [], }; const ownerLabel = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name); await apiFetch(`/channels/${session.id}/seats/me`, { method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, body: ownerLabel, }); await saveResource(resource); sessions.set(session.id, session); await createThread(session.id, '常规'); return toChannelInfo(info, resource); } catch (error) { session.dispose(); throw error; } } export async function joinChannel(input: { link: ParsedChannelLink; password?: string }): Promise { const { id, keys } = input.link; const existing = await getResource(id); const endpointSecret = existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32)); const session = await ChannelSession.open(makeLink(self.location.origin, id, keys), { endpointSecret }); try { const info = await readJson(`/channels/${id}`, session.authHeaders('read')); session.verifyChannelInfo(info); const joined = await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }), }).then((response) => response.json() as Promise<{ seats_used: number }>); info.seats_used = joined.seats_used; const resource: SavedResource = { id, kind: 'channel', mode: session.mode, role: session.role, creatorAccount: info.creator_account, name: info.settings.name, link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined, endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at, seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [], }; const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name); await apiFetch(`/channels/${id}/seats/me`, { method: 'PUT', headers: { ...session.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, body: label, }); await saveResource(resource); sessions.set(id, session); return toChannelInfo(info, resource); } catch (error) { session.dispose(); throw error; } } export async function previewChannel(link: ParsedChannelLink): Promise { const session = await ChannelSession.open(makeLink(self.location.origin, link.id, link.keys)); try { const info = await readJson(`/channels/${link.id}`, session.authHeaders('read')); session.verifyChannelInfo(info); return toChannelInfo(info, { id: link.id, kind: 'channel', mode: session.mode, role: session.role, endpointSecret: session.endpointSecret, link: session.link(self.location.origin), createdAt: info.created_at, expiresAt: info.expires_at, seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload }); } finally { session.dispose(); } } export async function channelMembers(id: string): Promise { const { session } = await getSession(id); return readJson(`/channels/${id}/members`, session.authHeaders(session.can('owner') ? 'owner' : 'read')); } export async function listChannels(): Promise { const saved = await listResources('channel'); return saved.map((resource) => ({ id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, seats: resource.seats, maxDownloads: resource.maxDownloads ?? undefined, creatorAccount: resource.creatorAccount, memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt, })); } export async function updateChannelSettings(resourceId: string, patch: ChannelSettingsPatch): Promise { const { session, resource } = await getSession(resourceId); if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置'); const body: Record = {}; if (patch.name !== undefined) body.name = patch.name.trim(); if (patch.seats !== undefined) body.seats = patch.seats; if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload; if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds; if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads; if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy; if (patch.allowGuests !== undefined) body.allow_guests = patch.allowGuests; if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword; const info = await apiFetch(`/channels/${resourceId}/settings`, { method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then((response) => response.json() as Promise); session.verifyChannelInfo(info); resource.name = info.settings.name; resource.seats = info.settings.seats; resource.membersCanUpload = info.settings.members_can_upload; resource.maxDownloads = info.settings.file_max_downloads; resource.expiresAt = info.expires_at; await saveResource(resource); publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, resource) }); } export async function renameDevice(resourceId: string, endpointHash: string, name: string): Promise { const { session, resource } = await getSession(resourceId); const ownEndpointHash = toBase64Url(session.endpointHash); const isSelf = endpointHash === ownEndpointHash; if (!isSelf && !session.can('owner')) throw new Error('只有所有者可以重命名其他设备'); const normalized = name.trim(); if (!normalized) throw new Error('设备名称不能为空'); const label = await session.sealLabel(normalized, { endpointHash }); const path = isSelf ? `/channels/${resourceId}/seats/me` : `/channels/${resourceId}/seats/${endpointHash}/label`; const role = !isSelf || session.can('owner') ? 'owner' : 'read'; await apiFetch(path, { method: 'PUT', headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream' }, body: label, }); if (isSelf) { resource.deviceLabel = normalized; await saveResource(resource); } await channelMetadata(resource, session); } export async function releaseDevice(resourceId: string, endpointHash: string): Promise { const { session, resource } = await getSession(resourceId); if (!session.can('owner')) throw new Error('只有所有者可以移除设备'); if (endpointHash === toBase64Url(session.endpointHash)) throw new Error('不能移除当前设备'); await apiFetch(`/channels/${resourceId}/seats/${endpointHash}`, { method: 'DELETE', headers: session.authHeaders('owner') }); await channelMetadata(resource, session); } export async function forgetChannel(id: string): Promise { stopChannel(id); sessions.get(id)?.dispose(); sessions.delete(id); await removeResource(id); }