fix: 修复分享链接路由与登录回跳

- 统一生成包含部署基路径和 Hash 路由的知识库、工作流分享地址

- 未登录访问分享页时保留目标地址,并在登录成功后自动回跳

- 限定分享密钥作用域并补充失效、加载失败及回归测试
This commit is contained in:
2026-07-24 16:00:57 +08:00
parent 41545fcef0
commit 526e16163b
15 changed files with 465 additions and 71 deletions

View File

@@ -1,4 +1,5 @@
import { api } from '#/api/request'; import { api } from '#/api/request';
import { readScopedRouteQueryParam } from '#/utils/share-route-context';
const EXPIRED_ERROR_CODES = new Set([4601, 4602]); const EXPIRED_ERROR_CODES = new Set([4601, 4602]);
const SHARE_ERROR_REASON: Record<number, string> = { const SHARE_ERROR_REASON: Record<number, string> = {
@@ -8,8 +9,8 @@ const SHARE_ERROR_REASON: Record<number, string> = {
const APP_BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); const APP_BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
function getShareParams() { function getShareParams() {
const params = new URLSearchParams(window.location.search); const shareKey =
const shareKey = params.get('shareKey') || ''; readScopedRouteQueryParam('/share/knowledge', 'shareKey') || '';
return { shareKey }; return { shareKey };
} }
@@ -36,9 +37,12 @@ function redirectIfExpired(response: any) {
return response; return response;
} }
const reason = SHARE_ERROR_REASON[errorCode] || 'expired'; const reason = SHARE_ERROR_REASON[errorCode] || 'expired';
window.location.assign( const route = `/share/knowledge/expired?reason=${encodeURIComponent(reason)}`;
`${APP_BASE_PATH}/share/knowledge/expired?reason=${reason}`, const target =
); import.meta.env.VITE_ROUTER_HISTORY === 'hash'
? `${APP_BASE_PATH}/#${route}`
: `${APP_BASE_PATH}${route}`;
window.location.assign(target);
return response; return response;
} }

View File

@@ -20,6 +20,7 @@ import { events } from 'fetch-event-stream';
import { useAuthStore } from '#/store'; import { useAuthStore } from '#/store';
import { import {
isWorkflowShareRequest,
readWorkflowShareKey, readWorkflowShareKey,
withWorkflowShareHeader, withWorkflowShareHeader,
WORKFLOW_SHARE_HEADER, WORKFLOW_SHARE_HEADER,
@@ -101,7 +102,10 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
config.headers['easyflow-token'] = formatToken(accessStore.accessToken); config.headers['easyflow-token'] = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale; config.headers['Accept-Language'] = preferences.app.locale;
const workflowShareKey = readWorkflowShareKey(); const workflowShareKey = readWorkflowShareKey();
if (workflowShareKey) { if (
workflowShareKey &&
isWorkflowShareRequest(config.url, config.method)
) {
config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey; config.headers[WORKFLOW_SHARE_HEADER] = workflowShareKey;
} }
return config; return config;
@@ -196,7 +200,7 @@ export class SseClient {
const res = await fetch(apiURL + url, { const res = await fetch(apiURL + url, {
method: 'POST', method: 'POST',
signal, // 使用局部变量 signal signal, // 使用局部变量 signal
headers: this.getHeaders(options?.headers), headers: this.getHeaders(url, options?.headers),
body: JSON.stringify(data), 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 accessStore = useAccessStore();
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: 'text/event-stream', Accept: 'text/event-stream',
@@ -277,7 +281,10 @@ export class SseClient {
headers[key] = value; headers[key] = value;
}); });
} }
return withWorkflowShareHeader(headers); return withWorkflowShareHeader(headers, {
requestMethod: 'POST',
requestUrl,
});
} }
} }

View File

@@ -90,6 +90,8 @@
"apiStatusExample": "Status Query Example", "apiStatusExample": "Status Query Example",
"apiResumeExample": "Resume Example", "apiResumeExample": "Resume Example",
"shareExpired": "This workflow share link has expired. Request a new link", "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?", "submitPublishApprovalConfirm": "Publish the current workflow now?",
"submitRepublishApprovalConfirm": "Republish the current workflow now?", "submitRepublishApprovalConfirm": "Republish the current workflow now?",
"submitOfflineApprovalConfirm": "Take the current workflow offline?", "submitOfflineApprovalConfirm": "Take the current workflow offline?",

