Files
EasyFlow/easyflow-ui-admin/app/src/utils/workflow-share-context.ts
陈子默 263f5f4b8b feat: 支持工作流对话匿名分享
- 增加免登录公共接口、访客隔离、限流和匿名上传校验

- 分离 SSE 连接与运行生命周期,支持刷新恢复服务端权威状态

- 持久化分享页对话并优化时间线滚动与输入区交互
2026-08-31 16:45:21 +08:00

200 lines
5.4 KiB
TypeScript

import { readScopedRouteQueryParam } from './share-route-context';
/**
* 工作流对话分享请求头。
*/
export const WORKFLOW_SHARE_HEADER = 'X-Workflow-Chat-Share-Key';
/**
* 当前标签页的工作流对话分享访客标识请求头。
*/
export const WORKFLOW_SHARE_VISITOR_HEADER = 'X-Workflow-Chat-Visitor';
const WORKFLOW_SHARE_VISITOR_STORAGE_KEY =
'easyflow.workflow-chat-share.visitor';
interface WorkflowShareResolutionOptions<T> {
currentWorkflowId?: null | T;
onFailure: (error: unknown) => Promise<void> | void;
resolve: () => Promise<null | T | undefined>;
shareKey?: unknown;
}
interface WorkflowShareHeaderOptions {
pageUrl?: string;
requestMethod?: string;
requestUrl?: string;
storage?: Pick<Storage, 'getItem' | 'setItem'>;
visitorId?: string;
}
const WORKFLOW_SHARE_ROUTES = ['/share/workflow'];
const WORKFLOW_SHARE_REQUESTS = [
['GET', '/api/v1/workflowChat/public/descriptor'],
['GET', '/api/v1/workflowChat/public/execution'],
['GET', '/api/v1/workflowShare/resolve'],
['POST', '/api/v1/workflowChat/public/cancel'],
['POST', '/api/v1/workflowChat/public/resume'],
['POST', '/api/v1/workflowChat/public/run'],
['POST', '/api/v1/workflowChat/public/upload'],
] as const;
/**
* 从页面地址读取工作流分享密钥,兼容 history 与 hash 路由。
*/
export function readWorkflowShareKey(url?: string): null | string {
for (const routePath of WORKFLOW_SHARE_ROUTES) {
const shareKey = readScopedRouteQueryParam(routePath, 'shareKey', url);
if (shareKey) {
return shareKey;
}
}
return null;
}
/**
* 将分享解析失败映射为失效页展示原因。
*/
export function resolveWorkflowShareFailureReason(
error: unknown,
): 'expired' | 'invalid' {
const candidate = error as
| undefined
| {
message?: unknown;
response?: {
data?: {
error?: unknown;
message?: unknown;
};
};
};
const message = [
candidate?.response?.data?.message,
candidate?.response?.data?.error,
candidate?.message,
]
.map((value) => String(value || '').trim())
.find(Boolean);
return message && (message.includes('过期') || /expired/i.test(message))
? 'expired'
: 'invalid';
}
/**
* 判断当前请求是否属于工作流分享授权白名单。
*/
export function isWorkflowShareRequest(
requestUrl?: string,
requestMethod?: string,
) {
if (!requestUrl || !requestMethod) {
return false;
}
try {
const parsed = new URL(requestUrl, 'http://easyflow.local');
const method = requestMethod.trim().toUpperCase();
return WORKFLOW_SHARE_REQUESTS.some(([allowedMethod, allowedPath]) => {
return method === allowedMethod && parsed.pathname.endsWith(allowedPath);
});
} catch {
return false;
}
}
/**
* 读取或创建当前标签页稳定的 128-bit 匿名访客标识。
*/
export function resolveWorkflowShareVisitorId(
storage:
| Pick<Storage, 'getItem' | 'setItem'>
| undefined = resolveSessionStorage(),
randomBytes: (size: number) => Uint8Array = createRandomBytes,
): string {
const existing = storage?.getItem(WORKFLOW_SHARE_VISITOR_STORAGE_KEY)?.trim();
if (existing && /^[a-f0-9]{32}$/.test(existing)) {
return existing;
}
const visitorId = [...randomBytes(16)]
.map((value) => value.toString(16).padStart(2, '0'))
.join('');
storage?.setItem(WORKFLOW_SHARE_VISITOR_STORAGE_KEY, visitorId);
return visitorId;
}
/**
* 在保留现有请求头的基础上附加工作流分享密钥。
*/
export function withWorkflowShareHeader(
headers: Record<string, string>,
options: WorkflowShareHeaderOptions = {},
): Record<string, string> {
return withWorkflowShareHeaders(headers, options);
}
/**
* 在保留通用请求头的基础上附加匿名分享密钥与当前标签页访客标识。
*/
export function withWorkflowShareHeaders(
headers: Record<string, string>,
options: WorkflowShareHeaderOptions = {},
): Record<string, string> {
if (!isWorkflowShareRequest(options.requestUrl, options.requestMethod)) {
return headers;
}
const shareKey = readWorkflowShareKey(options.pageUrl);
if (!shareKey) {
return headers;
}
const visitorId =
options.visitorId || resolveWorkflowShareVisitorId(options.storage);
return {
...headers,
'easyflow-token': '',
[WORKFLOW_SHARE_HEADER]: shareKey,
[WORKFLOW_SHARE_VISITOR_HEADER]: visitorId,
};
}
function resolveSessionStorage() {
try {
return globalThis.sessionStorage;
} catch {
return undefined;
}
}
function createRandomBytes(size: number) {
const bytes = new Uint8Array(size);
if (!globalThis.crypto?.getRandomValues) {
throw new Error('当前浏览器不支持安全的匿名访客标识');
}
return globalThis.crypto.getRandomValues(bytes);
}
/**
* 解析分享地址对应的工作流,并在链接失效时统一收口异常。
*/
export async function resolveWorkflowShareWorkflowId<T>({
currentWorkflowId,
onFailure,
resolve,
shareKey,
}: WorkflowShareResolutionOptions<T>): Promise<null | T> {
if (currentWorkflowId !== null && currentWorkflowId !== undefined) {
return currentWorkflowId;
}
const normalizedShareKey = Array.isArray(shareKey)
? shareKey.find((value) => String(value || '').trim())
: shareKey;
if (!String(normalizedShareKey || '').trim()) {
return null;
}
try {
return (await resolve()) ?? null;
} catch (error) {
await onFailure(error);
return null;
}
}