fix: 修复分享链接路由与登录回跳
- 统一生成包含部署基路径和 Hash 路由的知识库、工作流分享地址 - 未登录访问分享页时保留目标地址,并在登录成功后自动回跳 - 限定分享密钥作用域并补充失效、加载失败及回归测试
This commit is contained in:
@@ -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<string, any>) {
|
||||
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'),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
@@ -8,6 +9,7 @@ import { ElButton, ElCard, ElIcon, ElInput, ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||
|
||||
type EndpointParam = {
|
||||
location: 'body' | 'query';
|
||||
@@ -38,6 +40,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const createLoading = ref(false);
|
||||
const generatedUrl = ref('');
|
||||
const generatedExpireAt = ref('');
|
||||
@@ -189,7 +192,15 @@ const createShare = async () => {
|
||||
knowledgeId: props.knowledgeId,
|
||||
});
|
||||
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 || '';
|
||||
ElMessage.success('已创建分享链接');
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {getOptions, sortNodes} from '@easyflow/utils';
|
||||
import {Tinyflow} from '@tinyflow-ai/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 CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
|
||||
@@ -54,18 +54,7 @@ const { isDark } = usePreferences();
|
||||
// vue
|
||||
onMounted(async () => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
await resolveSharedWorkflowId();
|
||||
if (!workflowId.value) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
loadCustomNode(),
|
||||
getLlmList(),
|
||||
getKnowledgeList(),
|
||||
getCodeEngineList(),
|
||||
getWorkflowInfo(workflowId.value),
|
||||
]);
|
||||
showTinyFlow.value = true;
|
||||
await initializeWorkflow();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
captureCurrentWorkflowDraft();
|
||||
@@ -83,6 +72,7 @@ onDeactivated(() => {
|
||||
const tinyflowRef = ref<InstanceType<typeof Tinyflow> | null>(null);
|
||||
const workflowId = ref(route.query.id);
|
||||
const workflowInfo = ref<any>({});
|
||||
const initializationError = ref(false);
|
||||
const runParams = ref<any>(null);
|
||||
const tinyFlowData = ref<any>(null);
|
||||
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;
|
||||
let draftWriteTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pendingDraftContent: any = null;
|
||||
@@ -870,9 +886,24 @@ function onAsyncExecute(info: any) {
|
||||
</ElButton>
|
||||
</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
|
||||
ref="tinyflowRef"
|
||||
v-if="showTinyFlow"
|
||||
v-else-if="showTinyFlow"
|
||||
class="tiny-flow-container"
|
||||
:data="JSON.parse(JSON.stringify(tinyFlowData))"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
@@ -941,7 +972,12 @@ function onAsyncExecute(info: any) {
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<ElSkeleton v-if="!showTinyFlow" class="load-div" :rows="5" animated />
|
||||
<ElSkeleton
|
||||
v-if="!showTinyFlow && !initializationError"
|
||||
class="load-div"
|
||||
:rows="5"
|
||||
animated
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { useDictStore } from '#/store';
|
||||
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||
import { buildAbsoluteAppRouteUrl } from '#/utils/share-route-context';
|
||||
import AiResourceCornerMeta from '#/views/ai/shared/AiResourceCornerMeta.vue';
|
||||
import { confirmPublishSubmission } from '#/views/ai/shared/approval-application-reason';
|
||||
import { buildOfflineImpactMessage } from '#/views/ai/shared/offline-impact';
|
||||
@@ -862,15 +863,14 @@ async function shareWorkflow(row: any) {
|
||||
if (res.errorCode !== 0 || !res.data?.shareKey) {
|
||||
return;
|
||||
}
|
||||
const routeLocation = router.resolve({
|
||||
name: 'WorkflowDesign',
|
||||
query: {
|
||||
shareKey: res.data.shareKey,
|
||||
},
|
||||
});
|
||||
const shareUrl =
|
||||
res.data.shareUrl ||
|
||||
new URL(routeLocation.href, window.location.origin).toString();
|
||||
const shareUrl = buildAbsoluteAppRouteUrl(
|
||||
router.resolve({
|
||||
name: 'WorkflowDesign',
|
||||
query: {
|
||||
shareKey: res.data.shareKey,
|
||||
},
|
||||
}).href,
|
||||
);
|
||||
await copyTextWithFeedback(
|
||||
shareUrl,
|
||||
$t('message.copySuccess'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
isWorkflowShareRequest,
|
||||
readWorkflowShareKey,
|
||||
resolveWorkflowShareWorkflowId,
|
||||
withWorkflowShareHeader,
|
||||
@@ -28,7 +29,12 @@ describe('workflow share context', () => {
|
||||
expect(
|
||||
withWorkflowShareHeader(
|
||||
{ '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({
|
||||
'Accept-Language': 'zh-CN',
|
||||
@@ -40,13 +46,92 @@ describe('workflow share context', () => {
|
||||
const headers = { 'Accept-Language': 'zh-CN' };
|
||||
|
||||
expect(
|
||||
withWorkflowShareHeader(
|
||||
headers,
|
||||
'https://example.test/ai/workflow/design?id=1',
|
||||
),
|
||||
withWorkflowShareHeader(headers, {
|
||||
pageUrl: 'https://example.test/ai/workflow/design?id=1',
|
||||
requestMethod: 'GET',
|
||||
requestUrl: '/api/v1/workflow/detail?id=1',
|
||||
}),
|
||||
).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 () => {
|
||||
const resolve = vi.fn().mockResolvedValue('workflow-1');
|
||||
const onFailure = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user