diff --git a/easyflow-ui-admin/app/src/api/knowledge-share.ts b/easyflow-ui-admin/app/src/api/knowledge-share.ts index 24740409..d5f13d86 100644 --- a/easyflow-ui-admin/app/src/api/knowledge-share.ts +++ b/easyflow-ui-admin/app/src/api/knowledge-share.ts @@ -1,4 +1,5 @@ import { api } from '#/api/request'; +import { readScopedRouteQueryParam } from '#/utils/share-route-context'; const EXPIRED_ERROR_CODES = new Set([4601, 4602]); const SHARE_ERROR_REASON: Record = { @@ -8,8 +9,8 @@ const SHARE_ERROR_REASON: Record = { const APP_BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); function getShareParams() { - const params = new URLSearchParams(window.location.search); - const shareKey = params.get('shareKey') || ''; + const shareKey = + readScopedRouteQueryParam('/share/knowledge', 'shareKey') || ''; return { shareKey }; } @@ -36,9 +37,12 @@ function redirectIfExpired(response: any) { return response; } const reason = SHARE_ERROR_REASON[errorCode] || 'expired'; - window.location.assign( - `${APP_BASE_PATH}/share/knowledge/expired?reason=${reason}`, - ); + const route = `/share/knowledge/expired?reason=${encodeURIComponent(reason)}`; + const target = + import.meta.env.VITE_ROUTER_HISTORY === 'hash' + ? `${APP_BASE_PATH}/#${route}` + : `${APP_BASE_PATH}${route}`; + window.location.assign(target); return response; } diff --git a/easyflow-ui-admin/app/src/api/request.ts b/easyflow-ui-admin/app/src/api/request.ts index cde27cf9..1a2e58c4 100644 --- a/easyflow-ui-admin/app/src/api/request.ts +++ b/easyflow-ui-admin/app/src/api/request.ts @@ -20,6 +20,7 @@ import { events } from 'fetch-event-stream'; import { useAuthStore } from '#/store'; import { + isWorkflowShareRequest, readWorkflowShareKey, withWorkflowShareHeader, WORKFLOW_SHARE_HEADER, @@ -101,7 +102,10 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { config.headers['easyflow-token'] = formatToken(accessStore.accessToken); config.headers['Accept-Language'] = preferences.app.locale; const workflowShareKey = readWorkflowShareKey(); - if (workflowShareKey) { + if ( + workflowShareKey && + isWorkflowShareRequest(config.url, config.method) + ) { config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey; } return config; @@ -196,7 +200,7 @@ export class SseClient { const res = await fetch(apiURL + url, { method: 'POST', signal, // 使用局部变量 signal - headers: this.getHeaders(options?.headers), + headers: this.getHeaders(url, options?.headers), body: JSON.stringify(data), }); @@ -265,7 +269,7 @@ export class SseClient { } } - private getHeaders(extraHeaders?: HeadersInit) { + private getHeaders(requestUrl: string, extraHeaders?: HeadersInit) { const accessStore = useAccessStore(); const headers: Record = { Accept: 'text/event-stream', @@ -277,7 +281,10 @@ export class SseClient { headers[key] = value; }); } - return withWorkflowShareHeader(headers); + return withWorkflowShareHeader(headers, { + requestMethod: 'POST', + requestUrl, + }); } } diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json index 857a3d96..ab6739b7 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/aiWorkflow.json @@ -90,6 +90,8 @@ "apiStatusExample": "Status Query Example", "apiResumeExample": "Resume Example", "shareExpired": "This workflow share link has expired. Request a new link", + "loadFailed": "Unable to load the workflow. Please try again", + "reload": "Reload", "submitPublishApprovalConfirm": "Publish the current workflow now?", "submitRepublishApprovalConfirm": "Republish the current workflow now?", "submitOfflineApprovalConfirm": "Take the current workflow offline?", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json index 345f1591..dba1a5f8 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/aiWorkflow.json @@ -90,6 +90,8 @@ "apiStatusExample": "状态查询示例", "apiResumeExample": "恢复执行示例", "shareExpired": "工作流分享链接已失效,请重新获取", + "loadFailed": "工作流加载失败,请重试", + "reload": "重新加载", "submitPublishApprovalConfirm": "确认发布当前工作流吗?", "submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?", "submitOfflineApprovalConfirm": "确认下线当前工作流吗?", diff --git a/easyflow-ui-admin/app/src/store/auth.ts b/easyflow-ui-admin/app/src/store/auth.ts index cff7d276..649a38e5 100644 --- a/easyflow-ui-admin/app/src/store/auth.ts +++ b/easyflow-ui-admin/app/src/store/auth.ts @@ -18,11 +18,12 @@ import { logoutApi, } from '#/api'; import { $t } from '#/locales'; +import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache'; +import { resolveLoginRedirectPath } from '#/utils/login-redirect'; import { buildForcePasswordRoute, shouldForcePasswordChange, } from '#/utils/password-reset'; -import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache'; export const useAuthStore = defineStore('auth', () => { const accessStore = useAccessStore(); @@ -67,9 +68,16 @@ export const useAuthStore = defineStore('auth', () => { } else if (!options.skipRedirect) { const homePath = userInfo.homePath || preferences.app.defaultHomePath || '/'; - options.onSuccess - ? await options.onSuccess() - : await router.push(homePath); + const redirectPath = resolveLoginRedirectPath( + router.currentRoute.value.query.redirect, + ); + if (options.onSuccess) { + await options.onSuccess(); + } else if (redirectPath) { + await router.replace(redirectPath); + } else { + await router.push(homePath); + } } if (options.notify !== false && userInfo?.nickname) { diff --git a/easyflow-ui-admin/app/src/utils/__tests__/login-redirect.test.ts b/easyflow-ui-admin/app/src/utils/__tests__/login-redirect.test.ts new file mode 100644 index 00000000..cc6a37f5 --- /dev/null +++ b/easyflow-ui-admin/app/src/utils/__tests__/login-redirect.test.ts @@ -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(); + }); +}); diff --git a/easyflow-ui-admin/app/src/utils/login-redirect.ts b/easyflow-ui-admin/app/src/utils/login-redirect.ts new file mode 100644 index 00000000..f42eea33 --- /dev/null +++ b/easyflow-ui-admin/app/src/utils/login-redirect.ts @@ -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; + } +} diff --git a/easyflow-ui-admin/app/src/utils/share-route-context.test.ts b/easyflow-ui-admin/app/src/utils/share-route-context.test.ts new file mode 100644 index 00000000..42ab7542 --- /dev/null +++ b/easyflow-ui-admin/app/src/utils/share-route-context.test.ts @@ -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); + } + }); +}); diff --git a/easyflow-ui-admin/app/src/utils/share-route-context.ts b/easyflow-ui-admin/app/src/utils/share-route-context.ts new file mode 100644 index 00000000..14d4e073 --- /dev/null +++ b/easyflow-ui-admin/app/src/utils/share-route-context.ts @@ -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(); +} diff --git a/easyflow-ui-admin/app/src/utils/workflow-share-context.ts b/easyflow-ui-admin/app/src/utils/workflow-share-context.ts index 3768fd92..036f0e96 100644 --- a/easyflow-ui-admin/app/src/utils/workflow-share-context.ts +++ b/easyflow-ui-admin/app/src/utils/workflow-share-context.ts @@ -1,3 +1,5 @@ +import { readScopedRouteQueryParam } from './share-route-context'; + /** * 工作流协作分享请求头。 */ @@ -10,37 +12,52 @@ interface WorkflowShareResolutionOptions { 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, - url?: string, + options: WorkflowShareHeaderOptions = {}, ): Record { - const shareKey = readWorkflowShareKey(url); + if (!isWorkflowShareRequest(options.requestUrl, options.requestMethod)) { + return headers; + } + const shareKey = readWorkflowShareKey(options.pageUrl); if (!shareKey) { return headers; } diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollection.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollection.vue index 0e7b36d1..dfe2585f 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollection.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/DocumentCollection.vue @@ -45,6 +45,7 @@ import CardPage from '#/components/page/CardList.vue'; import PageData from '#/components/page/PageData.vue'; import PageSide from '#/components/page/PageSide.vue'; import { copyTextWithFeedback } from '#/utils/clipboard-feedback'; +import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context'; import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue'; import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue'; import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason'; @@ -154,10 +155,16 @@ async function shareKnowledge(row: Record) { const res = await api.post('/api/v1/knowledgeShare/url/create', { knowledgeId: row.id, }); - const shareUrl = String(res.data?.shareUrl || '').trim(); - if (res.errorCode !== 0 || !shareUrl) { + const shareKey = String(res.data?.shareKey || '').trim(); + if (res.errorCode !== 0 || !shareKey) { return; } + const shareUrl = buildAbsoluteAppRouteUrl( + router.resolve({ + name: 'KnowledgeShare', + query: { shareKey }, + }).href, + ); await copyTextWithFeedback( shareUrl, $t('message.copySuccess'), diff --git a/easyflow-ui-admin/app/src/views/ai/documentCollection/KnowledgeShareManagement.vue b/easyflow-ui-admin/app/src/views/ai/documentCollection/KnowledgeShareManagement.vue index 670e35bd..f55cfbf7 100644 --- a/easyflow-ui-admin/app/src/views/ai/documentCollection/KnowledgeShareManagement.vue +++ b/easyflow-ui-admin/app/src/views/ai/documentCollection/KnowledgeShareManagement.vue @@ -1,5 +1,6 @@