Skip to content

Commit b968cc2

Browse files
authored
feat: download multiple object selection as zip ignoring any deleted objects selected (#2965)
1 parent d116a35 commit b968cc2

File tree

14 files changed

+1002
-28
lines changed

14 files changed

+1002
-28
lines changed

integration/objects_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
package integration
1818

1919
import (
20+
"bytes"
2021
"context"
2122
"encoding/base64"
23+
"encoding/json"
2224
"fmt"
2325
"log"
2426
"math/rand"
@@ -194,3 +196,86 @@ func TestObjectGet(t *testing.T) {
194196
})
195197
}
196198
}
199+
200+
func downloadMultipleFiles(bucketName string, objects []string) (*http.Response, error) {
201+
requestURL := fmt.Sprintf("http://localhost:9090/api/v1/buckets/%s/objects/download-multiple", bucketName)
202+
203+
postReqParams, _ := json.Marshal(objects)
204+
reqBody := bytes.NewReader(postReqParams)
205+
206+
request, err := http.NewRequest(
207+
"POST", requestURL, reqBody)
208+
if err != nil {
209+
log.Println(err)
210+
return nil, nil
211+
}
212+
213+
request.Header.Add("Cookie", fmt.Sprintf("token=%s", token))
214+
request.Header.Add("Content-Type", "application/json")
215+
client := &http.Client{
216+
Timeout: 2 * time.Second,
217+
}
218+
response, err := client.Do(request)
219+
return response, err
220+
}
221+
222+
func TestDownloadMultipleFiles(t *testing.T) {
223+
assert := assert.New(t)
224+
type args struct {
225+
bucketName string
226+
objectLis []string
227+
}
228+
tests := []struct {
229+
name string
230+
args args
231+
expectedStatus int
232+
expectedError bool
233+
}{
234+
{
235+
name: "Test empty Bucket",
236+
args: args{
237+
bucketName: "",
238+
},
239+
expectedStatus: 400,
240+
expectedError: true,
241+
},
242+
{
243+
name: "Test empty object list",
244+
args: args{
245+
bucketName: "test-bucket",
246+
},
247+
expectedStatus: 400,
248+
expectedError: true,
249+
},
250+
{
251+
name: "Test with bucket and object list",
252+
args: args{
253+
bucketName: "test-bucket",
254+
objectLis: []string{
255+
"my-object.txt",
256+
"test-prefix/",
257+
"test-prefix/nested-prefix/",
258+
"test-prefix/nested-prefix/deep-nested/",
259+
},
260+
},
261+
expectedStatus: 200,
262+
expectedError: false,
263+
},
264+
}
265+
266+
for _, tt := range tests {
267+
t.Run(tt.name, func(t *testing.T) {
268+
resp, err := downloadMultipleFiles(tt.args.bucketName, tt.args.objectLis)
269+
if tt.expectedError {
270+
assert.Nil(err)
271+
if err != nil {
272+
log.Println(err)
273+
return
274+
}
275+
}
276+
if resp != nil {
277+
assert.NotNil(resp)
278+
}
279+
})
280+
}
281+
}

portal-ui/src/api/consoleApi.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2142,6 +2142,28 @@ export class Api<
21422142
...params,
21432143
}),
21442144