View File

@@ -90,6 +90,8 @@
"apiStatusExample": "状态查询示例", "apiStatusExample": "状态查询示例",
"apiResumeExample": "恢复执行示例", "apiResumeExample": "恢复执行示例",
"shareExpired": "工作流分享链接已失效,请重新获取", "shareExpired": "工作流分享链接已失效,请重新获取",
"loadFailed": "工作流加载失败,请重试",
"reload": "重新加载",
"submitPublishApprovalConfirm": "确认发布当前工作流吗?", "submitPublishApprovalConfirm": "确认发布当前工作流吗?",
"submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?", "submitRepublishApprovalConfirm": "确认重新发布当前工作流吗?",
"submitOfflineApprovalConfirm": "确认下线当前工作流吗?", "submitOfflineApprovalConfirm": "确认下线当前工作流吗?",

View File

@@ -18,11 +18,12 @@ import {
logoutApi, logoutApi,
} from '#/api'; } from '#/api';
import { $t } from '#/locales'; import { $t } from '#/locales';
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
import { resolveLoginRedirectPath } from '#/utils/login-redirect';
import { import {
buildForcePasswordRoute, buildForcePasswordRoute,
shouldForcePasswordChange, shouldForcePasswordChange,
} from '#/utils/password-reset'; } from '#/utils/password-reset';
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore(); const accessStore = useAccessStore();
@@ -67,9 +68,16 @@ export const useAuthStore = defineStore('auth', () => {
} else if (!options.skipRedirect) { } else if (!options.skipRedirect) {
const homePath = const homePath =
userInfo.homePath || preferences.app.defaultHomePath || '/'; userInfo.homePath || preferences.app.defaultHomePath || '/';
options.onSuccess const redirectPath = resolveLoginRedirectPath(
? await options.onSuccess() router.currentRoute.value.query.redirect,
: await router.push(homePath); );
if (options.onSuccess) {
await options.onSuccess();
} else if (redirectPath) {
await router.replace(redirectPath);
} else {
await router.push(homePath);
}
} }
if (options.notify !== false && userInfo?.nickname) { if (options.notify !== false && userInfo?.nickname) {

View File

@@ -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();
});
});

View 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;
}
}

View 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);
}
});
});

View 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();
}

View File

