Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions integration/objects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
package integration

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"math/rand"
Expand Down Expand Up @@ -194,3 +196,86 @@ func TestObjectGet(t *testing.T) {
})
}
}

func downloadMultipleFiles(bucketName string, objects []string) (*http.Response, error) {
requestURL := fmt.Sprintf("http://localhost:9090/api/v1/buckets/%s/objects/download-multiple", bucketName)

postReqParams, _ := json.Marshal(objects)
reqBody := bytes.NewReader(postReqParams)

request, err := http.NewRequest(
"POST", requestURL, reqBody)
if err != nil {
log.Println(err)
return nil, nil
}

request.Header.Add("Cookie", fmt.Sprintf("token=%s", token))
request.Header.Add("Content-Type", "application/json")
client := &http.Client{
Timeout: 2 * time.Second,
}
response, err := client.Do(request)
return response, err
}

func TestDownloadMultipleFiles(t *testing.T) {
assert := assert.New(t)
type args struct {
bucketName string
objectLis []string
}
tests := []struct {
name string
args args
expectedStatus int
expectedError bool
}{
{
name: "Test empty Bucket",
args: args{
bucketName: "",
},
expectedStatus: 400,
expectedError: true,
},
{
name: "Test empty object list",
args: args{
bucketName: "test-bucket",
},
expectedStatus: 400,
expectedError: true,
},
{
name: "Test with bucket and object list",
args: args{
bucketName: "test-bucket",
objectLis: []string{
"my-object.txt",
"test-prefix/",
"test-prefix/nested-prefix/",
"test-prefix/nested-prefix/deep-nested/",
},
},
expectedStatus: 200,
expectedError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := downloadMultipleFiles(tt.args.bucketName, tt.args.objectLis)
if tt.expectedError {
assert.Nil(err)
if err != nil {
log.Println(err)
return
}
}
if resp != nil {
assert.NotNil(resp)
}
})
}
}
22 changes: 22 additions & 0 deletions portal-ui/src/api/consoleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2142,6 +2142,28 @@ export class Api<
...params,
}),

/**
* No description
*
* @tags Object
* @name DownloadMultipleObjects
* @summary Download Multiple Objects
* @request POST:/buckets/{bucket_name}/objects/download-multiple
* @secure
*/
downloadMultipleObjects: (
bucketName: string,
objectList: string[],
params: RequestParams = {},
) =>
this.request<File, Error>({
path: `/buckets/${bucketName}/objects/download-multiple`,
method: "POST",
body: objectList,
secure: true,
...params,
}),

