fix: 修复分享链接路由与登录回跳
- 统一生成包含部署基路径和 Hash 路由的知识库、工作流分享地址 - 未登录访问分享页时保留目标地址,并在登录成功后自动回跳 - 限定分享密钥作用域并补充失效、加载失败及回归测试
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveLoginRedirectPath } from '#/utils/login-redirect';
|
||||
|
||||
describe('login redirect', () => {
|
||||
it('restores an encoded knowledge share route', () => {
|
||||
expect(
|
||||
resolveLoginRedirectPath(
|
||||
encodeURIComponent('/share/knowledge?shareKey=knowledge-key'),
|
||||
),
|
||||
).toBe('/share/knowledge?shareKey=knowledge-key');
|
||||
});
|
||||
|
||||
it('accepts an unencoded workflow share route', () => {
|
||||
expect(
|
||||
resolveLoginRedirectPath('/ai/workflow/design?shareKey=workflow-key'),
|
||||
).toBe('/ai/workflow/design?shareKey=workflow-key');
|
||||
});
|
||||
|
||||
it('uses the first valid query value', () => {
|
||||
expect(
|
||||
resolveLoginRedirectPath([
|
||||
undefined,
|
||||
encodeURIComponent('/share/knowledge?shareKey=knowledge-key'),
|
||||
]),
|
||||
).toBe('/share/knowledge?shareKey=knowledge-key');
|
||||
});
|
||||
|
||||
it('rejects external and malformed redirects', () => {
|
||||
expect(resolveLoginRedirectPath('https://example.test')).toBeNull();
|
||||
expect(resolveLoginRedirectPath('//example.test/path')).toBeNull();
|
||||
expect(resolveLoginRedirectPath('%E0%A4%A')).toBeNull();
|
||||
});
|
||||
});
|
||||
25
easyflow-ui-admin/app/src/utils/login-redirect.ts
Normal file
25
easyflow-ui-admin/app/src/utils/login-redirect.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
function firstQueryValue(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.find((item) => typeof item === 'string' && item.trim());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析登录成功后的站内回跳地址,并拒绝外部或无效跳转。
|
||||
*/
|
||||
export function resolveLoginRedirectPath(value: unknown): null | string {
|
||||
const rawValue = firstQueryValue(value);
|
||||
if (typeof rawValue !== 'string' || !rawValue.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const path = decodeURIComponent(rawValue.trim());
|
||||
if (!path.startsWith('/') || path.startsWith('//')) {
|
||||
return null;
|
||||
}
|
||||
return path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
66
easyflow-ui-admin/app/src/utils/share-route-context.test.ts
Normal file
66
easyflow-ui-admin/app/src/utils/share-route-context.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAbsoluteAppRouteUrl,
|
||||
readScopedRouteQueryParam,
|
||||
} from './share-route-context';
|
||||
|
||||
describe('share route context', () => {
|
||||
it('reads a knowledge share key from the active hash route', () => {
|
||||
expect(
|
||||
readScopedRouteQueryParam(
|
||||
'/share/knowledge',
|
||||
'shareKey',
|
||||
'https://example.test/flow/#/share/knowledge?shareKey=knowledge-key',
|
||||
),
|
||||
).toBe('knowledge-key');
|
||||
});
|
||||
|
||||
it('reads a knowledge share key from a history route', () => {
|
||||
expect(
|
||||
readScopedRouteQueryParam(
|
||||
'/share/knowledge',
|
||||
'shareKey',
|
||||
'https://example.test/flow/share/knowledge?shareKey=knowledge-key',
|
||||
),
|
||||
).toBe('knowledge-key');
|
||||
});
|
||||
|
||||
it('prefers the active hash route over an outer query', () => {
|
||||
expect(
|
||||
readScopedRouteQueryParam(
|
||||
'/share/knowledge',
|
||||
'shareKey',
|
||||
'https://example.test/flow/share/knowledge?shareKey=stale-key#/ai/workflow',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the hash route when building an absolute share URL', () => {
|
||||
expect(
|
||||
buildAbsoluteAppRouteUrl(
|
||||
'/flow/#/share/knowledge?shareKey=knowledge-key',
|
||||
'https://example.test',
|
||||
),
|
||||
).toBe(
|
||||
'https://example.test/flow/#/share/knowledge?shareKey=knowledge-key',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the runtime base path for a hash-only router href', () => {
|
||||
const previousPath = `${window.location.pathname}${window.location.search}`;
|
||||
window.history.replaceState({}, '', '/flow/');
|
||||
try {
|
||||
expect(
|
||||
buildAbsoluteAppRouteUrl(
|
||||
'#/share/knowledge?shareKey=knowledge-key',
|
||||
'https://example.test',
|
||||
),
|
||||
).toBe(
|
||||
'https://example.test/flow/#/share/knowledge?shareKey=knowledge-key',
|
||||
);
|
||||
} finally {
|
||||
window.history.replaceState({}, '', previousPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
87
easyflow-ui-admin/app/src/utils/share-route-context.ts
Normal file
87
easyflow-ui-admin/app/src/utils/share-route-context.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 判断当前路径是否对应指定的应用路由。
|
||||
*/
|
||||
function matchesRoutePath(pathname: string, routePath: string) {
|
||||
const normalizedPathname = pathname.replace(/\/+$/, '');
|
||||
const normalizedRoutePath = routePath.replace(/\/+$/, '');
|
||||
return (
|
||||
normalizedPathname === normalizedRoutePath ||
|
||||
normalizedPathname.endsWith(normalizedRoutePath)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Hash 模式下当前激活的应用路由。
|
||||
*/
|
||||
function parseHashRoute(hash: string) {
|
||||
if (!hash.startsWith('#/')) {
|
||||
return null;
|
||||
}
|
||||
return new URL(hash.slice(1), 'http://easyflow.local');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从指定应用路由读取查询参数。
|
||||
*
|
||||
* Hash 路由存在时仅信任 Hash 内的激活路由,避免 pathname 上遗留的分享参数
|
||||
* 污染后续页面。
|
||||
*/
|
||||
export function readScopedRouteQueryParam(
|
||||
routePath: string,
|
||||
queryName: string,
|
||||
url?: string,
|
||||
): null | string {
|
||||
const currentUrl =
|
||||
url || (typeof window === 'undefined' ? '' : window.location.href);
|
||||
if (!currentUrl) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(
|
||||
currentUrl,
|
||||
typeof window === 'undefined'
|
||||
? 'http://localhost'
|
||||
: window.location.origin,
|
||||
);
|
||||
const hashRoute = parseHashRoute(parsed.hash);
|
||||
if (hashRoute) {
|
||||
if (!matchesRoutePath(hashRoute.pathname, routePath)) {
|
||||
return null;
|
||||
}
|
||||
return hashRoute.searchParams.get(queryName)?.trim() || null;
|
||||
}
|
||||
if (!matchesRoutePath(parsed.pathname, routePath)) {
|
||||
return null;
|
||||
}
|
||||
return parsed.searchParams.get(queryName)?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Router 解析出的应用地址转换为可复制的绝对地址。
|
||||
*
|
||||
* Hash 路由下 Router 可能只返回 `#/path`,需显式补齐部署基路径。
|
||||
*/
|
||||
export function buildAbsoluteAppRouteUrl(href: string, origin?: string) {
|
||||
const currentOrigin =
|
||||
origin ||
|
||||
(typeof window === 'undefined'
|
||||
? 'http://localhost'
|
||||
: window.location.origin);
|
||||
if (!href.startsWith('#/') && !href.startsWith('/#/')) {
|
||||
return new URL(href, currentOrigin).toString();
|
||||
}
|
||||
const configuredBase = import.meta.env.BASE_URL || '/';
|
||||
const runtimeBase =
|
||||
configuredBase === '/' &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.location.pathname.endsWith('/')
|
||||
? window.location.pathname
|
||||
: configuredBase;
|
||||
return new URL(
|
||||
`${runtimeBase.replace(/\/?$/, '/')}${href.slice(href.indexOf('#'))}`,
|
||||
currentOrigin,
|
||||
).toString();
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readScopedRouteQueryParam } from './share-route-context';
|
||||
|
||||
/**
|
||||
* 工作流协作分享请求头。
|
||||
*/
|
||||
@@ -10,37 +12,52 @@ interface WorkflowShareResolutionOptions<T> {
|
||||
shareKey?: unknown;
|
||||
}
|
||||
|
||||
interface WorkflowShareHeaderOptions {
|
||||
pageUrl?: string;
|
||||
requestMethod?: string;
|
||||
requestUrl?: string;
|
||||
}
|
||||
|
||||
const WORKFLOW_DESIGN_ROUTE = '/ai/workflow/design';
|
||||
const WORKFLOW_SHARE_REQUESTS = [
|
||||
['GET', '/api/v1/workflow/detail'],
|
||||
['GET', '/api/v1/workflow/getRunningParameters'],
|
||||
['GET', '/api/v1/workflow/publishApprovalRequirement'],
|
||||
['GET', '/api/v1/workflowShare/resolve'],
|
||||
['POST', '/api/v1/workflow/check'],
|
||||
['POST', '/api/v1/workflow/getChainStatus'],
|
||||
['POST', '/api/v1/workflow/resume'],
|
||||
['POST', '/api/v1/workflow/runAsync'],
|
||||
['POST', '/api/v1/workflow/singleRun'],
|
||||
['POST', '/api/v1/workflow/submitPublishApproval'],
|
||||
['POST', '/api/v1/workflow/update'],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 从页面地址读取工作流分享密钥,兼容 history 与 hash 路由。
|
||||
*/
|
||||
export function readWorkflowShareKey(url?: string): null | string {
|
||||
const currentUrl =
|
||||
url || (typeof window === 'undefined' ? '' : window.location.href);
|
||||
if (!currentUrl) {
|
||||
return null;
|
||||
return readScopedRouteQueryParam(WORKFLOW_DESIGN_ROUTE, 'shareKey', url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前请求是否属于工作流分享授权白名单。
|
||||
*/
|
||||
export function isWorkflowShareRequest(
|
||||
requestUrl?: string,
|
||||
requestMethod?: string,
|
||||
) {
|
||||
if (!requestUrl || !requestMethod) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(
|
||||
currentUrl,
|
||||
typeof window === 'undefined'
|
||||
? 'http://localhost'
|
||||
: window.location.origin,
|
||||
);
|
||||
const historyKey = parsed.searchParams.get('shareKey')?.trim();
|
||||
if (historyKey) {
|
||||
return historyKey;
|
||||
}
|
||||
const queryIndex = parsed.hash.indexOf('?');
|
||||
if (queryIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
new URLSearchParams(parsed.hash.slice(queryIndex + 1))
|
||||
.get('shareKey')
|
||||
?.trim() || null
|
||||
);
|
||||
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 null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +66,12 @@ export function readWorkflowShareKey(url?: string): null | string {
|
||||
*/
|
||||
export function withWorkflowShareHeader(
|
||||
headers: Record<string, string>,
|
||||
url?: string,
|
||||
options: WorkflowShareHeaderOptions = {},
|
||||
): Record<string, string> {
|
||||
const shareKey = readWorkflowShareKey(url);
|
||||
if (!isWorkflowShareRequest(options.requestUrl, options.requestMethod)) {
|
||||
return headers;
|
||||
}
|
||||
const shareKey = readWorkflowShareKey(options.pageUrl);
|
||||
if (!shareKey) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user