From 449b46b8634576ba6306db90c0271c6d11363771 Mon Sep 17 00:00:00 2001 From: "Dx." Date: Fri, 26 Jun 2026 21:46:44 +0800 Subject: [PATCH] fix(web): download markdown file-preview links as attachments (#38030) --- .../markdown-blocks/__tests__/link.spec.tsx | 51 +++++++++++++++++++ .../components/base/markdown-blocks/link.tsx | 20 ++++++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx index 686ed236e8a..fb73fc38704 100644 --- a/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx +++ b/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx @@ -170,6 +170,57 @@ describe('Link component', () => { expect(link).toHaveAttribute('rel', 'noopener noreferrer') }) + it('adds attachment mode to file-preview links by default', () => { + mockIsValidUrl.mockReturnValue(true) + + const node = { + properties: { + href: 'http://localhost:5001/files/123/file-preview?timestamp=1&nonce=2&sign=3', + }, + } + + render(doc.pdf) + + expect(screen.getByText('doc.pdf')).toHaveAttribute( + 'href', + 'http://localhost:5001/files/123/file-preview?timestamp=1&nonce=2&sign=3&as_attachment=true', + ) + }) + + it('does not duplicate attachment mode for file-preview links', () => { + mockIsValidUrl.mockReturnValue(true) + + const node = { + properties: { + href: 'http://localhost:5001/files/123/file-preview?timestamp=1&nonce=2&sign=3&as_attachment=true', + }, + } + + render(doc.pdf) + + expect(screen.getByText('doc.pdf')).toHaveAttribute( + 'href', + 'http://localhost:5001/files/123/file-preview?timestamp=1&nonce=2&sign=3&as_attachment=true', + ) + }) + + it('keeps protocol-relative file-preview links protocol-relative', () => { + mockIsValidUrl.mockReturnValue(true) + + const node = { + properties: { + href: '//localhost:5001/files/123/file-preview?timestamp=1#page', + }, + } + + render(doc.pdf) + + expect(screen.getByText('doc.pdf')).toHaveAttribute( + 'href', + '//localhost:5001/files/123/file-preview?timestamp=1&as_attachment=true#page', + ) + }) + // -------------------------- // NO HREF // -------------------------- diff --git a/web/app/components/base/markdown-blocks/link.tsx b/web/app/components/base/markdown-blocks/link.tsx index d9fab217d4b..cf7bdf6c08c 100644 --- a/web/app/components/base/markdown-blocks/link.tsx +++ b/web/app/components/base/markdown-blocks/link.tsx @@ -7,6 +7,19 @@ import * as React from 'react' import { useChatContext } from '@/app/components/base/chat/chat/context' import { isValidUrl } from './utils' +const filePreviewPathPattern = /\/files\/[^/?#]+\/file-preview(?:[?#]|$)/ +const asAttachmentParamPattern = /[?&]as_attachment=/ + +const withFilePreviewAttachment = (href: string) => { + if (!filePreviewPathPattern.test(href) || asAttachmentParamPattern.test(href)) + return href + + const url = new URL(href, 'http://dify.local') + url.searchParams.set('as_attachment', 'true') + + return href.startsWith('//') ? url.href.replace(url.protocol, '') : url.href +} + const Link = ({ node, children, ...props }: any) => { const { onSend } = useChatContext() const commonClassName = 'cursor-pointer underline decoration-primary-700! decoration-dashed' @@ -16,8 +29,9 @@ const Link = ({ node, children, ...props }: any) => { return onSend?.(hidden_text)} title={node.children[0]?.value || ''}>{node.children[0]?.value || ''} } else { - const href = props.href || node.properties?.href - if (href && /^#[\w-]+$/.test(href.toString())) { + const rawHref = props.href || node.properties?.href + const href = rawHref?.toString() + if (href && /^#[\w-]+$/.test(href)) { const handleClick = (e: React.MouseEvent) => { e.preventDefault() // scroll to target element if exists within the answer container @@ -36,7 +50,7 @@ const Link = ({ node, children, ...props }: any) => { if (!href || !isValidUrl(href)) return {children} - return {children || 'Download'} + return {children || 'Download'} } }