import { hasCode } from '../runtime/http'; import { ErrorCode } from '@/api/generated'; import { tokenRole } from '../runtime/sessions'; import { fromBase64Url, type ChannelSession } from '@webdrop/core'; import type { FileGrantBody, FileGrantRequest } from '@/api/generated'; import type { SavedResource } from '../runtime/database'; import { apiFetch, apiUpload, externalFetch, externalRequest, getServerInfo } from '../runtime/http'; import type { FileDomainOptions, FileDomainUsage } from '@/domain/types'; import { cachedWorkspace, changeWorkspace } from '../space/workspaceStructure'; import { getSession } from '../runtime/sessions'; /** * 文件域:每个频道有一个,由文件服务(fileAPI)创建、计量、存放字节;主后端只管权限。 * - 创建/查询/上传/下载/删除,前端都直接和文件服务通信,凭的是空间(主后端)按权限签发的短期票据。 * - 主后端只记「这个频道的文件域 id」和每个文件的元数据、摘要。 * 聊天、头像、空间结构、文件目录不受影响,仍走主后端。 */ const toHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); export const itemHex = (itemId: string): string => toHex(fromBase64Url(itemId, 16) ?? new Uint8Array(16)); /** 帧头部里的条目 id(第 2 到 18 字节) */ export const frameItemHex = (bytes: Uint8Array): string => toHex(bytes.subarray(2, 18)); const base = (grant: FileGrantBody): string => grant.url.replace(/\/$/, ''); export async function filesEnabled(): Promise { try { return (await getServerInfo()).files?.enabled === true; } catch { return false; } } /** 文件服务连不上(还没部署好、放文件的机器离线)时给一句看得懂的提示,而不是笼统的「网络连接失败」。被服务端拒绝的错误照常往外抛 */ export const FILE_SERVICE_DOWN = '文件服务暂时不可用,请稍后再试'; async function reachable(call: () => Promise): Promise { try { return await call(); } catch (error) { if (error instanceof Error && !('status' in error)) throw new Error(FILE_SERVICE_DOWN); throw error; } } async function fileServiceUrl(): Promise { const files = (await getServerInfo()).files; if (!files?.enabled || !files.url) throw new Error('服务端没有开启文件服务'); return files.url.replace(/\/$/, ''); } /** 向空间要一张文件服务的票据:权限由空间按成员身份判断 */ export async function requestGrant(resource: SavedResource, session: ChannelSession, request: Omit & { from?: number }): Promise { const response = await apiFetch(`/spaces/${resource.id}/file-grants`, { method: 'POST', headers: { ...session.authHeaders(tokenRole(session)), 'Content-Type': 'application/json' }, body: JSON.stringify({ from: 0, ...request }), }); return response.json() as Promise; } /** 文件服务提供的选项:创建频道时可以选的文件空间档位。不含机密,直接问文件服务 */ export async function fileDomainOptions(): Promise { if (!await filesEnabled()) return undefined; return reachable(async () => (await externalFetch(`${await fileServiceUrl()}/file/v1/options`, {})).json() as Promise); } /** 在文件服务里创建一个文件域(空间所有者/管理员),返回域 id */ export async function createFileDomain(resource: SavedResource, session: ChannelSession, quota?: number): Promise { const grant = await requestGrant(resource, session, { op: 'create' }); const response = await reachable(() => externalRequest(`${base(grant)}/file/v1/domains`, { method: 'POST', headers: { 'X-WD-Ticket': grant.ticket, 'Content-Type': 'application/json' }, body: JSON.stringify(quota ? { quota } : {}) })); return ((await response.json()) as { id: string }).id; } /** * 保证这个频道有文件域:没有就创建一个并记到频道上(创建频道时没带的、旧频道)。 * 只有所有者和管理员能分配;其他人遇到没有文件域的频道只能等管理员分配。 */ export async function ensureThreadFileDomain(resource: SavedResource, session: ChannelSession, threadId: string, size: number): Promise { if (resource.kind !== 'channel') return false; const thread = (await cachedWorkspace(resource.id)).structure.channels.find((channel) => channel.id === threadId); if (!thread) throw new Error('频道不存在'); if (thread.fileDomain) return true; // 文件服务没开启或没部署好:小文件照样存在主后端(和聊天一样),大文件要等文件服务 const hosted = (reason: string): false => { if (size > HOSTED_FILE_MAX_BYTES) throw new Error(`${reason},暂时只能上传不超过 ${HOSTED_FILE_MAX_BYTES / 1024 / 1024} MB 的文件`); return false; }; if (!await filesEnabled()) return hosted('服务端没有开启文件服务'); let domain: string; try { domain = await createFileDomain(resource, session); } catch (error) { if (hasCode(error, ErrorCode.Forbidden)) { if (size <= HOSTED_FILE_MAX_BYTES) return false; throw new Error('这个频道还没有文件空间,请让管理员先为它分配'); } if (error instanceof Error && error.message === FILE_SERVICE_DOWN) return hosted('文件服务暂时不可用'); throw error; } await changeWorkspace(resource.id, `channels/${threadId}/file-domain`, 'PUT', { fileDomain: domain }); return true; } /** 没有文件域时,文件存在主后端能收的上限 */ export const HOSTED_FILE_MAX_BYTES = 256 * 1024 * 1024; /** 频道文件域的容量和已用字节 */ export async function fileDomainUsage(resourceId: string, threadId: string): Promise { const { session, resource } = await getSession(resourceId); if (resource.kind !== 'channel' || !await filesEnabled()) return undefined; const grant = await requestGrant(resource, session, { op: 'domain', channel: threadId }).catch(() => undefined); if (!grant) return undefined; const response = await externalFetch(`${base(grant)}/file/v1/domains/${grant.domain}`, { 'X-WD-Ticket': grant.ticket }); const body = await response.json() as { quota: number; used: number }; return { quota: body.quota, used: body.used }; } const uploadTickets = new Map(); /** 上传票据在有效期内复用(一个条目的所有帧共用),快过期时换新的 */ export async function uploadGrant(resource: SavedResource, session: ChannelSession, item: string, fresh = false): Promise { const key = `${resource.id}:${item}`; const known = uploadTickets.get(key); if (!fresh && known && known.expires_at - Date.now() > 60_000) return known; const grant = await requestGrant(resource, session, { op: 'put', item }); uploadTickets.set(key, grant); return grant; } export const forgetUploadGrant = (resource: SavedResource, item: string): void => { uploadTickets.delete(`${resource.id}:${item}`); }; /** 把一帧整个存进文件域,返回文件服务算的摘要(之后拿它去主后端登记) */ export async function putFrame( grant: FileGrantBody, bytes: Uint8Array, index: number, onProgress: (loaded: number, total: number) => void, signal?: AbortSignal, stallMs?: number, ): Promise { const url = `${base(grant)}/file/v1/domains/${grant.domain}/items/${frameItemHex(bytes)}/frames/${index}`; const text = await apiUpload('', { 'X-WD-Ticket': grant.ticket }, new Blob([bytes as BlobPart]), onProgress, signal, stallMs, { url, method: 'PUT' }); const digest = (JSON.parse(text) as { digest?: string }).digest; if (!digest) throw new Error('文件服务没有返回摘要'); return digest; } /** 整个文件已经登记到主后端:告诉文件服务这个条目不再是待确认,不会被清理。登记成功之后调用 */ export async function commitItem(resource: SavedResource, session: ChannelSession, itemId: string): Promise { const grant = await uploadGrant(resource, session, itemId); await externalRequest(`${base(grant)}/file/v1/domains/${grant.domain}/items/${itemHex(itemId)}/commit`, { method: 'POST', headers: { 'X-WD-Ticket': grant.ticket } }); forgetUploadGrant(resource, itemId); } /** 取消上传:清掉已经传给文件服务的字节(用的是上传这个条目的票据,主后端那边可能还没有这个条目) */ export async function abandonItem(resource: SavedResource, session: ChannelSession, itemId: string): Promise { const grant = await uploadGrant(resource, session, itemId, true); await externalRequest(`${base(grant)}/file/v1/domains/${grant.domain}/items/${itemHex(itemId)}`, { method: 'DELETE', headers: { 'X-WD-Ticket': grant.ticket } }); forgetUploadGrant(resource, itemId); } /** 删除文件域里一个条目的所有字节。先向空间要 delete 票据、删掉主后端的条目之后调用 */ export async function deleteFileBytes(grant: FileGrantBody, itemId: string): Promise { await externalRequest(`${base(grant)}/file/v1/domains/${grant.domain}/items/${itemHex(itemId)}`, { method: 'DELETE', headers: { 'X-WD-Ticket': grant.ticket } }); } /** * 打开一个条目自第 from 帧起的内容(下载、边下边播共用)。频道里的文件向空间要下载票据(按规则计次), * 再带着票据去文件服务取;分享里的文件仍在主后端。 */ export async function openItemFrames(resource: SavedResource, session: ChannelSession, itemId: string, from: number, signal?: AbortSignal): Promise { if (resource.kind === 'channel') { // 先问主后端:没有文件域的频道里,文件和聊天一样存在这里;存在文件服务的文件它会回 use_file_api,再去要票据 try { return await apiFetch(`/channels/${resource.id}/items/${itemId}/frames?from=${from}`, { headers: session.authHeaders(tokenRole(session)), ...(signal ? { signal } : {}) }); } catch (error) { if (!hasCode(error, ErrorCode.UseFileApi)) throw error; } const grant = await requestGrant(resource, session, { op: 'get', item: itemId, from }); return externalFetch(`${base(grant)}/file/v1/domains/${grant.domain}/items/${itemHex(itemId)}/frames?from=${from}`, { 'X-WD-Ticket': grant.ticket }, signal); } const path = `/shares/${resource.id}/items/${itemId}/frames?from=${from}`; const role = resource.kind === 'share' && resource.state === 'draft' ? 'owner' : session.can('read') ? 'read' : 'owner'; return apiFetch(path, { headers: session.authHeaders(role), ...(signal ? { signal } : {}) }); }