Files
Endoleap/src/lib/canvas-service/assets.ts
T

96 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Foundry 图片上传与共享资源授权适配。原文件的预分配资源 ID、SHA、长度和操作身份绑定冻结请求,
* 丢响应后查询原请求,而不是重新创建图片。规范 sourceURL 与预览 displayURL 分开,预览不能冒充原件身份。
*/
import {validateSharedAsset} from '../canvas-runtime/asset-depot.ts';
import type { Id, Json } from '../canvas-core/types.ts';
import { fault, freeze, validJson } from '../canvas-core/values.ts';
import type { AssetPort, ResourceRecord, StoredAsset } from '../canvas-runtime/resources.ts';
import type { FrozenRequest } from '../canvas-runtime/ledger.ts';
import type { OperationStatus } from '../canvas-runtime/scheduler.ts';
import type { ServiceResponse, ServiceTransport } from './foundry.ts';
// 本服务的工作区、请求及资源身份采用 UUID;摘要为小写64位十六进制 SHA-256。
const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
/** 只校验响应最外层对象,回执字段关联、JSON 和资源协议另在对应路径检查。 */
function object(value: unknown): Record<string, unknown> {
if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('invalid_asset_response');
return value as Record<string, unknown>;
}
/** 交叉检查冻结请求与元数据载荷:一个上传操作、无canvas写入版本、固定资源身份和原字节属性。
* 元数据 byte_length 必须为1字节至20MiB;body 只是固定请求声明,原 Blob 经 FormData 单独携带。
* 不改写 request.body,不在这里检查实际 Blob;JSON.parse 等异常沿调用路径处理,不执行文件保存或任务编译。 */
function payload(request: FrozenRequest) {
const raw = object(JSON.parse(request.body));
if (request.kind !== 'asset.upload' || request.protocolVersion !== 2 || raw.protocol_version !== 2 || raw.request_id !== request.id || !uuid.test(request.id) || typeof raw.resource_id !== 'string' || !uuid.test(raw.resource_id) || request.operationIds.length !== 1 || raw.operation_id !== request.operationIds[0] || request.canvasIds.length || Object.keys(request.expectedVersions).length || typeof raw.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(raw.sha256) || typeof raw.byte_length !== 'number' || !Number.isSafeInteger(raw.byte_length) || raw.byte_length < 1 || raw.byte_length > 20 * 1024 * 1024 || typeof raw.name !== 'string' || !raw.name || typeof raw.mime_type !== 'string') throw fault('invalid_asset_request');
return raw;
}
/** applied 只在2xx+协议/requestId 匹配时成立,捕获完整 JSON 回执,资源字段再由 accept 核对。
* 查询404+matching unknown 是可再查的 unresolved;其它4xx 可 blocked,但不能因此制造 not-applied。
* 状态解释不解决 ledger 或更新资源,它只返回 scheduler 所需的结果描述。 */
function status(request: FrozenRequest, response: ServiceResponse, query: boolean): OperationStatus {
const body = object(response.body), matching = body.protocol_version === 2 && body.request_id === request.id;
if (matching && body.status === 'applied' && response.status >= 200 && response.status < 300) {
if (!validJson(body)) throw fault('invalid_asset_response');
return freeze({ outcome: 'applied', canonical: JSON.parse(JSON.stringify(body)) as Json });
}
if (query && matching && body.status === 'unknown' && response.status === 404) return { outcome: 'unresolved', error: 'asset_receipt_unknown', retryAfterMs: response.retryAfterMs };
const error = typeof body.error === 'string' ? body.error : 'asset_transport_unknown';
// A bare rejection does not prove whether an earlier upload committed.
return { outcome: 'unresolved', error, retryAfterMs: response.retryAfterMs, ...(response.status >= 400 && response.status < 500 ? { blocked: true } : {}) };
}
/** Immutable bytes and a preallocated resource ID survive response loss and view changes. */
/** 校验规范HTTPS源站,构造工作区资源服务与授权域。
* 上传与查询复用固定身份;本地文件保存与任务调度由 engine 负责,工厂不自建队列或重试 timer。
* origin 只用于规范 sourceURL/authority;实际 HTTP 目的地由 transport 配置,不能据此参数推断 fetch 去向。
* options.project/transport 等沿用宿主对象读取合同,应保持配置稳定,不能运行中改 project 破坏授权域/URL 一致性。 */
export function createFoundryAssets(options: { workspaceId: Id; transport: ServiceTransport; project?: string; origin?: string; displayURL?: (id: Id) => string }): AssetPort {
if (!uuid.test(options.workspaceId)) throw fault('invalid_service_scope');
const origin = new URL(options.origin ?? 'https://api.foundry.entroia.com');
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.search || origin.hash || origin.pathname !== '/') throw fault('invalid_asset_origin');
const path = `workspaces/${encodeURIComponent(options.workspaceId)}/assets`;
/** 可持久化的规范原件地址;每段编码,默认项目endoleap,不与宿主代理预览URL混用。 */
const sourceURL = (id: Id) => `${origin.origin}/projects/${encodeURIComponent(options.project ?? 'endoleap')}/${path}/${encodeURIComponent(id)}`;
/** 验证已应用回执和本地资源事实一致,包括操作/摘要/长度/规范URL、尺寸和允许MIME。
* 限制正整数尺寸及4000万像素,捕获/冻结 asset 后返回;不在此更新 runtime、清本地原图或写共享仓库。
* 接受服务器检测的允许图片 MIME,不要求它与原上传 mimeType 完全相同;实际像素不在此再次解码验证。 */
const accept = (request: FrozenRequest, result: OperationStatus, resource: ResourceRecord): StoredAsset => {
const raw = payload(request), receipt = object(result.canonical), asset = object(receipt.asset);
if (result.outcome !== 'applied' || receipt.protocol_version !== 2 || receipt.request_id !== request.id || receipt.status !== 'applied' || resource.id !== raw.resource_id || resource.operationId !== raw.operation_id || resource.digest !== raw.sha256 || resource.byteLength !== raw.byte_length || asset.id !== resource.id || asset.url !== sourceURL(resource.id) || asset.bytes !== resource.byteLength || typeof asset.width !== 'number' || typeof asset.height !== 'number' || !Number.isSafeInteger(asset.width) || !Number.isSafeInteger(asset.height) || asset.width <= 0 || asset.height <= 0 || asset.width * asset.height > 40_000_000 || !['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(asset.mime_type as string)) throw fault('asset_receipt_mismatch');
return freeze(JSON.parse(JSON.stringify(asset)) as StoredAsset);
};
/** 带 bytes 则 POST,否则 GET 原 requestId 回执;只做单次尝试,不换资源/操作/request 身份。
* 参数/网络/回执异常按 Error 风格 code 转 unresolved;有明确 code(除 invalid_asset_response)才附 blocked。
* signal 传给 transport,不能抢占这里原生 Blob.arrayBuffer/crypto.digest 的执行。 */
async function execute(request: FrozenRequest, bytes: Blob | undefined, signal: AbortSignal) {
try {
const raw = payload(request);
let body: FormData | undefined;
if (bytes) {
// 元数据长度上限已校验,但实际 bytes.size 在整体 arrayBuffer+SHA 后才比对,不是本函数读取前的硬限。
// 正常 engine 上传路径先检查实际长度;这里不做流式摘要或内存分块,调用者不能传任意超大 Blob 期待提前拒绝。
const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', await bytes.arrayBuffer()))].map(value => value.toString(16).padStart(2, '0')).join('');
if (bytes.size !== raw.byte_length || digest !== raw.sha256) throw fault('resource_bytes_mismatch');
// FormData只携带原图和文件名;幂等请求ID及资源ID通过transport专用头发送。
body = new FormData(); body.append('image', bytes, raw.name as string);
}
const response = await options.transport({ method: bytes ? 'POST' : 'GET', path: bytes ? path : `${path}/operations/${encodeURIComponent(request.id)}`, requestId: bytes ? request.id : undefined, resourceId: bytes ? raw.resource_id as string : undefined, body, signal });
return status(request, response, !bytes);
} catch (error) {
const code = (error as { code?: string }).code;
return { outcome: 'unresolved' as const, error: code ?? 'asset_transport_unknown', ...(code && code !== 'invalid_asset_response' ? { blocked: true } : {}) };
}
}
/** 跨项目复用的服务授权端口。resolve读取版本化元数据并核对ID/规范URL,
* read获取原件字节;字节SHA与授权元数据的一致性由调用它的AssetDepot继续验证。
* scope绑定源站/项目/工作区,缓存命中不能替代一次新的领用授权。
* resolve 返回冻结元数据,read 返回原字节响应;不在此重新哈希/解码,权限身份与字节预算还依赖 transport 合同。 */
const authority=Object.freeze({scope:`${origin.origin}/projects/${encodeURIComponent(options.project??'endoleap')}/${path}`,async resolve(id:Id,signal:AbortSignal){if(!uuid.test(id))throw fault('invalid_resource_id');const response=await options.transport({method:'GET',path:`${path}/metadata/${encodeURIComponent(id)}`,signal}),raw=object(response.body);if(response.status!==200)throw fault(typeof raw.error==='string'?raw.error:'asset_authorization_failed');if(raw.metadata_version!==1)throw fault('asset_metadata_interpreter_unavailable');const value={version:1 as const,authority:authority.scope,digest:raw.sha256 as string,asset:raw.asset as StoredAsset};validateSharedAsset(value);if(value.asset.id!==id||value.asset.url!==sourceURL(id))throw fault('asset_authorization_mismatch');return freeze(value);},async read(id:Id,signal:AbortSignal){if(!uuid.test(id))throw fault('invalid_resource_id');const response=await options.transport({method:'GET',path:`${path}/${encodeURIComponent(id)}`,signal,responseFormat:'asset-bytes-v1'});if(response.status!==200||!response.bytes)throw fault('asset_bytes_unavailable');return response.bytes;}});
// 默认显示地址走同源预览代理;upload/query只委托统一执行器,不复制另一套任务状态。
return Object.freeze({ authority, sourceURL, displayURL: options.displayURL ?? ((id: Id) => `/api/foundry/${path}/${encodeURIComponent(id)}?variant=preview`), accept,
upload: (request: FrozenRequest, bytes: Blob, signal: AbortSignal) => execute(request, bytes, signal),
query: (request: FrozenRequest, signal: AbortSignal) => execute(request, undefined, signal),
});
}