From 145e1f97971436d89f990fce70eae9aa0a992a8b Mon Sep 17 00:00:00 2001 From: cxymds Date: Fri, 12 Jun 2026 11:55:50 +0800 Subject: [PATCH] feat: use server zip downloads (#128) --- components/object/list.tsx | 91 +++++++-------------------- lib/api-client.ts | 4 ++ lib/object-zip-download.js | 30 +++++++++ lib/object-zip-download.ts | 58 +++++++++++++++++ package.json | 3 - pnpm-lock.yaml | 91 --------------------------- tests/lib/api-client-source.test.js | 17 +++++ tests/lib/object-zip-download.test.js | 59 +++++++++++++++++ 8 files changed, 190 insertions(+), 163 deletions(-) create mode 100644 lib/object-zip-download.js create mode 100644 lib/object-zip-download.ts create mode 100644 tests/lib/api-client-source.test.js create mode 100644 tests/lib/object-zip-download.test.js diff --git a/components/object/list.tsx b/components/object/list.tsx index 3a6b653..9e344be 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -35,6 +35,7 @@ import { useObject } from "@/hooks/use-object" import { useBucket } from "@/hooks/use-bucket" import { useLocalStorage } from "@/hooks/use-local-storage" import { usePermissions } from "@/hooks/use-permissions" +import { useApi } from "@/contexts/api-context" import { useMessage } from "@/lib/feedback/message" import { exportFile } from "@/lib/export-file" import { getContentType } from "@/lib/mime-types" @@ -56,12 +57,15 @@ import { shouldShowDeleteAllVersions, type BucketVersioningState, } from "@/lib/object-delete" +import { + buildObjectZipDownloadPayload, + normalizeObjectZipDownloadUrl, + type CreateObjectZipDownloadResponse, +} from "@/lib/object-zip-download" import { TaskStatsButton } from "@/components/tasks/stats-button" import { useAddDeleteKeys, useAddDeleteFolder, useTasks } from "@/contexts/task-context" import type { ColumnDef } from "@tanstack/react-table" import dayjs from "dayjs" -import { saveAs } from "file-saver" -import JSZip from "jszip" interface ObjectRow { Key: string @@ -95,7 +99,8 @@ export function ObjectList({ }: ObjectListProps) { const { t } = useTranslation() const message = useMessage() - const { listObject, getSignedUrl, mapAllFiles } = useObject(bucket) + const api = useApi() + const { listObject, getSignedUrl } = useObject(bucket) const { getBucketVersioning } = useBucket() const { canCapability } = usePermissions() const addDeleteKeys = useAddDeleteKeys() @@ -416,12 +421,6 @@ export function ObjectList({ const checkedKeys = selectedRowIds - const computeRelativeKey = (key: string) => { - if (!key) return "" - const base = prefix || "" - return base && key.startsWith(base) ? key.slice(base.length) : key - } - const downloadMultiple = async () => { if (!checkedKeys.length) { message.warning(t("Please select at least one item")) @@ -434,74 +433,28 @@ export function ObjectList({ return } - const collecting = message.loading(t("Collecting files"), { duration: 0 }) - const allFiles: { key: string; relative: string }[] = [] + const loadingMsg = message.loading(t("Preparing download"), { duration: 0 }) try { - for (const item of selectedRows) { - if (item.type === "object") { - allFiles.push({ - key: item.Key, - relative: computeRelativeKey(item.Key), - }) - } else if (item.type === "prefix") { - await mapAllFiles(bucket, item.Key, (fileKey) => { - allFiles.push({ - key: fileKey, - relative: computeRelativeKey(fileKey), - }) - }) - } - } - } catch (err) { - collecting.destroy() - message.error((err as Error)?.message ?? t("Download Failed")) - return - } - - collecting.destroy() - - if (!allFiles.length) { - message.warning(t("No files to download")) - return - } - - const zip = new JSZip() - let finished = 0 - const destroyRef: { current: (() => void) | null } = { current: null } - - const updateProgress = () => { - if (destroyRef.current) destroyRef.current() - const handle = message.loading(`${t("Downloading files")} ${Math.round((finished / allFiles.length) * 100)}%`, { - duration: 0, + const payload = buildObjectZipDownloadPayload({ + bucket, + basePrefix: prefix, + rows: selectedRows, }) - destroyRef.current = handle.destroy - } + const response = (await api.post("/object-zip-downloads", payload)) as CreateObjectZipDownloadResponse - updateProgress() + if (!response.download_url) { + throw new Error(t("Download Failed")) + } - try { - await Promise.all( - allFiles.map(async ({ key, relative }) => { - const url = await getSignedUrl(key) - const response = await fetch(url) - const blob = await response.blob() - zip.file(relative || key, blob) - finished++ - updateProgress() - }), - ) + window.location.href = normalizeObjectZipDownloadUrl(response.download_url, api.resolveUrl("/")) + table.resetRowSelection() + message.success(t("Download ready")) } catch (err) { - if (destroyRef.current) destroyRef.current() message.error((err as Error)?.message ?? t("Download Failed")) - return + } finally { + loadingMsg.destroy() } - - if (destroyRef.current) destroyRef.current() - - const content = await zip.generateAsync({ type: "blob" }) - saveAs(content, `${bucket ?? "download"}.zip`) - message.success(t("Download ready")) } const openDeleteDialog = (keys: string[]) => { diff --git a/lib/api-client.ts b/lib/api-client.ts index 02c1568..03becda 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -40,6 +40,10 @@ export class ApiClient { this.errorHandler = handler } + resolveUrl(url: string): string { + return new URL(url, this.config?.baseUrl).toString() + } + async request(url: string, options: RequestOptions = {}, parseJson: boolean = true) { url = this.config?.baseUrl ? joinURL(this.config?.baseUrl, url) : url options.headers = { ...this.config?.headers, ...options.headers } diff --git a/lib/object-zip-download.js b/lib/object-zip-download.js new file mode 100644 index 0000000..e13ca18 --- /dev/null +++ b/lib/object-zip-download.js @@ -0,0 +1,30 @@ +function sanitizeZipFilename(value) { + const cleaned = value + .trim() + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/^\.+$/, "") + .replace(/^-+|-+$/g, "") + + return cleaned || "download" +} + +export function getObjectZipDownloadFilename(bucket, basePrefix) { + const normalizedPrefix = basePrefix.replace(/\/+$/g, "") + const name = normalizedPrefix ? normalizedPrefix.split("/").pop() : bucket + + return `${sanitizeZipFilename(name || bucket || "download")}.zip` +} + +export function buildObjectZipDownloadPayload({ bucket, basePrefix, rows }) { + return { + bucket, + prefix: basePrefix, + objects: rows.filter((row) => row.type === "object").map((row) => row.Key), + prefixes: rows.filter((row) => row.type === "prefix").map((row) => row.Key), + filename: getObjectZipDownloadFilename(bucket, basePrefix), + } +} + +export function normalizeObjectZipDownloadUrl(downloadUrl, origin) { + return new URL(downloadUrl, origin).toString() +} diff --git a/lib/object-zip-download.ts b/lib/object-zip-download.ts new file mode 100644 index 0000000..264f42b --- /dev/null +++ b/lib/object-zip-download.ts @@ -0,0 +1,58 @@ +export interface ObjectZipDownloadRow { + Key: string + type: "prefix" | "object" +} + +export interface BuildObjectZipDownloadPayloadInput { + bucket: string + basePrefix: string + rows: ObjectZipDownloadRow[] +} + +export interface ObjectZipDownloadPayload { + bucket: string + prefix: string + objects: string[] + prefixes: string[] + filename: string +} + +export interface CreateObjectZipDownloadResponse { + download_url: string + expires_at?: string +} + +function sanitizeZipFilename(value: string) { + const cleaned = value + .trim() + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/^\.+$/, "") + .replace(/^-+|-+$/g, "") + + return cleaned || "download" +} + +export function getObjectZipDownloadFilename(bucket: string, basePrefix: string) { + const normalizedPrefix = basePrefix.replace(/\/+$/g, "") + const name = normalizedPrefix ? normalizedPrefix.split("/").pop() : bucket + + return `${sanitizeZipFilename(name || bucket || "download")}.zip` +} + +export function buildObjectZipDownloadPayload({ + bucket, + basePrefix, + rows, +}: BuildObjectZipDownloadPayloadInput): ObjectZipDownloadPayload { + return { + bucket, + prefix: basePrefix, + objects: rows.filter((row) => row.type === "object").map((row) => row.Key), + prefixes: rows.filter((row) => row.type === "prefix").map((row) => row.Key), + filename: getObjectZipDownloadFilename(bucket, basePrefix), + } +} + +export function normalizeObjectZipDownloadUrl(downloadUrl: string, origin: string) { + return new URL(downloadUrl, origin).toString() +} diff --git a/package.json b/package.json index 7bc811f..69b3aea 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,11 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.20", "embla-carousel-react": "^8.6.0", - "file-saver": "^2.0.5", "hyparquet": "^1.26.0", "hyparquet-compressors": "^1.1.1", "i18next": "^25.10.4", "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", - "jszip": "^3.10.1", "next": "^16.2.1", "next-themes": "^0.4.6", "node-forge": "^1.3.3", @@ -55,7 +53,6 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.2.2", - "@types/file-saver": "^2.0.7", "@types/node": "^20.19.37", "@types/node-forge": "^1.3.14", "@types/react": "^19.2.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a36b9f6..03a5ea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,9 +249,6 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.4) - file-saver: - specifier: ^2.0.5 - version: 2.0.5 hyparquet: specifier: ^1.26.0 version: 1.26.0 @@ -267,9 +264,6 @@ importers: input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - jszip: - specifier: ^3.10.1 - version: 3.10.1 next: specifier: ^16.2.1 version: 16.2.1(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -319,9 +313,6 @@ importers: '@tailwindcss/postcss': specifier: ^4.2.2 version: 4.2.2 - '@types/file-saver': - specifier: ^2.0.7 - version: 2.0.7 '@types/node': specifier: ^20.19.37 version: 20.19.37 @@ -1677,9 +1668,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/file-saver@2.0.7': - resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2129,9 +2117,6 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -2615,9 +2600,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-saver@2.0.5: - resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2842,9 +2824,6 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immediate@3.0.6: - resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} @@ -3050,9 +3029,6 @@ packages: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3120,9 +3096,6 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - jszip@3.10.1: - resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3145,9 +3118,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lie@3.3.0: - resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -3462,9 +3432,6 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - pako@1.0.11: - resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3557,9 +3524,6 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -3675,9 +3639,6 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -3759,9 +3720,6 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3805,9 +3763,6 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - setimmediate@1.0.5: - resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} - setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -3916,9 +3871,6 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - stringify-object@5.0.0: resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} engines: {node: '>=14.16'} @@ -5966,8 +5918,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/file-saver@2.0.7': {} - '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -6415,8 +6365,6 @@ snapshots: cookie@1.1.1: {} - core-util-is@1.0.3: {} - cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -7054,8 +7002,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-saver@2.0.5: {} - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -7270,8 +7216,6 @@ snapshots: ignore@7.0.5: {} - immediate@3.0.6: {} - immer@10.2.0: {} immer@11.1.8: {} @@ -7450,8 +7394,6 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isarray@1.0.0: {} - isarray@2.0.5: {} isexe@2.0.0: {} @@ -7510,13 +7452,6 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - jszip@3.10.1: - dependencies: - lie: 3.3.0 - pako: 1.0.11 - readable-stream: 2.3.8 - setimmediate: 1.0.5 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7536,10 +7471,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lie@3.3.0: - dependencies: - immediate: 3.0.6 - lightningcss-android-arm64@1.32.0: optional: true @@ -7846,8 +7777,6 @@ snapshots: dependencies: p-limit: 3.1.0 - pako@1.0.11: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7916,8 +7845,6 @@ snapshots: dependencies: parse-ms: 4.0.0 - process-nextick-args@2.0.1: {} - prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -8022,16 +7949,6 @@ snapshots: react@19.2.4: {} - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -8141,8 +8058,6 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 - safe-buffer@5.1.2: {} - safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -8209,8 +8124,6 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - setimmediate@1.0.5: {} - setprototypeof@1.2.0: {} shadcn@4.7.0(@types/node@20.19.37)(typescript@5.9.3): @@ -8412,10 +8325,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - stringify-object@5.0.0: dependencies: get-own-enumerable-keys: 1.0.0 diff --git a/tests/lib/api-client-source.test.js b/tests/lib/api-client-source.test.js new file mode 100644 index 0000000..246877d --- /dev/null +++ b/tests/lib/api-client-source.test.js @@ -0,0 +1,17 @@ +import test from "node:test" +import assert from "node:assert/strict" +import fs from "node:fs" + +test("ApiClient exposes URL resolution against its configured base URL", () => { + const source = fs.readFileSync("lib/api-client.ts", "utf8") + + assert.match(source, /resolveUrl\(url: string\): string/) + assert.match(source, /new URL\(url, this\.config\?\.baseUrl\)\.toString\(\)/) +}) + +test("object bulk download resolves relative download URLs through the API client", () => { + const source = fs.readFileSync("components/object/list.tsx", "utf8") + + assert.match(source, /normalizeObjectZipDownloadUrl\(response\.download_url, api\.resolveUrl\("\/"\)\)/) + assert.doesNotMatch(source, /normalizeObjectZipDownloadUrl\(response\.download_url, window\.location\.origin\)/) +}) diff --git a/tests/lib/object-zip-download.test.js b/tests/lib/object-zip-download.test.js new file mode 100644 index 0000000..144646a --- /dev/null +++ b/tests/lib/object-zip-download.test.js @@ -0,0 +1,59 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + buildObjectZipDownloadPayload, + getObjectZipDownloadFilename, + normalizeObjectZipDownloadUrl, +} from "../../lib/object-zip-download.js" + +test("buildObjectZipDownloadPayload separates object keys and prefixes", () => { + const payload = buildObjectZipDownloadPayload({ + bucket: "photos", + basePrefix: "2026/events/", + rows: [ + { Key: "2026/events/a.txt", type: "object" }, + { Key: "2026/events/raw/", type: "prefix" }, + ], + }) + + assert.deepEqual(payload, { + bucket: "photos", + prefix: "2026/events/", + objects: ["2026/events/a.txt"], + prefixes: ["2026/events/raw/"], + filename: "events.zip", + }) +}) + +test("getObjectZipDownloadFilename falls back to bucket name at root", () => { + assert.equal(getObjectZipDownloadFilename("photos", ""), "photos.zip") +}) + +test("getObjectZipDownloadFilename uses the last prefix segment", () => { + assert.equal(getObjectZipDownloadFilename("photos", "2026/events/"), "events.zip") +}) + +test("getObjectZipDownloadFilename sanitizes unsafe filename characters", () => { + assert.equal(getObjectZipDownloadFilename("photos", '2026/bad:name*?"<>|/'), "bad-name.zip") +}) + +test("getObjectZipDownloadFilename falls back when sanitized names are empty", () => { + assert.equal(getObjectZipDownloadFilename("...", ""), "download.zip") +}) + +test("normalizeObjectZipDownloadUrl keeps absolute URLs", () => { + assert.equal( + normalizeObjectZipDownloadUrl("https://example.com/download.zip", "https://console.example.com"), + "https://example.com/download.zip", + ) +}) + +test("normalizeObjectZipDownloadUrl resolves relative URLs against the API base URL origin", () => { + assert.equal( + normalizeObjectZipDownloadUrl( + "/rustfs/admin/v3/object-zip-downloads/abc.zip?token=abc", + "https://api.example.com/rustfs/admin/v3", + ), + "https://api.example.com/rustfs/admin/v3/object-zip-downloads/abc.zip?token=abc", + ) +})