@@ -1,3 +1,5 @@
import { readScopedRouteQueryParam } from './share-route-context';
/** /**
* 工作流协作分享请求头。 * 工作流协作分享请求头。
*/ */
@@ -10,37 +12,52 @@ interface WorkflowShareResolutionOptions<T> {
shareKey?: unknown; 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 路由。 * 从页面地址读取工作流分享密钥,兼容 history 与 hash 路由。
*/ */
export function readWorkflowShareKey(url?: string): null | string { export function readWorkflowShareKey(url?: string): null | string {
const currentUrl = return readScopedRouteQueryParam(WORKFLOW_DESIGN_ROUTE, 'shareKey', url);
url || (typeof window === 'undefined' ? '' : window.location.href); }
if (!currentUrl) {
return null; /**
* 判断当前请求是否属于工作流分享授权白名单。
*/
export function isWorkflowShareRequest(
requestUrl?: string,
requestMethod?: string,
) {
if (!requestUrl || !requestMethod) {
return false;
} }
try { try {
const parsed = new URL( const parsed = new URL(requestUrl, 'http://easyflow.local');
currentUrl, const method = requestMethod.trim().toUpperCase();
typeof window === 'undefined' return WORKFLOW_SHARE_REQUESTS.some(([allowedMethod, allowedPath]) => {
? 'http://localhost' return method === allowedMethod && parsed.pathname.endsWith(allowedPath);
: 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
);
} catch { } catch {
return null; return false;
} }
} }
@@ -49,9 +66,12 @@ export function readWorkflowShareKey(url?: string): null | string {
*/ */
export function withWorkflowShareHeader( export function withWorkflowShareHeader(
headers: Record<string, string>, headers: Record<string, string>,
url?: string, options: WorkflowShareHeaderOptions = {},
): Record<string, string> { ): Record<string, string> {
const shareKey = readWorkflowShareKey(url); if (!isWorkflowShareRequest(options.requestUrl, options.requestMethod)) {
return headers;
}
const shareKey = readWorkflowShareKey(options.pageUrl);
if (!shareKey) { if (!shareKey) {
return headers; return headers;
} }

View File

@@ -45,6 +45,7 @@ import CardPage from '#/components/page/CardList.vue';
import PageData from '#/components/page/PageData.vue'; import PageData from '#/components/page/PageData.vue';
import PageSide from '#/components/page/PageSide.vue'; import PageSide from '#/components/page/PageSide.vue';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback'; import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue'; import DocumentCollectionModal from '#/views/ai/documentCollection/DocumentCollectionModal.vue';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue'; import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason'; import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
@@ -154,10 +155,16 @@ async function shareKnowledge(row: Record<string, any>) {
const res = await api.post('/api/v1/knowledgeShare/url/create', { const res = await api.post('/api/v1/knowledgeShare/url/create', {
knowledgeId: row.id, knowledgeId: row.id,
}); });
const shareUrl = String(res.data?.shareUrl || '').trim(); const shareKey = String(res.data?.shareKey || '').trim();
if (res.errorCode !== 0 || !shareUrl) { if (res.errorCode !== 0 || !shareKey) {
return; return;
} }
const shareUrl = buildAbsoluteAppRouteUrl(
router.resolve({
name: 'KnowledgeShare',
query: { shareKey },
}).href,
);
await copyTextWithFeedback( await copyTextWithFeedback(
shareUrl, shareUrl,
$t('message.copySuccess'), $t('message.copySuccess'),

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { $t } from '@easyflow/locales'; import { $t } from '@easyflow/locales';
@@ -8,6 +9,7 @@ import { ElButton, ElCard, ElIcon, ElInput, ElMessage } from 'element-plus';
import { api } from '#/api/request'; import { api } from '#/api/request';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback'; import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
type EndpointParam = { type EndpointParam = {
location: 'body' | 'query'; location: 'body' | 'query';
@@ -38,6 +40,7 @@ const props = defineProps({
}, },
}); });
const router = useRouter();
const createLoading = ref(false); const createLoading = ref(false);
const generatedUrl = ref(''); const generatedUrl = ref('');
const generatedExpireAt = ref(''); const generatedExpireAt = ref('');
@@ -189,7 +192,15 @@ const createShare = async () => {
knowledgeId: props.knowledgeId, knowledgeId: props.knowledgeId,
}); });
if (res.errorCode === 0) { if (res.errorCode === 0) {
generatedUrl.value = res.data?.shareUrl || ''; const shareKey = String(res.data?.shareKey || '').trim();
generatedUrl.value = shareKey
? buildAbsoluteAppRouteUrl(
router.resolve({
name: 'KnowledgeShare',
query: { shareKey },
}).href,
)
: '';
generatedExpireAt.value = res.data?.expiresAt || ''; generatedExpireAt.value = res.data?.expiresAt || '';
ElMessage.success('已创建分享链接'); ElMessage.success('已创建分享链接');
} }

View File

@@ -14,7 +14,7 @@ import {getOptions, sortNodes} from '@easyflow/utils';
import {Tinyflow} from '@tinyflow-ai/vue'; import {Tinyflow} from '@tinyflow-ai/vue';
import {ArrowLeft, CircleCheck, Close, Promotion,} from '@element-plus/icons-vue'; import {ArrowLeft, CircleCheck, Close, Promotion,} from '@element-plus/icons-vue';
import {ElButton, ElDrawer, ElMessage, ElSkeleton,} from 'element-plus'; import {ElButton, ElDrawer, ElMessage, ElResult, ElSkeleton,} from 'element-plus';
import {api} from '#/api/request'; import {api} from '#/api/request';
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue'; import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
@@ -54,18 +54,7 @@ const { isDark } = usePreferences();
// vue // vue
onMounted(async () => { onMounted(async () => {
document.addEventListener('keydown', handleKeydown); document.addEventListener('keydown', handleKeydown);
await resolveSharedWorkflowId(); await initializeWorkflow();
if (!workflowId.value) {
return;
}
await Promise.all([
loadCustomNode(),
getLlmList(),
getKnowledgeList(),
getCodeEngineList(),
getWorkflowInfo(workflowId.value),
]);
showTinyFlow.value = true;
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
captureCurrentWorkflowDraft(); captureCurrentWorkflowDraft();
@@ -83,6 +72,7 @@ onDeactivated(() => {
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null); const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
const workflowId = ref(route.query.id); const workflowId = ref(route.query.id);
const workflowInfo = ref<any>({}); const workflowInfo = ref<any>({});
const initializationError = ref(false);
const runParams = ref<any>(null); const runParams = ref<any>(null);
const tinyFlowData = ref<any>(null); const tinyFlowData = ref<any>(null);
const llmList = ref<any>([]); const llmList = ref<any>([]);
@@ -109,6 +99,32 @@ async function resolveSharedWorkflowId() {
}, },
}); });
} }
async function initializeWorkflow() {
initializationError.value = false;
showTinyFlow.value = false;
try {
await resolveSharedWorkflowId();
if (!workflowId.value) {
return;
}
await Promise.all([
loadCustomNode(),
getLlmList(),
getKnowledgeList(),
getCodeEngineList(),
getWorkflowInfo(workflowId.value),
]);
showTinyFlow.value = true;
} catch (error) {
console.error('Workflow initialization failed:', error);
initializationError.value = true;
}
}
function backToWorkflowList() {
router.replace({ path: '/ai/workflow' });
}
const WORKFLOW_DRAFT_WRITE_DELAY = 320; const WORKFLOW_DRAFT_WRITE_DELAY = 320;
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined; let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
let pendingDraftContent: any = null; let pendingDraftContent: any = null;
@@ -870,9 +886,24 @@ function onAsyncExecute(info: any) {
</ElButton> </ElButton>
</div> </div>
</div> </div>
<ElResult
v-if="initializationError"
icon="error"
:title="$t('aiWorkflow.loadFailed')"
class="load-div"
>
<template #extra>
<ElButton @click="backToWorkflowList">
{{ $t('button.back') }}
</ElButton>
<ElButton type="primary" @click="initializeWorkflow">
{{ $t('aiWorkflow.reload') }}
</ElButton>
</template>
</ElResult>
<Tinyflow <Tinyflow
ref="tinyflowRef" ref="tinyflowRef"
v-if="showTinyFlow" v-else-if="showTinyFlow"
class="tiny-flow-container" class="tiny-flow-container"
:data="JSON.parse(JSON.stringify(tinyFlowData))" :data="JSON.parse(JSON.stringify(tinyFlowData))"
:theme="isDark ? 'dark' : 'light'" :theme="isDark ? 'dark' : 'light'"
@@ -941,7 +972,12 @@ function onAsyncExecute(info: any) {
</div> </div>
</div> </div>
</transition> </transition>
<ElSkeleton v-if="!showTinyFlow" class="load-div" :rows="5" animated /> <ElSkeleton
v-if="!showTinyFlow && !initializationError"
class="load-div"
:rows="5"
animated
/>
</div> </div>
</template> </template>

View File

@@ -62,6 +62,7 @@ import { $t } from '#/locales';
import { router } from '#/router'; import { router } from '#/router';
import { useDictStore } from '#/store'; import { useDictStore } from '#/store';
import { copyTextWithFeedback } from '#/utils/clipboard-feedback'; import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue'; import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason'; import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact'; import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact';
@@ -862,15 +863,14 @@ async function shareWorkflow(row: any) {
if (res.errorCode !== 0 || !res.data?.shareKey) { if (res.errorCode !== 0 || !res.data?.shareKey) {
return; return;
} }
const routeLocation = router.resolve({ const shareUrl = buildAbsoluteAppRouteUrl(
name: 'WorkflowDesign', router.resolve({
query: { name: 'WorkflowDesign',
shareKey: res.data.shareKey, query: {
}, shareKey: res.data.shareKey,
}); },
const shareUrl = }).href,
res.data.shareUrl || );
new URL(routeLocation.href, window.location.origin).toString();
await copyTextWithFeedback( await copyTextWithFeedback(
shareUrl, shareUrl,
$t('message.copySuccess'), $t('message.copySuccess'),

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { import {
isWorkflowShareRequest,
readWorkflowShareKey, readWorkflowShareKey,
resolveWorkflowShareWorkflowId, resolveWorkflowShareWorkflowId,
withWorkflowShareHeader, withWorkflowShareHeader,
@@ -28,7 +29,12 @@ describe('workflow share context', () => {
expect( expect(
withWorkflowShareHeader( withWorkflowShareHeader(
{ 'Accept-Language': 'zh-CN' }, { 'Accept-Language': 'zh-CN' },
'https://example.test/ai/workflow/design?id=1&shareKey=abc123', {
pageUrl:
'https://example.test/ai/workflow/design?id=1&shareKey=abc123',
requestMethod: 'GET',
requestUrl: '/api/v1/workflow/detail?id=1',
},
), ),
).toEqual({ ).toEqual({
'Accept-Language': 'zh-CN', 'Accept-Language': 'zh-CN',
@@ -40,13 +46,92 @@ describe('workflow share context', () => {
const headers = { 'Accept-Language': 'zh-CN' }; const headers = { 'Accept-Language': 'zh-CN' };
expect( expect(
withWorkflowShareHeader( withWorkflowShareHeader(headers, {
headers, pageUrl: 'https://example.test/ai/workflow/design?id=1',
'https://example.test/ai/workflow/design?id=1', requestMethod: 'GET',
), requestUrl: '/api/v1/workflow/detail?id=1',
}),
).toEqual(headers); ).toEqual(headers);
}); });
it('ignores a knowledge share key left before the active hash route', () => {
const pollutedUrl =
'https://example.test/flow/share/knowledge?shareKey=knowledge-key#/ai/workflow';
expect(readWorkflowShareKey(pollutedUrl)).toBeNull();
expect(
withWorkflowShareHeader(
{ 'Accept-Language': 'zh-CN' },
{
pageUrl: pollutedUrl,
requestMethod: 'POST',
requestUrl: '/api/v1/workflow/submitPublishApproval',
},
),
).toEqual({ 'Accept-Language': 'zh-CN' });
});
it('does not reuse an outer share key after entering workflow design', () => {
expect(
readWorkflowShareKey(
'https://example.test/flow/share/knowledge?shareKey=knowledge-key#/ai/workflow/design?id=1',
),
).toBeNull();
});
it('only attaches the share key to explicitly allowed workflow requests', () => {
const pageUrl =
'https://example.test/flow/#/ai/workflow/design?shareKey=workflow-key';
expect(
withWorkflowShareHeader(
{},
{
pageUrl,
requestMethod: 'GET',
requestUrl: '/api/v1/workflow/page',
},
),
).toEqual({});
expect(
withWorkflowShareHeader(
{},
{
pageUrl,
requestMethod: 'POST',
requestUrl: '/api/v1/bot/chat',
},
),
).toEqual({});
expect(
withWorkflowShareHeader(
{},
{
pageUrl,
requestMethod: 'POST',
requestUrl: '/api/v1/workflow/update',
},
),
).toEqual({
[WORKFLOW_SHARE_HEADER]: 'workflow-key',
});
});
it('matches only the workflow sharing endpoint whitelist', () => {
expect(
isWorkflowShareRequest(
'/flow/api/v1/workflow/submitPublishApproval',
'post',
),
).toBe(true);
expect(isWorkflowShareRequest('/flow/api/v1/workflow/page', 'get')).toBe(
false,
);
expect(isWorkflowShareRequest('/flow/api/v1/model/list', 'get')).toBe(
false,
);
});
it('resolves the workflow id for a shared URL', async () => { it('resolves the workflow id for a shared URL', async () => {
const resolve = vi.fn().mockResolvedValue('workflow-1'); const resolve = vi.fn().mockResolvedValue('workflow-1');
const onFailure = vi.fn(); const onFailure = vi.fn();