feat(web): add spaces, grouped channels and file chat

This commit is contained in:
justme committed 2026-10-13 01:39:23 +08:00
1 parent c7d8ef15bc
commit f6cfb12716
111 files changed
+4371 -506

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../database';
import { apiFetch } from './http';
import { clearResourceSessions, getSession } from './sessions';
import { setAccountIdentity, requireAccount } from './accountSession';
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys } from './accountCrypto';
import { mergeVault, validateVault, validatePersonal, type VaultProfile, type PersonalProfile } from './accountVault';
import { joinChannel } from './channelResources';
import { openChannelGrant, sealChannelGrant } from './channelGrant';
import { recoverAccountChannels } from './accountChannels';
import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName } from './accountPersistence';
interface VaultBody { revision: number; vault: string }
interface AccountSession {
username: string; token: string; key: CryptoKey; profile: VaultProfile; revision: number;
controller: AbortController; dirty: boolean; syncing?: Promise<void>; error?: string;
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
}
export interface Invitation { channel_id: string; username: string; payload: string; created_at: number }
let account: AccountSession | undefined;
let restoration: Promise<void> | undefined;
function usernameOf(input: string): string {
const username = input.trim().toLowerCase();
if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new Error('用户名需为 3–32 位字母、数字、下划线或短横线');
return username;
}
function headers(session: Pick<AccountSession, 'token'>): Record<string, string> { return { 'X-WD-Account': session.token }; }
function stillActive(session: AccountSession): void {
if (account !== session || session.controller.signal.aborted) throw new Error('账号已切换,操作已停止');
}
async function applyProfile(profile: VaultProfile): Promise<void> {
await importResources(profile.resources.map((row) => ({ ...row, endpointSecret: new Uint8Array(32) } as SavedResource)));
await importResourceDeletes(profile.deletes);
}
async function syncVault(): Promise<void> {
if (!account) throw new Error('请先登录账号');
const session = account;
if (session.syncing) { session.dirty = true; return session.syncing; }
session.syncing = (async () => {
await recoverAccountChannels(session.username, session.profile.keys, session.controller.signal);
do {
session.dirty = false;
for (let attempt = 0; attempt < 3; attempt++) {
const remote = await apiFetch('/accounts/vault', { headers: headers(session), signal: session.controller.signal }).then((r) => r.json() as Promise<VaultBody>);
const profile = validateVault(await openJson(remote.vault, session.key));
if (profile.keys.publicKey.x !== session.profile.keys.publicKey.x || profile.keys.publicKey.y !== session.profile.keys.publicKey.y) throw new Error('账号邀请密钥发生冲突,已停止同步');
const merged = mergeVault(profile, await listAllResources(), await listResourceDeletes(), session.profile.personal);
if (JSON.stringify(merged) === JSON.stringify(profile)) {
stillActive(session); await applyProfile(merged);
session.profile = merged; session.revision = remote.revision; session.error = undefined;
setAccountIdentity(session, merged.keys.publicKey, merged.personal);
await rememberAccount({ username: session.username, token: session.token, key: session.key, ...remote });
break;
}
const vault = await sealJson(merged, session.key);
stillActive(session);
try {
const result = await apiFetch('/accounts/vault', { method: 'PUT', headers: { ...headers(session), 'Content-Type': 'application/json' },
signal: session.controller.signal, body: JSON.stringify({ revision: remote.revision, vault }) }).then((r) => r.json() as Promise<VaultBody>);
stillActive(session);
await applyProfile(merged);
session.profile = merged; session.revision = result.revision; session.error = undefined;
setAccountIdentity(session, merged.keys.publicKey, merged.personal);
await rememberAccount({ username: session.username, token: session.token, key: session.key, vault, revision: result.revision });
break;
} catch (error) {
if (attempt === 2 || !(error instanceof Error && 'status' in error && error.status === 409)) throw error;
}
}
} while (session.dirty && account === session);
})().catch((error: unknown) => {
if (account === session) session.error = error instanceof Error ? error.message : '同步失败';
if (account === session && error instanceof Error && 'status' in error && error.status === 401) {
account = undefined; clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort();
setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null);
void forgetAccount();
}
throw error;
}).finally(() => { session.syncing = undefined; });
return session.syncing;
}
async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody): Promise<AccountSession> {
const profile = validateVault(await openJson(remote.vault, key));
clearResourceSessions(); setResourceScope(identity.username);
const session: AccountSession = { ...identity, key, profile, revision: remote.revision, controller: new AbortController(), dirty: false };
account = session; setAccountIdentity(identity, profile.keys.publicKey, profile.personal);
await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes()));
setResourceChangeListener(scheduleSync);
session.polling = setInterval(scheduleSync, 20_000);
return session;
}
export function restoreAccount(): Promise<void> {
restoration ??= (async () => {
const saved = await rememberedAccount();
if (!saved || account) return;
let remote: VaultBody;
try {
remote = await apiFetch('/accounts/vault', { headers: headers(saved) }).then((r) => r.json() as Promise<VaultBody>);
} catch (error) {
if (error instanceof Error && 'status' in error && error.status === 401) { await forgetAccount(); return; }
remote = saved;
}
try {
const session = await activate(saved, saved.key, remote);
const local = validateVault(await openJson(saved.vault, saved.key));
if (local.personal && local.personal.modifiedAt > (session.profile.personal?.modifiedAt ?? -1)) session.profile = { ...session.profile, personal: local.personal };
setAccountIdentity(session, session.profile.keys.publicKey, session.profile.personal);
await syncVault().catch(() => {});
} catch (error) { await logoutAccount(); throw error; }
})().catch((error: unknown) => { restoration = undefined; throw error; });
return restoration;
}
function scheduleSync(): void {
if (!account) return;
account.dirty = true; clearTimeout(account.timer);
account.timer = setTimeout(() => { void syncVault().catch(() => {}); }, 700);
}
export async function registerAccount(input: string, password: string): Promise<Awaited<ReturnType<typeof accountStatus>>> {
if (account) throw new Error('请先退出当前账号');
const username = usernameOf(input);
if (password.length < 12 || password.length > 1024) throw new Error('密码长度需为 12–1024 个字符');
const salt = crypto.getRandomValues(new Uint8Array(16));
const { verifier, key } = await deriveAccountKeys(password, salt);
const profile: VaultProfile = { v: 2, keys: await generateInvitationKeys(), resources: [], deletes: [] };
try {
await apiFetch('/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey), vault: await sealJson(profile, key) }) });
} finally { verifier.fill(0); }
return loginAccount(username, password);
}
export async function loginAccount(input: string, password: string): Promise<Awaited<ReturnType<typeof accountStatus>>> {
if (account) throw new Error('请先退出当前账号');
const username = usernameOf(input);
const challenge = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) })
.then((r) => r.json() as Promise<{ salt: string }>);
const { verifier, key } = await deriveAccountKeys(password, unb64(challenge.salt));
let identity: { username: string; token: string };
try {
const device = await localAccountDevice();
identity = await apiFetch('/accounts/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, verifier: b64(verifier), device_id: device.id, device_name: device.name }) })
.then((r) => r.json() as Promise<{ username: string; token: string }>);
} finally { verifier.fill(0); }
try {
const remote = await apiFetch('/accounts/vault', { headers: headers(identity) }).then((r) => r.json() as Promise<VaultBody>);
const profile = validateVault(await openJson(remote.vault, key));
const publicKey = await apiFetch(`/accounts/users/${username}/key`, { headers: headers(identity) }).then((r) => r.json() as Promise<{ public_key: string }>);
const published = JSON.parse(publicKey.public_key) as JsonWebKey;
if (published.x !== profile.keys.publicKey.x || published.y !== profile.keys.publicKey.y) throw new Error('账号邀请公钥与保险库不匹配');
await activate(identity, key, remote);
await rememberAccount({ ...identity, key, ...remote });
await syncVault().catch(() => {});
return accountStatus();
} catch (error) {
await logoutAccount();
void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(identity) }).catch(() => {});
throw error;
}
}
export async function accountStatus(): Promise<{ username: string | null; revision: number; personal?: PersonalProfile; error?: string }> {
return { username: account?.username ?? null, revision: account?.revision ?? 0,
...(account?.profile.personal ? { personal: account.profile.personal } : {}),
...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) };
}
export async function logoutAccount(): Promise<void> {
const session = account; account = undefined;
if (session) { clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); await session.syncing?.catch(() => {}); }
setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null);
await forgetAccount();
if (session) void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(session) }).catch(() => {});
}
export async function syncAccount(): Promise<void> { await syncVault(); }
export async function updatePersonal(displayName: string, avatar?: string): Promise<void> {
if (!account) throw new Error('请先登录账号');
account.profile = { ...account.profile, personal: validatePersonal({ displayName, ...(avatar ? { avatar } : {}), modifiedAt: Date.now() }) };
setAccountIdentity(account, account.profile.keys.publicKey, account.profile.personal);
// Retain an offline profile edit for the next session restoration.
await rememberAccount({ username: account.username, token: account.token, key: account.key, vault: await sealJson(account.profile, account.key), revision: account.revision });
await syncVault();
}
export async function listAccountDevices(): Promise<unknown> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); }
export async function renameAccountDevice(id: string, name: string): Promise<void> {
requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
if ((await localAccountDevice()).id === id) await saveDeviceName(name.trim());
}
export async function revokeAccountDevice(id: string): Promise<void> {
requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'DELETE' });
if ((await localAccountDevice()).id === id) await logoutAccount();
}
export async function listAccountInvitations(): Promise<Invitation[]> {
requireAccount(); return apiFetch('/accounts/invitations').then((r) => r.json() as Promise<Invitation[]>);
}
export async function acceptInvitation(channelId: string): Promise<void> {
if (!account) throw new Error('请先登录账号');
const session = account;
const invitation = (await listAccountInvitations()).find((row) => row.channel_id === channelId);
if (!invitation) throw new Error('邀请已失效');
const link = await openChannelGrant(invitation.payload, session.username, channelId, session.profile.keys.privateKey, 'member', self.location.origin);
stillActive(session); await joinChannel({ link }); await syncVault();
}
export async function inviteChannelUser(channelId: string, input: string): Promise<void> {
requireAccount(); const username = usernameOf(input); const { session, resource } = await getSession(channelId);
if (!session.can('owner')) throw new Error('只有空间主可以邀请用户');
const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<{ public_key: string }>);
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey);
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload }) });
}
export async function listChannelInvitations(channelId: string): Promise<Invitation[]> {
const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise<Invitation[]>);
}
export async function revokeChannelInvitation(channelId: string, input: string): Promise<void> {
const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders('owner') });
}