Chat stays usable when the file service is off or down. Files up to 256 MB are hosted like chat; downloads try the host first and fall back to file-service tickets. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> [CI]
168 lines
10 KiB
TypeScript
168 lines
10 KiB
TypeScript
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<boolean> {
|
|
try { return (await getServerInfo()).files?.enabled === true; } catch { return false; }
|
|
}
|
|
|
|
/** 文件服务连不上(还没部署好、放文件的机器离线)时给一句看得懂的提示,而不是笼统的「网络连接失败」。被服务端拒绝的错误照常往外抛 */
|
|
export const FILE_SERVICE_DOWN = '文件服务暂时不可用,请稍后再试';
|
|
async function reachable<T>(call: () => Promise<T>): Promise<T> {
|
|
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<string> {
|
|
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<FileGrantRequest, 'from'> & { from?: number }): Promise<FileGrantBody> {
|
|
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<FileGrantBody>;
|
|
}
|
|
|
|
/** 文件服务提供的选项:创建频道时可以选的文件空间档位。不含机密,直接问文件服务 */
|
|
export async function fileDomainOptions(): Promise<FileDomainOptions | undefined> {
|
|
if (!await filesEnabled()) return undefined;
|
|
return reachable(async () => (await externalFetch(`${await fileServiceUrl()}/file/v1/options`, {})).json() as Promise<FileDomainOptions>);
|
|
}
|
|
|
|
/** 在文件服务里创建一个文件域(空间所有者/管理员),返回域 id */
|
|
export async function createFileDomain(resource: SavedResource, session: ChannelSession, quota?: number): Promise<string> {
|
|
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<boolean> {
|
|
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<FileDomainUsage | undefined> {
|
|
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<string, FileGrantBody>();
|
|
/** 上传票据在有效期内复用(一个条目的所有帧共用),快过期时换新的 */
|
|
export async function uploadGrant(resource: SavedResource, session: ChannelSession, item: string, fresh = false): Promise<FileGrantBody> {
|
|
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Response> {
|
|
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 } : {}) });
|
|
}
|