Skip to content

Commit 145e1f9

Browse files
authored
feat: use server zip downloads (#128)
1 parent 03db6ea commit 145e1f9

8 files changed

Lines changed: 190 additions & 163 deletions

File tree

‎components/object/list.tsx‎

Lines changed: 22 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { useObject } from "@/hooks/use-object"
3535
import { useBucket } from "@/hooks/use-bucket"
3636
import { useLocalStorage } from "@/hooks/use-local-storage"
3737
import { usePermissions } from "@/hooks/use-permissions"
38+
import { useApi } from "@/contexts/api-context"
3839
import { useMessage } from "@/lib/feedback/message"
3940
import { exportFile } from "@/lib/export-file"
4041
import { getContentType } from "@/lib/mime-types"
@@ -56,12 +57,15 @@ import {
5657
shouldShowDeleteAllVersions,
5758
type BucketVersioningState,
5859
} from "@/lib/object-delete"
60+
import {
61+
buildObjectZipDownloadPayload,
62+
normalizeObjectZipDownloadUrl,
63+
type CreateObjectZipDownloadResponse,
64+
} from "@/lib/object-zip-download"
5965
import { TaskStatsButton } from "@/components/tasks/stats-button"
6066
import { useAddDeleteKeys, useAddDeleteFolder, useTasks } from "@/contexts/task-context"
6167
import type { ColumnDef } from "@tanstack/react-table"
6268
import dayjs from "dayjs"
63-
import { saveAs } from "file-saver"
64-
import JSZip from "jszip"
6569

6670
interface ObjectRow {
6771
Key: string
@@ -95,7 +99,8 @@ export function ObjectList({
9599
}: ObjectListProps) {
96100
const { t } = useTranslation()
97101
const message = useMessage()
98-
const { listObject, getSignedUrl, mapAllFiles } = useObject(bucket)
102+
const api = useApi()
103+
const { listObject, getSignedUrl } = useObject(bucket)
99104
const { getBucketVersioning } = useBucket()
100105
const { canCapability } = usePermissions()
101106
const addDeleteKeys = useAddDeleteKeys()
@@ -416,12 +421,6 @@ export function ObjectList({
416421

417422
const checkedKeys = selectedRowIds
418423

419-
const computeRelativeKey = (key: string) => {
420-
if (!key) return ""
421-
const base = prefix || ""
422-
return base && key.startsWith(base) ? key.slice(base.length) : key
423-
}
424-
425424
const downloadMultiple = async () => {
426425
if (!checkedKeys.length) {
427426
message.warning(t("Please select at least one item"))
@@ -434,74 +433,28 @@ export function ObjectList({
434433
return
435434
}
436435

437-
const collecting = message.loading(t("Collecting files"), { duration: 0 })
438-
const allFiles: { key: string; relative: string }[] = []
436+
const loadingMsg = message.loading(t("Preparing download"), { duration: 0 })
439437

440438
try {
441-
for (const item of selectedRows) {
442-
if (item.type === "object") {
443-
allFiles.push({
444-
key: item.Key,
445-
relative: computeRelativeKey(item.Key),
446-
})
447-
} else if (item.type === "prefix") {
448-
await mapAllFiles(bucket, item.Key, (fileKey) => {
449-
allFiles.push({
450-
key: fileKey,
451-
relative: computeRelativeKey(fileKey),
452-
})
453-
})
454-
}
455-
}
456-
} catch (err) {
457-
collecting.destroy()
458-
message.error((err as Error)?.message ?? t("Download Failed"))
459-
return
460-
}
461-
462-
collecting.destroy()
463-
464-
if (!allFiles.length) {
465-
message.warning(t("No files to download"))
466-
return
467-
}
468-
469-
const zip = new JSZip()
470-
let finished = 0
471-
const destroyRef: { current: (() => void) | null } = { current: null }
472-
473-
const updateProgress = () => {
474-
if (destroyRef.current) destroyRef.current()
475-
const handle = message.loading(`${t("Downloading files")} ${Math.round((finished / allFiles.length) * 100)}%`, {
476-
duration: 0,
439+
const payload = buildObjectZipDownloadPayload({
440+
bucket,
441+
basePrefix: prefix,
442+
rows: selectedRows,
477443
})
478-
destroyRef.current = handle.destroy
479-
}
444+
const response = (await api.post("/object-zip-downloads", payload)) as CreateObjectZipDownloadResponse
480445

481-
updateProgress()
446+
if (!response.download_url) {
447+
throw new Error(t("Download Failed"))
448+
}
482449

483-
try {
484-
await Promise.all(
485-
allFiles.map(async ({ key, relative }) => {
486-
const url = await getSignedUrl(key)
487-
const response = await fetch(url)
488-
const blob = await response.blob()
489-
zip.file(relative || key, blob)
490-
finished++
491-
updateProgress()
492-
}),
493-
)
450+
window.location.href = normalizeObjectZipDownloadUrl(response.download_url, api.resolveUrl("/"))
451+
table.resetRowSelection()
452+
message.success(t("Download ready"))
494453
} catch (err) {
495-
if (destroyRef.current) destroyRef.current()
496454
message.error((err as Error)?.message ?? t("Download Failed"))
497-
return
455+
} finally {
456+
loadingMsg.destroy()
498457
}
499-
500-
if (destroyRef.current) destroyRef.current()
501-
502-
const content = await zip.generateAsync({ type: "blob" })
503-
saveAs(content, `${bucket ?? "download"}.zip`)
504-
message.success(t("Download ready"))
505458
}
506459

507460
const openDeleteDialog = (keys: string[]) => {

‎lib/api-client.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export class ApiClient {
4040
this.errorHandler = handler
4141
}
4242

43+
resolveUrl(url: string): string {
44+
return new URL(url, this.config?.baseUrl).toString()
45+
}
46+
4347
async request(url: string, options: RequestOptions = {}, parseJson: boolean = true) {
4448
url = this.config?.baseUrl ? joinURL(this.config?.baseUrl, url) : url
4549
options.headers = { ...this.config?.headers, ...options.headers }

‎lib/object-zip-download.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
function sanitizeZipFilename(value) {
2+
const cleaned = value
3+
.trim()
4+
.replace(/[\\/:*?"<>|]+/g, "-")
5+
.replace(/^\.+$/, "")
6+
.replace(/^-+|-+$/g, "")
7+
8+
return cleaned || "download"
9+
}
10+
11+
export function getObjectZipDownloadFilename(bucket, basePrefix) {
12+
const normalizedPrefix = basePrefix.replace(/\/+$/g, "")
13+
const name = normalizedPrefix ? normalizedPrefix.split("/").pop() : bucket
14+
15+
return `${sanitizeZipFilename(name || bucket || "download")}.zip`
16+
}
17+
18+
export function buildObjectZipDownloadPayload({ bucket, basePrefix, rows }) {
19+
return {
20+
bucket,
21+
prefix: basePrefix,
22+
objects: rows.filter((row) => row.type === "object").map((row) => row.Key),
23+
prefixes: rows.filter((row) => row.type === "prefix").map((row) => row.Key),
24+
filename: getObjectZipDownloadFilename(bucket, basePrefix),
25+
}
26+
}
27+
28+
export function normalizeObjectZipDownloadUrl(downloadUrl, origin) {
29+
return new URL(downloadUrl, origin).toString()
30+
}

‎lib/object-zip-download.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
export interface ObjectZipDownloadRow {
2+
Key: string
3+
type: "prefix" | "object"
4+
}
5+
6+
export interface BuildObjectZipDownloadPayloadInput {
7+
bucket: string
8+
basePrefix: string
9+
rows: ObjectZipDownloadRow[]
10+
}
11+
12+
export interface ObjectZipDownloadPayload {
13+
bucket: string
14+
prefix: string
15+
objects: string[]
16+
prefixes: string[]
17+
filename: string
18+
}
19+
20+
export interface CreateObjectZipDownloadResponse {
21+
download_url: string
22+
expires_at?: string
23+
}
24+
25+
function sanitizeZipFilename(value: string) {
26+
const cleaned = value
27+
.trim()
28+
.replace(/[\\/:*?"<>|]+/g, "-")
29+
.replace(/^\.+$/, "")
30+
.replace(/^-+|-+$/g, "")
31+
32+
return cleaned || "download"
33+
}
34+
35+
export function getObjectZipDownloadFilename(bucket: string, basePrefix: string) {
36+
const normalizedPrefix = basePrefix.replace(/\/+$/g, "")
37+
const name = normalizedPrefix ? normalizedPrefix.split("/").pop() : bucket
38+
39+
return `${sanitizeZipFilename(name || bucket || "download")}.zip`
40+
}
41+
42+
export function buildObjectZipDownloadPayload({
43+
bucket,
44+
basePrefix,
45+
rows,
46+
}: BuildObjectZipDownloadPayloadInput): ObjectZipDownloadPayload {
47+
return {
48+
bucket,
49+
prefix: basePrefix,
50+
objects: rows.filter((row) => row.type === "object").map((row) => row.Key),
51+
prefixes: rows.filter((row) => row.type === "prefix").map((row) => row.Key),
52+
filename: getObjectZipDownloadFilename(bucket, basePrefix),
53+
}
54+
}
55+
56+
export function normalizeObjectZipDownloadUrl(downloadUrl: string, origin: string) {
57+
return new URL(downloadUrl, origin).toString()
58+
}

‎package.json‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,11 @@
3030
"date-fns": "^4.1.0",
3131
"dayjs": "^1.11.20",
3232
"embla-carousel-react": "^8.6.0",
33-
"file-saver": "^2.0.5",
3433
"hyparquet": "^1.26.0",
3534
"hyparquet-compressors": "^1.1.1",
3635
"i18next": "^25.10.4",
3736
"i18next-browser-languagedetector": "^8.2.1",
3837
"input-otp": "^1.4.2",
39-
"jszip": "^3.10.1",
4038
"next": "^16.2.1",
4139
"next-themes": "^0.4.6",
4240
"node-forge": "^1.3.3",
@@ -55,7 +53,6 @@
5553
},
5654
"devDependencies": {
5755
"@tailwindcss/postcss": "^4.2.2",
58-
"@types/file-saver": "^2.0.7",
5956
"@types/node": "^20.19.37",
6057
"@types/node-forge": "^1.3.14",
6158
"@types/react": "^19.2.14",

0 commit comments

Comments
 (0)