/**
* No description
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,11 @@ const ListObjects = () => {
createdTime = DateTime.fromISO(bucketInfo.creation_date);
}

const downloadToolTip =
selectedObjects?.length <= 1
? "Download Selected"
: ` Download selected objects as Zip. Any Deleted objects in the selection would be skipped from download.`;

const multiActionButtons = [
{
action: () => {
Expand All @@ -921,7 +926,7 @@ const ListObjects = () => {
disabled: !canDownload || selectedObjects?.length === 0,
icon: <DownloadIcon />,
tooltip: canDownload
? "Download Selected"
? downloadToolTip
: permissionTooltipHelper(
[IAM_SCOPES.S3_GET_OBJECT, IAM_SCOPES.S3_GET_ACTIONS],
"download objects from this bucket",
Expand Down
55 changes: 46 additions & 9 deletions portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,52 @@ import { BucketObjectItem } from "./ListObjects/types";
import { encodeURLString } from "../../../../../common/utils";
import { removeTrace } from "../../../ObjectBrowser/transferManager";
import store from "../../../../../store";
import { PermissionResource } from "api/consoleApi";
import { ContentType, PermissionResource } from "api/consoleApi";
import { api } from "../../../../../api";
import { setErrorSnackMessage } from "../../../../../systemSlice";

const downloadWithLink = (href: string, downloadFileName: string) => {
const link = document.createElement("a");
link.href = href;
link.download = downloadFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};

export const downloadSelectedAsZip = async (
bucketName: string,
objectList: string[],
resultFileName: string,
) => {
const state = store.getState();
const anonymousMode = state.system.anonymousMode;

try {
const resp = await api.buckets.downloadMultipleObjects(
bucketName,
objectList,
{
type: ContentType.Json,
headers: anonymousMode
? {
"X-Anonymous": "1",
}
: undefined,
},
);
const blob = await resp.blob();
const href = window.URL.createObjectURL(blob);
downloadWithLink(href, resultFileName);
} catch (err: any) {
store.dispatch(
setErrorSnackMessage({
errorMessage: `Download of multiple files failed. ${err.statusText}`,
detailedError: "",
}),
);
}
};
export const download = (
bucketName: string,
objectPath: string,
Expand All @@ -33,8 +77,6 @@ export const download = (
abortCallback: () => void,
toastCallback: () => void,
) => {
const anchor = document.createElement("a");
document.body.appendChild(anchor);
let basename = document.baseURI.replace(window.location.origin, "");
const state = store.getState();
const anonymousMode = state.system.anonymousMode;
Expand Down Expand Up @@ -90,12 +132,7 @@ export const download = (

removeTrace(id);

var link = document.createElement("a");
link.href = window.URL.createObjectURL(req.response);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
downloadWithLink(window.URL.createObjectURL(req.response), filename);
} else {
if (req.getResponseHeader("Content-Type") === "application/json") {
const rspBody: { detailedMessage?: string } = JSON.parse(
Expand Down
39 changes: 32 additions & 7 deletions portal-ui/src/screens/Console/ObjectBrowser/objectBrowserThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import { AppState } from "../../../store";
import { encodeURLString, getClientOS } from "../../../common/utils";
import { BucketObjectItem } from "../Buckets/ListBuckets/Objects/ListObjects/types";
import { makeid, storeCallForObjectWithID } from "./transferManager";
import { download } from "../Buckets/ListBuckets/Objects/utils";
import {
download,
downloadSelectedAsZip,
} from "../Buckets/ListBuckets/Objects/utils";
import {
cancelObjectInList,
completeObject,
Expand All @@ -33,6 +36,7 @@ import {
updateProgress,
} from "./objectBrowserSlice";
import { setSnackBarMessage } from "../../../systemSlice";
import { DateTime } from "luxon";

export const downloadSelected = createAsyncThunk(
"objectBrowser/downloadSelected",
Expand Down Expand Up @@ -104,21 +108,42 @@ export const downloadSelected = createAsyncThunk(

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

// I case just one element is selected, then we trigger download modal validation.
// We are going to enforce zip download when multiple files are selected
// In case just one element is selected, then we trigger download modal validation.
if (itemsToDownload.length === 1) {
if (
itemsToDownload[0].name.length > 200 &&
getClientOS().toLowerCase().includes("win")
) {
dispatch(setDownloadRenameModal(itemsToDownload[0]));
return;
} else {
downloadObject(itemsToDownload[0]);
}
} else {
if (itemsToDownload.length === 1) {
downloadObject(itemsToDownload[0]);
} else if (itemsToDownload.length > 1) {
const fileName = `${DateTime.now().toFormat(
"LL-dd-yyyy-HH-mm-ss",
)}_files_list.zip`;

// We are enforcing zip download when multiple files are selected for better user experience
const multiObjList = itemsToDownload.reduce((dwList: any[], bi) => {
// Download objects/prefixes(recursively) as zip
// Skip any deleted files selected via "Show deleted objects" in selection and log for debugging
const isDeleted = bi?.delete_flag;
if (bi && !isDeleted) {
dwList.push(bi.name);
} else {
console.log(`Skipping ${bi?.name} from download.`);
}
return dwList;
}, []);

await downloadSelectedAsZip(bucketName, multiObjList, fileName);
return;
}
}

itemsToDownload.forEach((filteredItem) => {
downloadObject(filteredItem);
});
}
},
);
Expand Down
Loading