Skip to content

Commit

Permalink
feat: download multiple object selection as zip ignoring any deleted …
Browse files Browse the repository at this point in the history
…objects selected (minio#2965)
  • Loading branch information
prakashsvmx authored Aug 3, 2023
1 parent d116a35 commit b968cc2
Show file tree
Hide file tree
Showing 14 changed files with 1,002 additions and 28 deletions.
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

0 comments on commit b968cc2

Please sign in to comment.