diff --git a/integration/user_api_bucket_test.go b/integration/user_api_bucket_test.go index 37976eab41..54444849a9 100644 --- a/integration/user_api_bucket_test.go +++ b/integration/user_api_bucket_test.go @@ -461,12 +461,24 @@ func ListObjects(bucketName, prefix, withVersions string) (*http.Response, error return response, err } -func SharesAnObjectOnAUrl(bucketName, prefix, versionID, expires string) (*http.Response, error) { - // Helper function to share an object on a url +func SharesAnObjectOnAUrl(bucketName, prefix, versionID, expires, accessKey, secretKey string) (*http.Response, error) { + // Helper function to share an object on an url + + requestDataAdd := map[string]interface{}{ + "prefix": prefix, + "version_id": versionID, + "expires": expires, + "access_key": accessKey, + "secret_key": secretKey, + } + + requestDataJSON, _ := json.Marshal(requestDataAdd) + requestDataBody := bytes.NewReader(requestDataJSON) + request, err := http.NewRequest( - "GET", - "http://localhost:9090/api/v1/buckets/"+bucketName+"/objects/share?prefix="+prefix+"&version_id="+versionID+"&expires="+expires, - nil, + "POST", + "http://localhost:9090/api/v1/buckets/"+bucketName+"/objects/share", + requestDataBody, ) if err != nil { log.Println(err) @@ -743,6 +755,39 @@ func PutObjectsLegalholdStatus(bucketName, prefix, status, versionID string) (*h return response, err } +func PostServiceAccountCredentials(accessKey, secretKey, policy string) (*http.Response, error) { + /* + Helper function to create a service account + POST: {{baseUrl}}/service-account-credentials + { + "accessKey":"testsa", + "secretKey":"secretsa", + "policy":"" + } + */ + requestDataAdd := map[string]interface{}{ + "accessKey": accessKey, + "secretKey": secretKey, + "policy": policy, + } + requestDataJSON, _ := json.Marshal(requestDataAdd) + requestDataBody := bytes.NewReader(requestDataJSON) + + request, err := http.NewRequest("POST", + "http://localhost:9090/api/v1/service-account-credentials", + requestDataBody) + if err != nil { + log.Println(err) + } + 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 TestPutObjectsLegalholdStatus(t *testing.T) { // Variables assert := assert.New(t) @@ -1514,6 +1559,8 @@ func TestShareObjectOnURL(t *testing.T) { tags := make(map[string]string) tags["tag"] = "testputobjecttagbucketonetagone" versionID := "null" + accessKey := "testaccesskey" + secretKey := "secretAccessKey" // 1. Create the bucket if !setupBucket(bucketName, false, false, nil, nil, assert, 200) { @@ -1534,6 +1581,21 @@ func TestShareObjectOnURL(t *testing.T) { inspectHTTPResponse(uploadResponse), ) } + // 2. Create Access Key + accKeyRsp, createError := PostServiceAccountCredentials(accessKey, secretKey, "") + + if createError != nil { + log.Println(createError) + return + } + + if accKeyRsp != nil { + assert.Equal( + 201, + accKeyRsp.StatusCode, + inspectHTTPResponse(accKeyRsp), + ) + } type args struct { prefix string @@ -1561,7 +1623,7 @@ func TestShareObjectOnURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // 3. Share the object on a URL - shareResponse, shareError := SharesAnObjectOnAUrl(bucketName, tt.args.prefix, versionID, "604800s") + shareResponse, shareError := SharesAnObjectOnAUrl(bucketName, tt.args.prefix, versionID, "604800s", accessKey, secretKey) assert.Nil(shareError) if shareError != nil { log.Println(shareError) diff --git a/models/share_request.go b/models/share_request.go new file mode 100644 index 0000000000..b12a7f041e --- /dev/null +++ b/models/share_request.go @@ -0,0 +1,142 @@ +// Code generated by go-swagger; DO NOT EDIT. + +// This file is part of MinIO Console Server +// Copyright (c) 2023 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// + +package models + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// ShareRequest share request +// +// swagger:model shareRequest +type ShareRequest struct { + + // access key + // Required: true + AccessKey *string `json:"access_key"` + + // expires + Expires string `json:"expires,omitempty"` + + // prefix + // Required: true + Prefix *string `json:"prefix"` + + // secret key + // Required: true + SecretKey *string `json:"secret_key"` + + // version id + // Required: true + VersionID *string `json:"version_id"` +} + +// Validate validates this share request +func (m *ShareRequest) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateAccessKey(formats); err != nil { + res = append(res, err) + } + + if err := m.validatePrefix(formats); err != nil { + res = append(res, err) + } + + if err := m.validateSecretKey(formats); err != nil { + res = append(res, err) + } + + if err := m.validateVersionID(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *ShareRequest) validateAccessKey(formats strfmt.Registry) error { + + if err := validate.Required("access_key", "body", m.AccessKey); err != nil { + return err + } + + return nil +} + +func (m *ShareRequest) validatePrefix(formats strfmt.Registry) error { + + if err := validate.Required("prefix", "body", m.Prefix); err != nil { + return err + } + + return nil +} + +func (m *ShareRequest) validateSecretKey(formats strfmt.Registry) error { + + if err := validate.Required("secret_key", "body", m.SecretKey); err != nil { + return err + } + + return nil +} + +func (m *ShareRequest) validateVersionID(formats strfmt.Registry) error { + + if err := validate.Required("version_id", "body", m.VersionID); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this share request based on context it is used +func (m *ShareRequest) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *ShareRequest) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *ShareRequest) UnmarshalBinary(b []byte) error { + var res ShareRequest + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/portal-ui/package.json b/portal-ui/package.json index 08669ae6c0..8b4e3ec8a1 100644 --- a/portal-ui/package.json +++ b/portal-ui/package.json @@ -66,7 +66,7 @@ }, "proxy": "http://localhost:9090/", "devDependencies": { - "@playwright/test": "^1.32.3", + "@playwright/test": "^1.34.0", "@types/lodash": "^4.14.194", "@types/luxon": "^3.3.0", "@types/minio": "^7.0.18", @@ -85,6 +85,7 @@ "@types/websocket": "^1.0.0", "babel-plugin-istanbul": "^6.1.1", "customize-cra": "^1.0.0", + "minio": "^7.0.33", "nyc": "^15.1.0", "playwright": "^1.31.3", "prettier": "2.8.8", @@ -92,8 +93,7 @@ "react-app-rewired": "^2.2.1", "react-scripts": "5.0.1", "testcafe": "^2.5.0", - "typescript": "^4.4.3", - "minio": "^7.0.33" + "typescript": "^4.4.3" }, "resolutions": { "nth-check": "^2.0.1", diff --git a/portal-ui/src/api/consoleApi.ts b/portal-ui/src/api/consoleApi.ts index 390bd9db75..cc4896ef0a 100644 --- a/portal-ui/src/api/consoleApi.ts +++ b/portal-ui/src/api/consoleApi.ts @@ -1494,6 +1494,14 @@ export interface LdapPolicyEntity { groups?: string[]; } +export interface ShareRequest { + prefix: string; + version_id: string; + expires?: string; + access_key: string; + secret_key: string; +} + export type QueryParamsType = Record; export type ResponseFormat = keyof Omit; @@ -2171,23 +2179,20 @@ export class Api< * @tags Object * @name ShareObject * @summary Shares an Object on a url - * @request GET:/buckets/{bucket_name}/objects/share + * @request POST:/buckets/{bucket_name}/objects/share * @secure */ shareObject: ( bucketName: string, - query: { - prefix: string; - version_id: string; - expires?: string; - }, + body: ShareRequest, params: RequestParams = {} ) => this.request({ path: `/buckets/${bucketName}/objects/share`, - method: "GET", - query: query, + method: "POST", + body: body, secure: true, + type: ContentType.Json, format: "json", ...params, }), diff --git a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/ShareFile.tsx b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/ShareFile.tsx index 99c97c55a8..1361e14db5 100644 --- a/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/ShareFile.tsx +++ b/portal-ui/src/screens/Console/Buckets/ListBuckets/Objects/ObjectDetails/ShareFile.tsx @@ -17,11 +17,20 @@ import React, { Fragment, useEffect, useState } from "react"; import { useSelector } from "react-redux"; import { Theme } from "@mui/material/styles"; -import { Button, CopyIcon, ReadBox, ShareIcon } from "mds"; +import { + Button, + CopyIcon, + FormLayout, + Grid, + InputBox, + RadioGroup, + ReadBox, + Select, + ShareIcon, +} from "mds"; import createStyles from "@mui/styles/createStyles"; import withStyles from "@mui/styles/withStyles"; import CopyToClipboard from "react-copy-to-clipboard"; -import Grid from "@mui/material/Grid"; import LinearProgress from "@mui/material/LinearProgress"; import { formFieldStyles, @@ -36,10 +45,13 @@ import DaysSelector from "../../../../Common/FormComponents/DaysSelector/DaysSel import { encodeURLString } from "../../../../../../common/utils"; import { selDistSet, + setErrorSnackMessage, setModalErrorSnackMessage, setModalSnackMessage, } from "../../../../../../systemSlice"; import { useAppDispatch } from "../../../../../../store"; +import { DateTime } from "luxon"; +import { stringSort } from "../../../../../../utils/sortFunctions"; const styles = (theme: Theme) => createStyles({ @@ -85,11 +97,17 @@ const ShareFile = ({ const dispatch = useAppDispatch(); const distributedSetup = useSelector(selDistSet); const [shareURL, setShareURL] = useState(""); - const [isLoadingVersion, setIsLoadingVersion] = useState(true); - const [isLoadingFile, setIsLoadingFile] = useState(false); + const [isLoadingURL, setIsLoadingURL] = useState(false); + const [isLoadingAccessKeys, setLoadingAccessKeys] = useState(true); const [selectedDate, setSelectedDate] = useState(""); const [dateValid, setDateValid] = useState(true); const [versionID, setVersionID] = useState("null"); + const [displayURL, setDisplayURL] = useState(false); + const [accessKeys, setAccessKeys] = useState([]); + const [selectedAccessKey, setSelectedAccessKey] = useState(""); + const [secretKey, setSecretKey] = useState(""); + const [authType, setAuthType] = useState("acc-list"); + const [otherAK, setOtherAK] = useState(""); const initialDate = new Date(); @@ -134,20 +152,19 @@ const ShareFile = ({ dispatch(setModalErrorSnackMessage(error)); }); - setIsLoadingVersion(false); + setIsLoadingURL(false); return; } setVersionID("null"); - setIsLoadingVersion(false); + setIsLoadingURL(false); return; } setVersionID(dataObject.version_id || "null"); - setIsLoadingVersion(false); + setIsLoadingURL(false); }, [bucketName, dataObject, distributedSetup, dispatch]); useEffect(() => { - if (dateValid && !isLoadingVersion) { - setIsLoadingFile(true); + if (dateValid && isLoadingURL) { setShareURL(""); const slDate = new Date(`${selectedDate}`); @@ -157,28 +174,33 @@ const ShareFile = ({ (slDate.getTime() - currDate.getTime()) / 1000 ); + const accKey = authType === "acc-list" ? selectedAccessKey : otherAK; + if (diffDate > 0) { api - .invoke( - "GET", - `/api/v1/buckets/${bucketName}/objects/share?prefix=${encodeURLString( - dataObject.name - )}&version_id=${versionID}${ - selectedDate !== "" ? `&expires=${diffDate}s` : "" - }` - ) + .invoke("POST", `/api/v1/buckets/${bucketName}/objects/share`, { + prefix: encodeURLString(dataObject.name), + version_id: versionID, + expires: selectedDate !== "" ? `${diffDate}s` : "", + access_key: accKey, + secret_key: secretKey, + }) .then((res: string) => { setShareURL(res); - setIsLoadingFile(false); + setIsLoadingURL(false); + setDisplayURL(true); }) .catch((error: ErrorResponseHandler) => { dispatch(setModalErrorSnackMessage(error)); setShareURL(""); - setIsLoadingFile(false); + setIsLoadingURL(false); + setDisplayURL(false); }); } } }, [ + secretKey, + selectedAccessKey, dataObject, selectedDate, bucketName, @@ -186,80 +208,205 @@ const ShareFile = ({ setShareURL, dispatch, distributedSetup, - isLoadingVersion, versionID, + isLoadingURL, + authType, + otherAK, ]); + useEffect(() => { + if (isLoadingAccessKeys) { + const userLoggedIn = localStorage.getItem("userLoggedIn"); + + api + .invoke( + "GET", + `/api/v1/user/${encodeURLString(userLoggedIn)}/service-accounts` + ) + .then((res: string[]) => { + if (res.length === 0) { + setAuthType("acc-other"); + } + + const serviceAccounts = res + .sort(stringSort) + .map((element) => ({ value: element, label: element })); + + setLoadingAccessKeys(false); + setAccessKeys(serviceAccounts); + setSelectedAccessKey(serviceAccounts[0].value); + }) + .catch((err: ErrorResponseHandler) => { + dispatch(setErrorSnackMessage(err)); + setLoadingAccessKeys(false); + }); + } + }, [isLoadingAccessKeys, dispatch]); + + const generateLink = () => { + setIsLoadingURL(true); + }; + + const generateAnotherLink = () => { + setIsLoadingURL(false); + setDisplayURL(false); + setSelectedDate(""); + setShareURL(""); + }; + return ( - - } - modalOpen={open} - onClose={() => { - closeModalAndRefresh(); - }} - > - {isLoadingVersion && ( - - + } + modalOpen={open} + onClose={() => { + closeModalAndRefresh(); + }} + > + {displayURL ? ( + + + This is a temporary URL with integrated access credentials for + sharing {dataObject.name} until{" "} + + {DateTime.fromISO(selectedDate).toFormat( + "ccc, LLL dd yyyy HH:mm (ZZZZ)" + )} + +
+
+ This temporary URL will expiry after this time.
- )} - {!isLoadingVersion && ( - - - This is a temporary URL with integrated access credentials for - sharing objects valid for up to 7 days. -
-
- The temporary URL expires after the configured time limit. -
+ + + + +
+ ) : ( + + + To generate a temporary URL, please provide a set of credentials, + this link can ve valid up to 7 days.
- - + + + {accessKeys.length > 0 && ( + { + setAuthType(e.target.value); + }} + currentValue={authType} + /> + )} + {authType === "acc-other" ? ( + { + setOtherAK(e.target.value); + }} + label={"Access Key"} + /> + ) : ( +