2145+
/**
2146+
* No description
2147+
*
2148+
* @tags Object
2149+
* @name DownloadMultipleObjects
2150+
* @summary Download Multiple Objects
2151+
* @request POST:/buckets/{bucket_name}/objects/download-multiple
2152+
* @secure
2153+
*/
2154+
downloadMultipleObjects: (
2155+
bucketName: string,
2156+
objectList: string[],
2157+
params: RequestParams = {},
2158+
) =>
2159+
this.request<File, Error>({
2160+
path: `/buckets/${bucketName}/objects/download-multiple`,
2161+
method: "POST",
2162+
body: objectList,
2163+
secure: true,
2164+
...params,
2165+
}),
2166+
21452167
/**
21462168
* No description
21472169
*

portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ListObjects/ListObjects.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,11 @@ const ListObjects = () => {
912912
createdTime = DateTime.fromISO(bucketInfo.creation_date);
913913
}
914914

915+
const downloadToolTip =
916+
selectedObjects?.length <= 1
917+
? "Download Selected"
918+
: ` Download selected objects as Zip. Any Deleted objects in the selection would be skipped from download.`;
919+
915920
const multiActionButtons = [
916921
{
917922
action: () => {
@@ -921,7 +926,7 @@ const ListObjects = () => {
921926
disabled: !canDownload || selectedObjects?.length === 0,
922927
icon: <DownloadIcon />,
923928
tooltip: canDownload
924-
? "Download Selected"
929+
? downloadToolTip
925930
: permissionTooltipHelper(
926931
[IAM_SCOPES.S3_GET_OBJECT, IAM_SCOPES.S3_GET_ACTIONS],
927932
"download objects from this bucket",

portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/utils.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,52 @@ import { BucketObjectItem } from "./ListObjects/types";
1818
import { encodeURLString } from "../../../../../common/utils";
1919
import { removeTrace } from "../../../ObjectBrowser/transferManager";
2020
import store from "../../../../../store";
21-
import { PermissionResource } from "api/consoleApi";
21+
import { ContentType, PermissionResource } from "api/consoleApi";
22+
import { api } from "../../../../../api";
23+
import { setErrorSnackMessage } from "../../../../../systemSlice";
24+
25+
const downloadWithLink = (href: string, downloadFileName: string) => {
26+
const link = document.createElement("a");
27+
link.href = href;
28+
link.download = downloadFileName;
29+
document.body.appendChild(link);
30+
link.click();
31+
document.body.removeChild(link);
32+
};
2233

34+
export const downloadSelectedAsZip = async (
35+
bucketName: string,
36+
objectList: string[],
37+
resultFileName: string,
38+
) => {
39+
const state = store.getState();
40+
const anonymousMode = state.system.anonymousMode;
41+
42+
try {
43+
const resp = await api.buckets.downloadMultipleObjects(
44+
bucketName,
45+
objectList,
46+
{
47+
type: ContentType.Json,
48+
headers: anonymousMode
49+
? {
50+
"X-Anonymous": "1",
51+
}
52+
: undefined,
53+
},
54+
);
55+
const blob = await resp.blob();
56+
const href = window.URL.createObjectURL(blob);
57+
downloadWithLink(href, resultFileName);
58+
} catch (err: any) {
59+
store.dispatch(
60+
setErrorSnackMessage({
61+
errorMessage: `Download of multiple files failed. ${err.statusText}`,
62+
detailedError: "",
63+
}),
64+
);
65+
}
66+
};
2367
export const download = (
2468
bucketName: string,
2569
objectPath: string,
@@ -33,8 +77,6 @@ export const download = (
3377
abortCallback: () => void,
3478
toastCallback: () => void,
3579
) => {
36-
const anchor = document.createElement("a");
37-
document.body.appendChild(anchor);
3880
let basename = document.baseURI.replace(window.location.origin, "");
3981
const state = store.getState();
4082
const anonymousMode = state.system.anonymousMode;
@@ -90,12 +132,7 @@ export const download = (
90132

91133
removeTrace(id);
92134

93-
var link = document.createElement("a");
94-
link.href = window.URL.createObjectURL(req.response);
95-
link.download = filename;
96-
document.body.appendChild(link);
97-
link.click();
98-
document.body.removeChild(link);
135+
downloadWithLink(window.URL.createObjectURL(req.response), filename);
99136
} else {
100137
if (req.getResponseHeader("Content-Type") === "application/json") {
101138
const rspBody: { detailedMessage?: string } = JSON.parse(

portal-ui/src/screens/Console/ObjectBrowser/objectBrowserThunks.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import { AppState } from "../../../store";
1919
import { encodeURLString, getClientOS } from "../../../common/utils";
2020
import { BucketObjectItem } from "../Buckets/ListBuckets/Objects/ListObjects/types";
2121
import { makeid, storeCallForObjectWithID } from "./transferManager";
22-
import { download } from "../Buckets/ListBuckets/Objects/utils";
22+
import {
23+
download,
24+
downloadSelectedAsZip,
25+
} from "../Buckets/ListBuckets/Objects/utils";
2326
import {
2427
cancelObjectInList,
2528
completeObject,
@@ -33,6 +36,7 @@ import {
3336
updateProgress,
3437
} from "./objectBrowserSlice";
3538
import { setSnackBarMessage } from "../../../systemSlice";
39+
import { DateTime } from "luxon";
3640

3741
export const downloadSelected = createAsyncThunk(
3842
"objectBrowser/downloadSelected",
@@ -104,21 +108,42 @@ export const downloadSelected = createAsyncThunk(
104108

105109
itemsToDownload = state.objectBrowser.records.filter(filterFunction);
106110

107-
// I case just one element is selected, then we trigger download modal validation.
108-
// We are going to enforce zip download when multiple files are selected
111+
// In case just one element is selected, then we trigger download modal validation.
109112
if (itemsToDownload.length === 1) {
110113
if (
111114
itemsToDownload[0].name.length > 200 &&
112115
getClientOS().toLowerCase().includes("win")
113116
) {
114117
dispatch(setDownloadRenameModal(itemsToDownload[0]));
115118
return;
119+
} else {
120+
downloadObject(itemsToDownload[0]);
121+
}
122+
} else {
123+
if (itemsToDownload.length === 1) {
124+
downloadObject(itemsToDownload[0]);
125+
} else if (itemsToDownload.length > 1) {
126+
const fileName = `${DateTime.now().toFormat(
127+
"LL-dd-yyyy-HH-mm-ss",
128+
)}_files_list.zip`;
129+
130+
// We are enforcing zip download when multiple files are selected for better user experience
131+
const multiObjList = itemsToDownload.reduce((dwList: any[], bi) => {
132+
// Download objects/prefixes(recursively) as zip
133+
// Skip any deleted files selected via "Show deleted objects" in selection and log for debugging
134+
const isDeleted = bi?.delete_flag;
135+
if (bi && !isDeleted) {
136+
dwList.push(bi.name);
137+
} else {
138+
console.log(`Skipping ${bi?.name} from download.`);
139+
}
140+
return dwList;
141+
}, []);
142+
143+
await downloadSelectedAsZip(bucketName, multiObjList, fileName);
144+
return;
116145
}
117146
}
118-
119-
itemsToDownload.forEach((filteredItem) => {
120-
downloadObject(filteredItem);
121-
});
122147
}
123148
},
124149
);

0 commit comments

Comments
 (0)