feat(web): add spaces, grouped channels and file chat
This commit is contained in:
1 parent
c7d8ef15bc
commit
f6cfb12716
111 files changed
+4371
-506
No files matched your search
@@ -0,0 +1,172 @@
|
||||
import type { ChannelSession, ReceivedItem } from '@webdrop/core';
|
||||
import type { SavedResource } from '../database';
|
||||
import type { FileItem, FolderInfo, SenderInfo, ThreadInfo, WorkspaceEvent } from '../types';
|
||||
import { readSender } from '@/lib/sender';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { getSession } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { flushOutbox, persistPlan } from './transfer';
|
||||
|
||||
const CONTROL_APP = 'webdrop.workspace';
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
type Control =
|
||||
| { op: 'item.author'; itemId: string; author: SenderInfo }
|
||||
| { op: 'file.published'; item: FileItem }
|
||||
| { op: 'thread.create'; id: string; name: string; groupId?: string }
|
||||
| { op: 'group.create'; id: string; name: string }
|
||||
| { op: 'group.rename'; id: string; name: string }
|
||||
| { op: 'thread.group'; id: string; groupId: string | null }
|
||||
| { op: 'thread.rename'; id: string; name: string }
|
||||
| { op: 'folder.create'; id: string; threadId: string; parentId?: string; name: string }
|
||||
| { op: 'folder.rename'; id: string; name: string }
|
||||
| { op: 'items.move'; itemIds: string[]; threadId: string; folderId: string | null };
|
||||
|
||||
function cleanName(input: string): string {
|
||||
const name = input.trim();
|
||||
if (!name || name.length > 80 || /[\u0000-\u001f]/.test(name)) throw new Error('名称需为 1–80 个字符,不能包含控制字符');
|
||||
return name;
|
||||
}
|
||||
|
||||
async function sendControl(resourceId: string, control: Control, threadId?: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
|
||||
if (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload)) throw new Error('此链接没有上传权限');
|
||||
const device = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
|
||||
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, ...control })), {
|
||||
kind: 'other', threadId, dev: device,
|
||||
}, { ts: Date.now(), dev: device, control: true });
|
||||
await persistPlan(resource, plan);
|
||||
const event = controlEvent(control, Date.now(), device);
|
||||
if (event) publishWorkspace(resourceId, event);
|
||||
await flushOutbox(resource, session);
|
||||
}
|
||||
|
||||
export async function createThread(resourceId: string, input: string, groupId?: string): Promise<string> {
|
||||
if (groupId !== undefined && !isId(groupId)) throw new Error('频道分组格式无效');
|
||||
const id = crypto.randomUUID();
|
||||
await sendControl(resourceId, { op: 'thread.create', id, name: cleanName(input), ...(groupId ? { groupId } : {}) });
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function createGroup(resourceId: string, input: string): Promise<string> {
|
||||
const id = crypto.randomUUID(); await sendControl(resourceId, { op: 'group.create', id, name: cleanName(input) }); return id;
|
||||
}
|
||||
export async function renameGroup(resourceId: string, id: string, input: string): Promise<void> {
|
||||
if (!isId(id)) throw new Error('频道分组格式无效');
|
||||
await sendControl(resourceId, { op: 'group.rename', id, name: cleanName(input) });
|
||||
}
|
||||
export async function groupThread(resourceId: string, id: string, groupId?: string): Promise<void> {
|
||||
if (!isId(id) || groupId !== undefined && !isId(groupId)) throw new Error('频道分组格式无效');
|
||||
await sendControl(resourceId, { op: 'thread.group', id, groupId: groupId ?? null }, id);
|
||||
}
|
||||
|
||||
export async function renameThread(resourceId: string, id: string, input: string): Promise<void> {
|
||||
await sendControl(resourceId, { op: 'thread.rename', id, name: cleanName(input) }, id);
|
||||
}
|
||||
|
||||
export async function createFolder(resourceId: string, threadId: string, input: string, parentId?: string): Promise<void> {
|
||||
await sendControl(resourceId, { op: 'folder.create', id: crypto.randomUUID(), threadId, ...(parentId ? { parentId } : {}), name: cleanName(input) }, threadId);
|
||||
}
|
||||
|
||||
export async function renameFolder(resourceId: string, id: string, input: string, threadId: string): Promise<void> {
|
||||
await sendControl(resourceId, { op: 'folder.rename', id, name: cleanName(input) }, threadId);
|
||||
}
|
||||
|
||||
export async function moveItems(resourceId: string, itemIds: string[], threadId: string, folderId?: string): Promise<void> {
|
||||
if (itemIds.length < 1 || itemIds.length > 100) throw new Error('一次最多移动 100 个文件');
|
||||
await sendControl(resourceId, { op: 'items.move', itemIds, threadId, folderId: folderId ?? null }, threadId);
|
||||
}
|
||||
|
||||
export async function queueFileCatalog(resource: SavedResource, session: ChannelSession, item: FileItem): Promise<void> {
|
||||
const control: Control = { op: 'file.published', item: { ...item, state: { kind: 'complete' } } };
|
||||
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, ...control })),
|
||||
{ kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true });
|
||||
await persistPlan(resource, plan);
|
||||
}
|
||||
|
||||
export async function queueItemAuthor(resource: SavedResource, session: ChannelSession, itemId: string, author: SenderInfo, threadId: string, ts: number): Promise<void> {
|
||||
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, op: 'item.author', itemId, author })),
|
||||
{ kind: 'other', threadId }, { ts, control: true });
|
||||
await persistPlan(resource, plan);
|
||||
}
|
||||
|
||||
function isId(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value);
|
||||
}
|
||||
function isItemId(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value); }
|
||||
function validName(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= 80 && !/[\u0000-\u001f]/.test(value);
|
||||
}
|
||||
|
||||
function controlEvent(control: Control, ts: number, author: string): WorkspaceEvent | null {
|
||||
switch (control.op) {
|
||||
case 'item.author': return { t: 'item-author', id: control.itemId, author: control.author };
|
||||
case 'file.published': return { t: 'file-catalog', item: control.item };
|
||||
case 'thread.create': return { t: 'thread-created', thread: { id: control.id, name: control.name, createdAt: ts, createdBy: author, ...(control.groupId ? { groupId: control.groupId } : {}) } };
|
||||
case 'group.create': return { t: 'group-created', group: { id: control.id, name: control.name, createdAt: ts } };
|
||||
case 'group.rename': return { t: 'group-renamed', id: control.id, name: control.name };
|
||||
case 'thread.group': return { t: 'thread-grouped', id: control.id, ...(control.groupId ? { groupId: control.groupId } : {}) };
|
||||
case 'thread.rename': return { t: 'thread-renamed', id: control.id, name: control.name };
|
||||
case 'folder.create': return { t: 'folder-created', folder: { id: control.id, threadId: control.threadId, ...(control.parentId ? { parentId: control.parentId } : {}), name: control.name } };
|
||||
case 'folder.rename': return { t: 'folder-renamed', id: control.id, name: control.name };
|
||||
case 'items.move': return { t: 'items-moved', itemIds: control.itemIds, threadId: control.threadId, ...(control.folderId ? { folderId: control.folderId } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
export function applyControl(item: ReceivedItem): WorkspaceEvent | false {
|
||||
if (item.meta?.kind !== 'other') return false;
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(new TextDecoder().decode(item.content)); } catch { return false; }
|
||||
if (!value || typeof value !== 'object' || (value as { app?: unknown }).app !== CONTROL_APP) return false;
|
||||
const row = value as Record<string, unknown>;
|
||||
if (row.v !== 1 || typeof row.op !== 'string') return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
const author = item.meta.dev ?? '未知设备';
|
||||
switch (row.op) {
|
||||
case 'item.author': {
|
||||
const sender = readSender(row.author);
|
||||
return isItemId(row.itemId) && sender ? { t: 'item-author', id: row.itemId, author: sender } : { t: 'error', message: '发送者资料格式无效,已拒绝应用。' };
|
||||
}
|
||||
case 'file.published': {
|
||||
const file = row.item as FileItem | undefined;
|
||||
const sender = readSender(file?.author);
|
||||
if (!file || !isItemId(file.id) || !isId(file.threadId) || (file.folderId !== undefined && !isId(file.folderId))
|
||||
|| file.kind !== 'file' || typeof file.name !== 'string' || file.name.length > 1024 || typeof file.mime !== 'string' || file.mime.length > 256
|
||||
|| !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean' || file.author !== undefined && !sender) return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' };
|
||||
return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, ...(file.folderId ? { folderId: file.folderId } : {}),
|
||||
name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, deviceId: item.meta.dev ?? '未知设备',
|
||||
...(sender ? { author: sender } : {}),
|
||||
gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } };
|
||||
}
|
||||
case 'thread.create':
|
||||
return isId(row.id) && validName(row.name) && (row.groupId === undefined || isId(row.groupId))
|
||||
? { t: 'thread-created', thread: { id: row.id, name: row.name, createdAt: item.meta.ts, createdBy: author,
|
||||
...(row.groupId ? { groupId: row.groupId as string } : {}) } satisfies ThreadInfo } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
case 'group.create':
|
||||
return isId(row.id) && validName(row.name) ? { t: 'group-created', group: { id: row.id, name: row.name, createdAt: item.meta.ts } }
|
||||
: { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' };
|
||||
case 'group.rename':
|
||||
return isId(row.id) && validName(row.name) ? { t: 'group-renamed', id: row.id, name: row.name }
|
||||
: { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' };
|
||||
case 'thread.group':
|
||||
return isId(row.id) && (row.groupId === null || isId(row.groupId)) ? { t: 'thread-grouped', id: row.id, ...(row.groupId ? { groupId: row.groupId as string } : {}) }
|
||||
: { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' };
|
||||
case 'thread.rename':
|
||||
return isId(row.id) && typeof row.name === 'string' && row.name.length > 0 && row.name.length <= 80
|
||||
? { t: 'thread-renamed', id: row.id, name: row.name } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
case 'folder.create':
|
||||
if (!isId(row.id) || !isId(row.threadId) || (row.parentId !== undefined && !isId(row.parentId))
|
||||
|| typeof row.name !== 'string' || row.name.length < 1 || row.name.length > 80) return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
return { t: 'folder-created', folder: { id: row.id, threadId: row.threadId, ...(row.parentId ? { parentId: row.parentId as string } : {}), name: row.name } satisfies FolderInfo };
|
||||
case 'folder.rename':
|
||||
return isId(row.id) && typeof row.name === 'string' && row.name.length > 0 && row.name.length <= 80
|
||||
? { t: 'folder-renamed', id: row.id, name: row.name } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
case 'items.move':
|
||||
if (!Array.isArray(row.itemIds) || row.itemIds.length > 100 || !row.itemIds.every(isItemId) || !isId(row.threadId)
|
||||
|| (row.folderId !== null && !isId(row.folderId))) return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
return { t: 'items-moved', itemIds: row.itemIds, threadId: row.threadId, ...(row.folderId ? { folderId: row.folderId as string } : {}) };
|
||||
default:
|
||||
return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user