From ca8742821620bc612bcc6a69cd4d3ce3e3b84b9d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 13 Jun 2026 01:44:03 +0300 Subject: [PATCH] fix: harden file previews and downloads --- .../components/layout/SidebarFilesTree.tsx | 5 +- .../ui/src/components/views/FilesView.tsx | 204 +++++++++++++----- packages/web/server/lib/fs/DOCUMENTATION.md | 1 + packages/web/server/lib/fs/routes.js | 93 ++++++++ packages/web/server/lib/ui-auth/ui-auth.js | 19 +- .../web/server/lib/ui-auth/ui-auth.test.js | 31 +++ packages/web/src/api/files.ts | 11 +- 7 files changed, 305 insertions(+), 59 deletions(-) diff --git a/packages/ui/src/components/layout/SidebarFilesTree.tsx b/packages/ui/src/components/layout/SidebarFilesTree.tsx index e27e0aaf..94956d5a 100644 --- a/packages/ui/src/components/layout/SidebarFilesTree.tsx +++ b/packages/ui/src/components/layout/SidebarFilesTree.tsx @@ -221,7 +221,10 @@ const FileRow: React.FC = ({ {!isDir && downloadFile && ( { e.stopPropagation(); - void downloadFile(node.path); + void downloadFile(node.path).catch((error) => { + console.error('Download failed:', error); + toast.error(t('sidebarFilesTree.toast.operationFailed')); + }); }}> {t('sidebarFilesTree.menu.save')} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 5277fcb8..18dff29d 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -358,13 +358,13 @@ const isJsonFile = (path: string): boolean => { return ext === 'json' || ext === 'jsonc' || ext === 'json5' || ext === 'geojson'; }; -const isHtmlFile = (path: string): boolean => { - if (!path) return false; - const ext = path.toLowerCase().split('.').pop(); - return ext === 'html' || ext === 'htm'; -}; - -interface FileRowProps { +const isHtmlFile = (path: string): boolean => { + if (!path) return false; + const ext = path.toLowerCase().split('.').pop(); + return ext === 'html' || ext === 'htm'; +}; + +interface FileRowProps { node: FileNode; root: string; isExpanded: boolean; @@ -478,7 +478,10 @@ const FileRow: React.FC = ({ {!isDir && downloadFile && ( { e.stopPropagation(); - void downloadFile(node.path); + void downloadFile(node.path).catch((error) => { + console.error('Download failed:', error); + toast.error(t('sidebarFilesTree.toast.operationFailed')); + }); }}> {t('sidebarFilesTree.menu.save')} @@ -769,10 +772,28 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }; }, []); - const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]); - const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]); + const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]); + const effectiveSelectedPath = React.useMemo(() => { + if (selectedPath) { + const comparableSelected = toComparablePath(selectedPath); + if (openPaths.some((path) => toComparablePath(path) === comparableSelected)) { + return selectedPath; + } + } + return openPaths[0] ?? null; + }, [openPaths, selectedPath]); const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]); const selectedFilePath = selectedFile?.path ?? ''; + + React.useEffect(() => { + if (!root || !selectedPath) return; + const comparableSelected = toComparablePath(selectedPath); + const selectedIsOpen = openPaths.some((path) => toComparablePath(path) === comparableSelected); + if (!selectedIsOpen) { + setSelectedPath(root, openPaths[0] ?? null); + } + }, [openPaths, root, selectedPath, setSelectedPath]); + const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root)); const selectedOutsideFileGrant = selectedFileIsOutsideWorkspace ? getOutsideFileGrant(selectedFilePath) : undefined; const selectedFileReadOptions = React.useMemo( @@ -825,7 +846,9 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [fileLoading, setFileLoading] = React.useState(false); const [fileError, setFileError] = React.useState(null); const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); + const desktopImageBlobUrlRef = React.useRef(''); const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState(''); + const [htmlAssetAuthReadyKey, setHtmlAssetAuthReadyKey] = React.useState(''); const [pdfAssetAuthReadyKey, setPdfAssetAuthReadyKey] = React.useState(''); const [loadedFilePath, setLoadedFilePath] = React.useState(null); @@ -2835,6 +2858,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf ? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}` : ''; + + const htmlAssetAuthKey = selectedFile?.path && isHtml && htmlViewMode === 'preview' && !runtime.isVSCode + ? selectedFile.path + : ''; React.useEffect(() => { if (!imageAssetAuthKey) { @@ -2857,6 +2884,33 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey); + React.useEffect(() => { + if (!htmlAssetAuthKey) { + setHtmlAssetAuthReadyKey(''); + return; + } + + let cancelled = false; + setHtmlAssetAuthReadyKey(''); + void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl()) + .then((token) => { + if (!cancelled && token) { + setHtmlAssetAuthReadyKey(htmlAssetAuthKey); + } + }) + .catch((error) => { + if (!cancelled) { + setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed')); + } + }); + + return () => { + cancelled = true; + }; + }, [htmlAssetAuthKey, t]); + + const isHtmlAssetAuthLoading = Boolean(htmlAssetAuthKey && htmlAssetAuthReadyKey !== htmlAssetAuthKey); + React.useEffect(() => { if (!pdfAssetAuthKey) { setPdfAssetAuthReadyKey(''); @@ -2918,21 +2972,45 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { React.useEffect(() => { let cancelled = false; - const resolveDesktopImage = async () => { - if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) { - setDesktopImageSrc(''); - return; - } - - setFileError(null); - - const srcPromise = files.readFileBinary - ? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl) - : Promise.resolve(getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { - path: selectedFile.path, - allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, - outsideFileGrant: selectedFileReadOptions.outsideFileGrant, - })); + const resolveDesktopImage = async () => { + if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) { + if (desktopImageBlobUrlRef.current) { + URL.revokeObjectURL(desktopImageBlobUrlRef.current); + desktopImageBlobUrlRef.current = ''; + } + setDesktopImageSrc(''); + return; + } + + setFileError(null); + + if (desktopImageBlobUrlRef.current) { + URL.revokeObjectURL(desktopImageBlobUrlRef.current); + desktopImageBlobUrlRef.current = ''; + } + + const srcPromise = files.readFileBinary + ? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl) + : (async () => { + const response = await runtimeFetch('/api/fs/raw', { + query: { + path: selectedFile.path, + allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined, + outsideFileGrant: selectedFileReadOptions.outsideFileGrant, + }, + }); + if (!response.ok) { + throw new Error(t('filesView.error.readFileFailed')); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + if (cancelled) { + URL.revokeObjectURL(url); + return ''; + } + desktopImageBlobUrlRef.current = url; + return url; + })(); await srcPromise .then((src) => { @@ -2940,11 +3018,15 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { setDesktopImageSrc(src); setLoadedFilePath(selectedFile.path); } - }) - .catch((error) => { - if (!cancelled) { - setDesktopImageSrc(''); - setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed')); + }) + .catch((error) => { + if (desktopImageBlobUrlRef.current) { + URL.revokeObjectURL(desktopImageBlobUrlRef.current); + desktopImageBlobUrlRef.current = ''; + } + if (!cancelled) { + setDesktopImageSrc(''); + setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed')); setLoadedFilePath(null); } }) @@ -2957,10 +3039,19 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { void resolveDesktopImage(); - return () => { - cancelled = true; - }; - }, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]); + return () => { + cancelled = true; + }; + }, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]); + + React.useEffect(() => { + return () => { + if (desktopImageBlobUrlRef.current) { + URL.revokeObjectURL(desktopImageBlobUrlRef.current); + desktopImageBlobUrlRef.current = ''; + } + }; + }, []); const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []); @@ -3353,7 +3444,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { size="sm" onClick={() => { const fn = files.downloadFile; - if (fn) void fn(selectedFile.path); + if (fn) void fn(selectedFile.path).catch((error) => { + console.error('Download failed:', error); + toast.error(t('sidebarFilesTree.toast.operationFailed')); + }); }} className="size-6 p-0 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent" title={t('filesView.editor.saveFile')} @@ -3702,22 +3796,30 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { /> - ) : selectedFile && isHtml && htmlViewMode === 'preview' ? ( -
-