190 lines
9.7 KiB
TypeScript
190 lines
9.7 KiB
TypeScript
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<ChannelInfo> {
|
|
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<ChannelWire>);
|
|
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<ChannelInfo> {
|
|
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<ChannelWire>(`/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<ChannelInfo> {
|
|
const session = await ChannelSession.open(makeLink(self.location.origin, link.id, link.keys));
|
|
try {
|
|
const info = await readJson<ChannelWire>(`/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<unknown> {
|
|
const { session } = await getSession(id);
|
|
return readJson(`/channels/${id}/members`, session.authHeaders(session.can('owner') ? 'owner' : 'read'));
|
|
}
|
|
|
|
export async function listChannels(): Promise<ChannelInfo[]> {
|
|
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<void> {
|
|
const { session, resource } = await getSession(resourceId);
|
|
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
|
|
const body: Record<string, unknown> = {};
|
|
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<ChannelWire>);
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
stopChannel(id);
|
|
sessions.get(id)?.dispose();
|
|
sessions.delete(id);
|
|
await removeResource(id);
|
|
}
|
|
|