From 7301f767ea6d3458976fb98ca906d5abdeb2e5ed Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Tue, 14 Jan 2025 11:34:43 -0500 Subject: [PATCH] refactor(renterd): native direct file downloads --- .changeset/fluffy-pumas-float.md | 5 + .changeset/four-apes-work.md | 5 + apps/renterd-e2e/src/specs/files.spec.ts | 11 +- .../Files/FileContextMenu/index.tsx | 9 +- apps/renterd/components/TransfersBar.tsx | 87 +------ .../contexts/filesManager/downloads.tsx | 227 +++++------------- apps/renterd/contexts/filesManager/index.tsx | 7 - 7 files changed, 97 insertions(+), 254 deletions(-) create mode 100644 .changeset/fluffy-pumas-float.md create mode 100644 .changeset/four-apes-work.md diff --git a/.changeset/fluffy-pumas-float.md b/.changeset/fluffy-pumas-float.md new file mode 100644 index 000000000..520d38141 --- /dev/null +++ b/.changeset/fluffy-pumas-float.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The transfers bar no longer includes a download list. diff --git a/.changeset/four-apes-work.md b/.changeset/four-apes-work.md new file mode 100644 index 000000000..09b42d65a --- /dev/null +++ b/.changeset/four-apes-work.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +Files are now downloaded directly in the browser with cookie based auth. diff --git a/apps/renterd-e2e/src/specs/files.spec.ts b/apps/renterd-e2e/src/specs/files.spec.ts index bdf5c5c62..fb0917327 100644 --- a/apps/renterd-e2e/src/specs/files.spec.ts +++ b/apps/renterd-e2e/src/specs/files.spec.ts @@ -111,7 +111,7 @@ test('can upload, rename, and delete files', async ({ page }) => { await deleteBucket(page, bucketName) }) -test('can upload and download a file', async ({ page }) => { +test('can upload and download a file', async ({ page, context }) => { const bucketName = 'files-test' const fileName = 'sample.txt' const filePath = `${bucketName}/${fileName}` @@ -127,11 +127,16 @@ test('can upload and download a file', async ({ page }) => { await fileInList(page, filePath) // Download. + const pagePromise = context.waitForEvent('page') await openFileContextMenu(page, filePath) - await page.getByRole('menuitem', { name: 'Download file' }).click() await expect( - page.getByRole('button', { name: 'Active downloads' }) + page.getByRole('menuitem', { name: 'Download file' }) ).toBeVisible() + await page.getByRole('menuitem', { name: 'Download file' }).click() + const newPage = await pagePromise + expect(newPage.url()).toContain( + '/api/worker/object/sample.txt?bucket=files-test' + ) }) test('can rename and delete a directory with contents', async ({ page }) => { diff --git a/apps/renterd/components/Files/FileContextMenu/index.tsx b/apps/renterd/components/Files/FileContextMenu/index.tsx index 89257359d..375e353e1 100644 --- a/apps/renterd/components/Files/FileContextMenu/index.tsx +++ b/apps/renterd/components/Files/FileContextMenu/index.tsx @@ -22,6 +22,7 @@ import { CopyMetadataMenuItem } from './CopyMetadataMenuItem' import { getFilename } from '../../../lib/paths' import { useDialog } from '../../../contexts/dialog' import { useFilesManager } from '../../../contexts/filesManager' +import { useDownloads } from '../../../contexts/filesManager/downloads' type Props = { path: string @@ -30,8 +31,8 @@ type Props = { } export function FileContextMenu({ trigger, path, contentProps }: Props) { - const { downloadFiles, getFileUrl, navigateToModeSpecificFiltering } = - useFilesManager() + const { navigateToModeSpecificFiltering } = useFilesManager() + const { getAuthenticatedFileUrl, getFileUrl, downloadFiles } = useDownloads() const deleteFile = useFileDelete() const { openDialog } = useDialog() @@ -114,7 +115,7 @@ export function FileContextMenu({ trigger, path, contentProps }: Props) { { - copyToClipboard(getFileUrl(path, false), 'file URL') + copyToClipboard(getFileUrl(path), 'file URL') }} > @@ -125,7 +126,7 @@ export function FileContextMenu({ trigger, path, contentProps }: Props) { { copyToClipboardCustom({ - text: getFileUrl(path, true), + text: getAuthenticatedFileUrl(path), title: 'Copied authenticated file URL to clipboard', body: ( <> diff --git a/apps/renterd/components/TransfersBar.tsx b/apps/renterd/components/TransfersBar.tsx index 69acc0eb5..78a7443e8 100644 --- a/apps/renterd/components/TransfersBar.tsx +++ b/apps/renterd/components/TransfersBar.tsx @@ -1,39 +1,31 @@ -import { - Button, - Panel, - ScrollArea, - Text, - AppDockedControl, -} from '@siafoundation/design-system' -import { Download16, Subtract24, Upload16 } from '@siafoundation/react-icons' -import { useState } from 'react' +import { Button, AppDockedControl } from '@siafoundation/design-system' +import { Upload16 } from '@siafoundation/react-icons' import { useFilesManager } from '../contexts/filesManager' import { useAppSettings } from '@siafoundation/react-core' -import { TransfersBarItem } from './TransfersBarItem' import { useUploads } from '../contexts/uploads' export function TransfersBar() { const { isUnlockedAndAuthedRoute } = useAppSettings() - const { downloadsList, downloadCancel, isViewingUploads, navigateToUploads } = - useFilesManager() + const { isViewingUploads, navigateToUploads } = useFilesManager() const { datasetPageTotal: uploadsPageTotal } = useUploads() - const [maximized, setMaximized] = useState(true) const isActiveUploads = !!uploadsPageTotal - const downloadCount = downloadsList.length - const isActiveDownloads = !!downloadCount if (!isUnlockedAndAuthedRoute) { return } - if (!isActiveUploads && !isActiveDownloads) { + if (!isActiveUploads) { return } - const controls = ( -
- {isActiveUploads && !isViewingUploads ? ( + if (isViewingUploads) { + return + } + + return ( + +
- ) : null} - {isActiveDownloads ? ( - - ) : null} -
+
+
) - - if (isActiveDownloads && maximized) { - return ( - -
- - - {isActiveDownloads ? ( - <> -
- - Active downloads ({downloadCount}) - - -
- {downloadsList.map((download) => ( - downloadCancel(download)} - abortTip="Cancel download" - /> - ))} - - ) : null} -
-
- {controls} -
-
- ) - } - - return {controls} } diff --git a/apps/renterd/contexts/filesManager/downloads.tsx b/apps/renterd/contexts/filesManager/downloads.tsx index 7d34daa55..b0a05c047 100644 --- a/apps/renterd/contexts/filesManager/downloads.tsx +++ b/apps/renterd/contexts/filesManager/downloads.tsx @@ -1,193 +1,88 @@ -import { triggerErrorToast, triggerToast } from '@siafoundation/design-system' +import { triggerErrorToast } from '@siafoundation/design-system' import { useAppSettings } from '@siafoundation/react-core' -import { useBuckets, useObjectDownloadFunc } from '@siafoundation/renterd-react' -import { throttle } from '@technically/lodash' -import { useCallback, useMemo, useState } from 'react' -import { - FullPath, - bucketAndKeyParamsFromPath, - getBucketFromPath, - getFilename, - getKeyFromPath, -} from '../../lib/paths' -import { ObjectData } from './types' +import { useAuthToken } from '@siafoundation/renterd-react' +import { useCallback } from 'react' +import { FullPath, bucketAndKeyParamsFromPath } from '../../lib/paths' import { workerObjectKeyRoute } from '@siafoundation/renterd-types' - -type DownloadProgress = ObjectData & { - controller: AbortController -} - -type DownloadProgressParams = Omit - -type DownloadsMap = Record +import { minutesInMilliseconds } from '@siafoundation/units' export function useDownloads() { - const buckets = useBuckets() - const download = useObjectDownloadFunc() - const [downloadsMap, setDownloadsMap] = useState({}) + const { settings } = useAppSettings() + const generateAuthToken = useAuthToken() - const initDownloadProgress = useCallback( - (obj: DownloadProgressParams) => { - setDownloadsMap((map) => { - const downloadProgress: DownloadProgress = { - id: obj.path, - path: obj.path, - key: obj.key, - bucket: obj.bucket, - name: obj.name, - size: obj.size, - loaded: obj.loaded, - isUploading: false, - controller: obj.controller, - type: 'file', - } - return { - ...map, - [obj.path]: downloadProgress, - } - }) + const getFileUrl = useCallback( + (path: FullPath, token?: string) => { + const { bucket, key } = bucketAndKeyParamsFromPath(path) + let workerPath = `${workerObjectKeyRoute.replace( + ':key', + key + )}?bucket=${bucket}` + if (token) { + workerPath += `&apikey=${token}` + } + // Parse settings.api if its set otherwise URL. + const origin = settings.api || location.origin + const scheme = origin.startsWith('https') ? 'https' : 'http' + const host = origin.replace('https://', '').replace('http://', '') + return `${scheme}://${host}/api${workerPath}` }, - [setDownloadsMap] + [settings] ) - const updateDownloadProgress = useCallback( - (obj: { path: string; loaded: number; size: number }) => { - setDownloadsMap((map) => { - if (!map[obj.path]) { - return map - } - return { - ...map, - [obj.path]: { - ...map[obj.path], - path: obj.path, - loaded: obj.loaded, - size: obj.size, - }, - } - }) + const getAuthenticatedFileUrl = useCallback( + (path: FullPath) => { + const { bucket, key } = bucketAndKeyParamsFromPath(path) + const workerPath = `${workerObjectKeyRoute.replace( + ':key', + key + )}?bucket=${bucket}` + // Parse settings.api if its set otherwise URL. + const origin = settings.api || location.origin + const scheme = origin.startsWith('https') ? 'https' : 'http' + const host = origin.replace('https://', '').replace('http://', '') + return `${scheme}://:${settings.password}@${host}/api${workerPath}` }, - [setDownloadsMap] + [settings] ) - const removeDownload = useCallback( - (path: string) => { - setDownloadsMap((downloads) => { - delete downloads[path] - return { - ...downloads, - } + const getAuthToken = useCallback(async () => { + const response = await generateAuthToken.post({ + params: { + validity: minutesInMilliseconds(1), + }, + }) + + if (response.error) { + triggerErrorToast({ + title: 'Failed to get auth token for download', + body: response.error, }) - }, - [setDownloadsMap] - ) + return null + } - const downloadCancel = useCallback((download: DownloadProgress) => { - download.controller.abort() - }, []) + return response.data?.token ?? null + }, [generateAuthToken]) const downloadFiles = useCallback( async (files: FullPath[]) => { - files.forEach(async (path) => { - let isDone = false - const bucketName = getBucketFromPath(path) - const key = getKeyFromPath(path) - const bucket = buckets.data?.find((b) => b.name === bucketName) - if (!bucket) { - triggerErrorToast({ title: 'Bucket not found', body: bucketName }) - return - } - const name = getFilename(path) + const token = await getAuthToken() - if (downloadsMap[path]) { - triggerErrorToast({ title: 'Already downloading file', body: path }) - return - } + if (!token) { + return + } - const controller = new AbortController() - const onDownloadProgress = throttle((e) => { - if (isDone) { - return - } - updateDownloadProgress({ - path, - loaded: e.loaded, - size: e.total, - }) - }, 2000) - initDownloadProgress({ - path, - key, - name, - bucket, - loaded: 0, - size: 1, - controller, - }) - const response = await download.get(name, { - params: bucketAndKeyParamsFromPath(path), - config: { - axios: { - onDownloadProgress, - signal: controller.signal, - }, - }, - }) - isDone = true - if (response.error) { - if (response.error === 'canceled') { - triggerToast({ title: 'File download canceled' }) - } else { - triggerErrorToast({ - title: 'Error downloading file', - body: response.error, - }) - } - removeDownload(path) - } else { - removeDownload(path) - } + files.forEach(async (path) => { + const url = getFileUrl(path, token) + // open download in new tab + window.open(url, '_blank') }) }, - [ - buckets.data, - download, - downloadsMap, - initDownloadProgress, - removeDownload, - updateDownloadProgress, - ] - ) - - const downloadsList = useMemo( - () => Object.entries(downloadsMap).map((d) => d[1]), - [downloadsMap] - ) - - const { settings } = useAppSettings() - const getFileUrl = useCallback( - (path: FullPath, authenticated: boolean) => { - const { bucket, key } = bucketAndKeyParamsFromPath(path) - const workerPath = `${workerObjectKeyRoute.replace( - ':key', - key - )}?bucket=${bucket}` - // Parse settings.api if its set otherwise URL - const origin = settings.api || location.origin - const scheme = origin.startsWith('https') ? 'https' : 'http' - const host = origin.replace('https://', '').replace('http://', '') - if (authenticated) { - return `${scheme}://:${settings.password}@${host}/api${workerPath}` - } - return `${scheme}://${host}/api${workerPath}` - }, - [settings] + [getAuthToken, getFileUrl] ) return { downloadFiles, - downloadsList, getFileUrl, - downloadCancel, + getAuthenticatedFileUrl, } } diff --git a/apps/renterd/contexts/filesManager/index.tsx b/apps/renterd/contexts/filesManager/index.tsx index ad728dc08..b97003339 100644 --- a/apps/renterd/contexts/filesManager/index.tsx +++ b/apps/renterd/contexts/filesManager/index.tsx @@ -19,7 +19,6 @@ import { pathSegmentsToPath, } from '../../lib/paths' import { useUploads } from './uploads' -import { useDownloads } from './downloads' import { useBuckets } from '@siafoundation/renterd-react' import { routes } from '../../config/routes' import useLocalStorageState from 'use-local-storage-state' @@ -97,8 +96,6 @@ function useFilesManagerMain() { const { uploadFiles, uploadsMap, uploadsList } = useUploads({ activeDirectoryPath, }) - const { downloadFiles, downloadsList, getFileUrl, downloadCancel } = - useDownloads() const isViewingBuckets = activeDirectory.length === 0 const isViewingRootOfABucket = activeDirectory.length === 1 @@ -226,9 +223,6 @@ function useFilesManagerMain() { uploadFiles, uploadsMap, uploadsList, - downloadFiles, - downloadsList, - downloadCancel, configurableColumns, visibleColumnIds, visibleColumns, @@ -249,7 +243,6 @@ function useFilesManagerMain() { resetFilters, sortDirection, resetDefaultColumnVisibility, - getFileUrl, activeExplorerMode, setExplorerModeDirectory, setExplorerModeFlat,