diff --git a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx index 2e0fb7107d983..d57325334098e 100644 --- a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx @@ -6,7 +6,7 @@ import { MessageGenericPreviewTitle, MessageGenericPreviewDescription, } from '@rocket.chat/fuselage'; -import { useMediaUrl } from '@rocket.chat/ui-contexts'; +import { useMediaUrl, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useId } from 'react'; import type { UIEvent } from 'react'; import { useTranslation } from 'react-i18next'; @@ -15,6 +15,7 @@ import { getFileExtension } from '../../../../../../lib/utils/getFileExtension'; import { forAttachmentDownload, registerDownloadForUid } from '../../../../../hooks/useDownloadFromServiceWorker'; import MessageCollapsible from '../../../MessageCollapsible'; import AttachmentSize from '../structure/AttachmentSize'; +import { useOpenEncryptedPdf } from './hooks/useOpenEncryptedPdf'; const openDocumentViewer = window.RocketChatDesktop?.openDocumentViewer; @@ -31,26 +32,38 @@ const GenericFileAttachment = ({ const getURL = useMediaUrl(); const uid = useId(); const { t } = useTranslation(); + const openEncryptedPdf = useOpenEncryptedPdf(); + const dispatchToastMessage = useToastMessageDispatch(); - const handleTitleClick = (event: UIEvent): void => { + const handleTitleClick = async (event: UIEvent): Promise => { if (!link) { return; } - if (openDocumentViewer && format === 'PDF') { - event.preventDefault(); + const isEncrypted = link.includes('/file-decrypt/'); - const url = new URL(getURL(link), window.location.origin); - url.searchParams.set('contentDisposition', 'inline'); - openDocumentViewer(url.toString(), format, ''); - return; - } + try { + if (format === 'PDF' && openDocumentViewer) { + event.preventDefault(); + + if (isEncrypted) { + await openEncryptedPdf(link, title, size, format, openDocumentViewer); + return; + } - if (link.includes('/file-decrypt/')) { - event.preventDefault(); + const url = new URL(getURL(link), window.location.origin); + url.searchParams.set('contentDisposition', 'inline'); + openDocumentViewer(url.toString(), format, ''); + return; + } - registerDownloadForUid(uid, t, title); - forAttachmentDownload(uid, link); + if (isEncrypted) { + event.preventDefault(); + registerDownloadForUid(uid, t, title); + forAttachmentDownload(uid, link); + } + } catch (error) { + dispatchToastMessage({ type: 'error', message: t('FileUpload_Error_Trying_To_Open_File') }); } }; diff --git a/apps/meteor/client/components/message/content/attachments/file/hooks/useOpenEncryptedPdf.spec.tsx b/apps/meteor/client/components/message/content/attachments/file/hooks/useOpenEncryptedPdf.spec.tsx new file mode 100644 index 0000000000000..11de6f8ec0afa --- /dev/null +++ b/apps/meteor/client/components/message/content/attachments/file/hooks/useOpenEncryptedPdf.spec.tsx @@ -0,0 +1,322 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +import { useOpenEncryptedPdf } from './useOpenEncryptedPdf'; +import { forAttachmentDownload, registerDownloadForUid } from '../../../../../../hooks/useDownloadFromServiceWorker'; + +jest.mock('../../../../../../hooks/useDownloadFromServiceWorker', () => ({ + forAttachmentDownload: jest.fn(), + registerDownloadForUid: jest.fn(), +})); + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useMediaUrl: () => (url: string) => url, +})); + +const mockForAttachmentDownload = forAttachmentDownload as jest.MockedFunction; +const mockRegisterDownloadForUid = registerDownloadForUid as jest.MockedFunction; + +const mockAbort = jest.fn(); +const mockAbortController = jest.fn(() => { + const signal = { aborted: false }; + return { + abort: jest.fn(() => { + mockAbort(); + signal.aborted = true; + }), + signal, + }; +}); + +describe('useOpenEncryptedPdf', () => { + const testBlob = new Blob(['content'], { type: 'application/pdf' }); + const title = 'My PDF'; + const link = '/file-decrypt/encrypted-pdf.pdf'; + const format = 'PDF'; + const allowedSize = 5 * 1024 * 1024; + + let mockOpenDocumentViewer: jest.Mock; + let mockFetch: jest.Mock; + let mockRevokeObjectURL: jest.Mock; + let mockCreateObjectURL: jest.Mock; + + const originalFetch = global.fetch; + const originalCreateObjectURL = global.URL.createObjectURL; + const originalRevokeObjectURL = global.URL.revokeObjectURL; + const originalAbortController = global.AbortController; + + beforeEach(() => { + jest.clearAllMocks(); + + window.RocketChatDesktop = { + getE2ePdfPreviewSizeLimit: jest.fn(() => 15), + } as any; + + mockOpenDocumentViewer = jest.fn(); + + // Mock fetch + mockFetch = jest.fn(); + global.fetch = mockFetch; + + // Mock URL methods + mockCreateObjectURL = jest.fn(() => `blob:mock-url-${Math.random()}`); + mockRevokeObjectURL = jest.fn(); + global.URL.createObjectURL = mockCreateObjectURL; + global.URL.revokeObjectURL = mockRevokeObjectURL; + + // Mock AbortController + global.AbortController = mockAbortController as any; + }); + + afterEach(() => { + jest.restoreAllMocks(); + global.fetch = originalFetch; + global.URL.createObjectURL = originalCreateObjectURL; + global.URL.revokeObjectURL = originalRevokeObjectURL; + global.AbortController = originalAbortController; + delete (window as any).RocketChatDesktop; + }); + + describe('file size is not within the limit', () => { + it('should download file if it exceeds the preview size limit', async () => { + const { result } = renderHook(() => useOpenEncryptedPdf(), { + wrapper: mockAppRoot().build(), + }); + + const exceededSize = 20 * 1024 * 1024; // 20 MB (exceeds 15 MB limit) + + await act(async () => { + await result.current(link, title, exceededSize, format, mockOpenDocumentViewer); + }); + + expect(mockRegisterDownloadForUid).toHaveBeenCalled(); + expect(mockForAttachmentDownload).toHaveBeenCalledWith(expect.any(String), link); + expect(mockOpenDocumentViewer).not.toHaveBeenCalled(); + }); + + it('should download file if size is undefined', async () => { + const { result } = renderHook(() => useOpenEncryptedPdf(), { + wrapper: mockAppRoot().build(), + }); + + await act(async () => { + await result.current(link, title, undefined, format, mockOpenDocumentViewer); + }); + + expect(mockRegisterDownloadForUid).toHaveBeenCalled(); + expect(mockForAttachmentDownload).toHaveBeenCalledWith(expect.any(String), link); + expect(mockOpenDocumentViewer).not.toHaveBeenCalled(); + }); + }); + + describe('file size is within the limit', () => { + it('should fetch and open PDF', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + blob: jest.fn().mockResolvedValueOnce(testBlob), + }); + + const { result } = renderHook(() => useOpenEncryptedPdf(), { + wrapper: mockAppRoot().build(), + }); + + await act(async () => { + await result.current(link, title, allowedSize, format, mockOpenDocumentViewer); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith(link, expect.objectContaining({ signal: expect.any(Object) })); + expect(mockCreateObjectURL).toHaveBeenCalledWith(testBlob); + expect(mockOpenDocumentViewer).toHaveBeenCalledWith(expect.stringContaining('blob:'), format, title); + }); + }); + + it('should open the PDF viewer if title is undefined, falling back to empty string', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + blob: jest.fn().mockResolvedValueOnce(testBlob), + }); + + const { result } = renderHook(() => useOpenEncryptedPdf(), { + wrapper: mockAppRoot().build(), + }); + + await act(async () => { + await result.current(link, undefined, allowedSize, format, mockOpenDocumentViewer); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith(link, expect.objectContaining({ signal: expect.any(Object) })); + expect(mockCreateObjectURL).toHaveBeenCalledWith(testBlob); + expect(mockOpenDocumentViewer).toHaveBeenCalledWith(expect.stringContaining('blob:'), format, ''); + }); + }); + }); + + describe('when RocketChatDesktop is undefined', () => { + beforeEach(() => { + delete (window as any).RocketChatDesktop; + }); + + it('should fall back to 10MB limit and download if file exceeds it', async () => { + const { result } = renderHook(() => useOpenEncryptedPdf(), { + wrapper: mockAppRoot().build(), + }); + + const exceededSize = 11 * 1024 * 1024; // 11 MB + + await act(async () => { + await result.current(link, title, exceededSize, format, mockOpenDocumentViewer); + }); + + expect(mockRegisterDownloadForUid).toHaveBeenCalled(); + expect(mockForAttachmentDownload).toHaveBeenCalledWith(expect.any(String), link); + expect(mockOpenDocumentViewer).not.toHaveBeenCalled(); + }); + + it('should fall back to 10MB limit and open PDF if size is within limit', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + blob: jest.fn().mockResolvedValueOnce(testBlob), + }); + + const { result } = renderHook(() => useOpenEncryptedPdf(), { + wrapper: mockAppRoot().build(), + }); + + await act(async () => { + await result.current(link, title, allowedSize, format, mockOpenDocumentViewer); + }); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith(link, expect.objectContaining({ signal: expect.any(Object) })); + expect(mockCreateObjectURL).toHaveBeenCalledWith(testBlob); + expect(mockOpenDocumentViewer).toHaveBeenCalledWith(expect.stringContaining('blob:'), format, title); + }); + }); + }); + + describe('blob URL management', () => { + it('should revoke previous blob URL before creating a new one', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + blob: jest.fn().mockResolvedValueOnce(testBlob), + }) + .mockResolvedValueOnce({ + ok: true, + blob: jest.fn().mockResolvedValueOnce(testBlob), + }); + + const { result } = renderHook(() => useOpenEncryptedPdf(), { + wrapper: mockAppRoot().build(), + }); + + // First call + await act(async () => { + await result.current(link, title, allowedSize, format, mockOpenDocumentViewer); + }); + + await waitFor(() => { + expect(mockCreateObjectURL).toHaveBeenCalledTimes(1); + }); + + // Second call + await act(async () => { + await result.current(link, title, allowedSize, format, mockOpenDocumentViewer); + }); + + await waitFor(() => { + expect(mockRevokeObjectURL).toHaveBeenCalled(); + expect(mockCreateObjectURL).toHaveBeenCalledTimes(2); + }); + }); + + it('should revoke blob URL on component unmount', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + blob: jest.fn().mockResolvedValueOnce(testBlob), + }); + + const { result, unmount } = renderHook(() => useOpenEncryptedPdf(), { + wrapper: mockAppRoot().build(), + }); + + await act(async () => { + await result.current(link, title, allowedSize, format, mockOpenDocumentViewer); + }); + + await waitFor(() => { + expect(mockCreateObjectURL).toHaveBeenCalled(); + }); + + unmount(); + + expect(mockRevokeObjectURL).toHaveBeenCalled(); + }); + }); + + describe('fetch failure handling', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('should throw error if fetch response is not ok', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + const { result } = renderHook(() => useOpenEncryptedPdf(), { + wrapper: mockAppRoot().build(), + }); + + await expect(async () => { + await result.current(link, title, allowedSize, format, mockOpenDocumentViewer); + }).rejects.toThrow('Failed to fetch encrypted PDF: 404'); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error opening preview of encrypted PDF', + expect.objectContaining({ message: expect.stringContaining('Failed to fetch encrypted PDF: 404') }), + ); + }); + }); + }); + + describe('concurrent requests', () => { + it('should ignore blob from cancelled request', async () => { + let resolveFirstFetch: any; + + mockFetch + .mockImplementationOnce(() => { + return new Promise((resolve) => { + resolveFirstFetch = resolve; + }); + }) + .mockImplementationOnce(() => { + return Promise.resolve({ ok: true, blob: jest.fn().mockResolvedValueOnce(testBlob) }); + }); + + const { result } = renderHook(() => useOpenEncryptedPdf(), { wrapper: mockAppRoot().build() }); + + const promise1 = result.current(link, title, allowedSize, format, mockOpenDocumentViewer); + const promise2 = result.current(link, title, allowedSize, format, mockOpenDocumentViewer); + + await act(async () => { + resolveFirstFetch({ ok: true, blob: jest.fn().mockResolvedValue(testBlob) }); + await Promise.all([promise1, promise2]); + }); + + expect(mockOpenDocumentViewer).toHaveBeenCalledTimes(1); + expect(mockAbort).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/meteor/client/components/message/content/attachments/file/hooks/useOpenEncryptedPdf.tsx b/apps/meteor/client/components/message/content/attachments/file/hooks/useOpenEncryptedPdf.tsx new file mode 100644 index 0000000000000..cae87c48cf865 --- /dev/null +++ b/apps/meteor/client/components/message/content/attachments/file/hooks/useOpenEncryptedPdf.tsx @@ -0,0 +1,77 @@ +import { useMediaUrl } from '@rocket.chat/ui-contexts'; +import { useId, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { forAttachmentDownload, registerDownloadForUid } from '../../../../../../hooks/useDownloadFromServiceWorker'; + +export const useOpenEncryptedPdf = () => { + const getURL = useMediaUrl(); + const pdfPreviewSizeLimit = window.RocketChatDesktop?.getE2ePdfPreviewSizeLimit?.() ?? 10; + const pdfPreviewSizeLimitInBytes = pdfPreviewSizeLimit * 1024 * 1024; + const uid = useId(); + const { t } = useTranslation(); + + const blobUrlRef = useRef(undefined); + const abortControllerRef = useRef(null); + + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = undefined; + } + }; + }, []); + + const openEncryptedPdf = async ( + link: string, + title: string | undefined, + size: number | undefined, + format: string, + openDocumentViewer: (url: string, format: string, options: any) => void, + ) => { + if (size === undefined || size > pdfPreviewSizeLimitInBytes) { + registerDownloadForUid(uid, t, title); + forAttachmentDownload(uid, link); + return; + } + + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = undefined; + } + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + const response = await fetch(getURL(link), { + signal: abortController.signal, + }); + if (!response.ok) { + throw new Error(`Failed to fetch encrypted PDF: ${response.status}`); + } + const blob = await response.blob(); + if (abortController.signal.aborted || abortControllerRef.current !== abortController) { + return; + } + const blobUrl = URL.createObjectURL(blob); + blobUrlRef.current = blobUrl; + openDocumentViewer(blobUrl, format, title ?? ''); + } catch (error: any) { + if (error.name !== 'AbortError') { + console.error('Error opening preview of encrypted PDF', error); + throw error; + } + } + }; + + return openEncryptedPdf; +}; diff --git a/packages/desktop-api/src/index.ts b/packages/desktop-api/src/index.ts index 9d10c1f89d1c5..2d7b18c5cabb9 100644 --- a/packages/desktop-api/src/index.ts +++ b/packages/desktop-api/src/index.ts @@ -63,4 +63,6 @@ export interface IRocketChatDesktop { setUserToken: (token: string, userId: string) => void; openDocumentViewer: (url: string, format: string, options: any) => void; reloadServer: () => void; + getE2ePdfPreviewSizeLimit: () => number; + openInBrowser: (url: string) => void; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 3d361dc9ac675..a876ba8468531 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2316,6 +2316,7 @@ "FileUpload_Enabled": "File Uploads Enabled", "FileUpload_Enabled_Direct": "File Uploads Enabled in Direct Messages ", "FileUpload_Error": "File Upload Error", + "FileUpload_Error_Trying_To_Open_File": "Error trying to open file", "FileUpload_FileSystemPath": "System Path", "FileUpload_File_Empty": "File empty", "FileUpload_Canceled": "Upload canceled",