96 lines
10 KiB
TypeScript
96 lines
10 KiB
TypeScript
/**
|
||
* 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),
|
||
});
|
||
}
|