Anonymous Access (#2600)

Signed-off-by: Daniel Valdivia <18384552+dvaldivia@users.noreply.github.com>
This commit is contained in:
Daniel Valdivia
2023-01-27 12:23:30 -08:00
committed by GitHub
parent c141b6d65e
commit b218cbf503
39 changed files with 1596 additions and 891 deletions

View File

@@ -45,8 +45,8 @@ import (
// Session token errors
var (
ErrNoAuthToken = errors.New("session token missing")
errTokenExpired = errors.New("session token has expired")
errReadingToken = errors.New("session token internal data is malformed")
ErrTokenExpired = errors.New("session token has expired")
ErrReadingToken = errors.New("session token internal data is malformed")
)
// derivedKey is the key used to encrypt the session token claims, its derived using pbkdf on CONSOLE_PBKDF_PASSPHRASE with CONSOLE_PBKDF_SALT
@@ -101,12 +101,12 @@ func SessionTokenAuthenticate(token string) (*TokenClaims, error) {
decryptedToken, err := DecryptToken(token)
if err != nil {
// fail decrypting token
return nil, errReadingToken
return nil, ErrReadingToken
}
claimTokens, err := ParseClaimsFromToken(string(decryptedToken))
if err != nil {
// fail unmarshalling token into data structure
return nil, errReadingToken
return nil, ErrReadingToken
}
// claimsTokens contains the decrypted JWT for Console
return claimTokens, nil
@@ -321,7 +321,7 @@ func GetTokenFromRequest(r *http.Request) (string, error) {
}
currentTime := time.Now()
if tokenCookie.Expires.After(currentTime) {
return "", errTokenExpired
return "", ErrTokenExpired
}
return strings.TrimSpace(tokenCookie.Value), nil
}

View File

@@ -27,6 +27,7 @@ import {
globalSetDistributedSetup,
operatorMode,
selOpMode,
setAnonymousMode,
setOverrideStyles,
setSiteReplicationInfo,
userLogged,
@@ -48,7 +49,9 @@ const ProtectedRoute = ({ Component }: ProtectedRouteProps) => {
const [sessionLoading, setSessionLoading] = useState<boolean>(true);
const userLoggedIn = useSelector((state: AppState) => state.system.loggedIn);
const anonymousMode = useSelector(
(state: AppState) => state.system.anonymousMode
);
const { pathname = "" } = useLocation();
const StorePathAndRedirect = () => {
@@ -56,6 +59,9 @@ const ProtectedRoute = ({ Component }: ProtectedRouteProps) => {
return <Navigate to={{ pathname: `login` }} />;
};
const pathnameParts = pathname.split("/");
const screen = pathnameParts.length > 2 ? pathnameParts[1] : "";
useEffect(() => {
api
.invoke("GET", `/api/v1/session`)
@@ -81,8 +87,37 @@ const ProtectedRoute = ({ Component }: ProtectedRouteProps) => {
}
}
})
.catch(() => setSessionLoading(false));
}, [dispatch]);
.catch(() => {
// if we are trying to browse, probe access to the requested prefix
if (screen === "browser") {
const bucket = pathnameParts.length >= 3 ? pathnameParts[2] : "";
// no bucket, no business
if (bucket === "") {
setSessionLoading(false);
return;
}
// before marking the session as done, let's check if the bucket is publicly accessible
api
.invoke(
"GET",
`/api/v1/buckets/${bucket}/objects?limit=1`,
undefined,
{
"X-Anonymous": "1",
}
)
.then((value) => {
dispatch(setAnonymousMode());
setSessionLoading(false);
})
.catch(() => {
setSessionLoading(false);
});
} else {
setSessionLoading(false);
}
});
}, [dispatch, screen, pathnameParts]);
const [, invokeSRInfoApi] = useApi(
(res: any) => {
@@ -112,7 +147,7 @@ const ProtectedRoute = ({ Component }: ProtectedRouteProps) => {
);
useEffect(() => {
if (userLoggedIn && !sessionLoading && !isOperatorMode) {
if (userLoggedIn && !sessionLoading && !isOperatorMode && !anonymousMode) {
invokeSRInfoApi("GET", `api/v1/admin/site-replication`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -28,7 +28,9 @@ const hasPermission = (
return false;
}
const state = store.getState();
const sessionGrants = state.console.session.permissions || {};
const sessionGrants = state.console.session
? state.console.session.permissions || {}
: {};
const globalGrants = sessionGrants["arn:aws:s3:::*"] || [];
let resources: string[] = [];

View File

@@ -20,13 +20,23 @@ import { clearSession } from "../utils";
import { ErrorResponseHandler } from "../types";
import { baseUrl } from "../../history";
type RequestHeaders = { [name: string]: string };
export class API {
invoke(method: string, url: string, data?: object) {
invoke(method: string, url: string, data?: object, headers?: RequestHeaders) {
let targetURL = url;
if (targetURL[0] === "/") {
targetURL = targetURL.slice(1);
}
return request(method, targetURL)
let req = request(method, targetURL);
if (headers) {
for (let k in headers) {
req.set(k, headers[k]);
}
}
return req
.send(data)
.then((res) => res.body)
.catch((err) => {

View File

@@ -0,0 +1,88 @@
// 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 <http://www.gnu.org/licenses/>.
import React, { Fragment, Suspense } from "react";
import ObjectBrowser from "../Console/ObjectBrowser/ObjectBrowser";
import LoadingComponent from "../../common/LoadingComponent";
import ObjectManager from "../Console/Common/ObjectManager/ObjectManager";
import { ApplicationLogo } from "mds";
import { Route, Routes } from "react-router-dom";
import { IAM_PAGES } from "../../common/SecureComponent/permissions";
import { resetSession } from "../Console/consoleSlice";
import { useAppDispatch } from "../../store";
import { resetSystem } from "../../systemSlice";
import { getLogoVar } from "../../config";
import ObjectManagerButton from "../Console/Common/ObjectManager/ObjectManagerButton";
import { Button } from "@mui/material";
const AnonymousAccess = () => {
const dispatch = useAppDispatch();
return (
<Fragment>
<div
style={{
background:
"linear-gradient(90deg, rgba(16,47,81,1) 0%, rgba(13,28,64,1) 100%)",
height: 100,
width: "100%",
alignItems: "center",
display: "flex",
paddingLeft: 16,
paddingRight: 16,
}}
>
<div style={{ width: 200, flexShrink: 1 }}>
<ApplicationLogo
applicationName={"console"}
subVariant={getLogoVar()}
inverse={true}
/>
</div>
<div style={{ flexGrow: 1 }}></div>
<div style={{ flexShrink: 1, display: "flex", flexDirection: "row" }}>
<Button
id={"go-to-login"}
variant={"text"}
onClick={() => {
dispatch(resetSession());
dispatch(resetSystem());
}}
style={{ color: "white" }}
>
Login
</Button>
<ObjectManagerButton />
</div>
</div>
<Suspense fallback={<LoadingComponent />}>
<ObjectManager />
</Suspense>
<Routes>
<Route
path={`${IAM_PAGES.OBJECT_BROWSER_VIEW}/*`}
element={
<Suspense fallback={<LoadingComponent />}>
<ObjectBrowser />
</Suspense>
}
/>
</Routes>
</Fragment>
);
};
export default AnonymousAccess;

View File

@@ -16,26 +16,15 @@
import React, { Fragment, useCallback, useEffect } from "react";
import { useSelector } from "react-redux";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useLocation, useParams } from "react-router-dom";
import { Theme } from "@mui/material/styles";
import createStyles from "@mui/styles/createStyles";
import withStyles from "@mui/styles/withStyles";
import { Grid } from "@mui/material";
import { AppState, useAppDispatch } from "../../../../store";
import { containerForHeader } from "../../Common/FormComponents/common/styleLibrary";
import ListObjects from "../ListBuckets/Objects/ListObjects/ListObjects";
import PageHeader from "../../Common/PageHeader/PageHeader";
import { SettingsIcon } from "mds";
import { SecureComponent } from "../../../../common/SecureComponent";
import {
IAM_PAGES,
IAM_PERMISSIONS,
IAM_ROLES,
IAM_SCOPES,
} from "../../../../common/SecureComponent/permissions";
import BackLink from "../../../../common/BackLink";
import { IAM_SCOPES } from "../../../../common/SecureComponent/permissions";
import {
newMessage,
resetMessages,
@@ -50,17 +39,10 @@ import {
setLockingEnabled,
setObjectDetailsView,
setRecords,
setSearchObjects,
setSearchVersions,
setSelectedObjectView,
setSimplePathHandler,
setVersionsModeEnabled,
} from "../../ObjectBrowser/objectBrowserSlice";
import SearchBox from "../../Common/SearchBox";
import { selFeatures } from "../../consoleSlice";
import AutoColorIcon from "../../Common/Components/AutoColorIcon";
import TooltipWrapper from "../../Common/TooltipWrapper/TooltipWrapper";
import { Button } from "mds";
import hasPermission from "../../../../common/SecureComponent/accessControl";
import { IMessageEvent } from "websocket";
import { wsProtocol } from "../../../../utils/wsUtils";
@@ -74,6 +56,7 @@ import { setErrorSnackMessage } from "../../../../systemSlice";
import api from "../../../../common/api";
import { BucketObjectLocking, BucketVersioning } from "../types";
import { ErrorResponseHandler } from "../../../../common/types";
import OBHeader from "../../ObjectBrowser/OBHeader";
const styles = (theme: Theme) =>
createStyles({
@@ -145,7 +128,6 @@ const initWSConnection = (
const BrowserHandler = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const params = useParams();
const location = useLocation();
@@ -153,18 +135,6 @@ const BrowserHandler = () => {
(state: AppState) => state.objectBrowser.loadingVersioning
);
const versionsMode = useSelector(
(state: AppState) => state.objectBrowser.versionsMode
);
const searchObjects = useSelector(
(state: AppState) => state.objectBrowser.searchObjects
);
const versionedFile = useSelector(
(state: AppState) => state.objectBrowser.versionedFile
);
const searchVersions = useSelector(
(state: AppState) => state.objectBrowser.searchVersions
);
const rewindEnabled = useSelector(
(state: AppState) => state.objectBrowser.rewind.rewindEnabled
);
@@ -195,15 +165,14 @@ const BrowserHandler = () => {
const isOpeningOD = useSelector(
(state: AppState) => state.objectBrowser.isOpeningObjectDetail
);
const features = useSelector(selFeatures);
const anonymousMode = useSelector(
(state: AppState) => state.system.anonymousMode
);
const bucketName = params.bucketName || "";
const pathSegment = location.pathname.split(`/browser/${bucketName}/`);
const internalPaths = pathSegment.length === 2 ? pathSegment[1] : "";
const obOnly = !!features?.includes("object-browser-only");
/*WS Request Handlers*/
const onMessageCallBack = useCallback(
(message: IMessageEvent) => {
@@ -297,7 +266,6 @@ const BrowserHandler = () => {
const dupRequest = () => {
initWSRequest(path, date);
};
initWSConnection(dupRequest, onMessageCallBack);
}
},
@@ -377,10 +345,11 @@ const BrowserHandler = () => {
simplePath,
]);
const displayListObjects = hasPermission(bucketName, [
IAM_SCOPES.S3_LIST_BUCKET,
IAM_SCOPES.S3_ALL_LIST_BUCKET,
]);
const displayListObjects =
hasPermission(bucketName, [
IAM_SCOPES.S3_LIST_BUCKET,
IAM_SCOPES.S3_ALL_LIST_BUCKET,
]) || anonymousMode;
// Common objects list
useEffect(() => {
@@ -408,7 +377,6 @@ const BrowserHandler = () => {
if (rewindEnabled && rewindDate) {
requestDate = rewindDate;
}
initWSRequest(pathPrefix, requestDate);
} else {
dispatch(setLoadingObjects(false));
@@ -429,7 +397,7 @@ const BrowserHandler = () => {
}, [internalPaths, dispatch]);
useEffect(() => {
if (loadingVersioning) {
if (loadingVersioning && !anonymousMode) {
if (displayListObjects) {
api
.invoke("GET", `/api/v1/buckets/${bucketName}/versioning`)
@@ -449,7 +417,13 @@ const BrowserHandler = () => {
dispatch(resetMessages());
}
}
}, [bucketName, loadingVersioning, dispatch, displayListObjects]);
}, [
bucketName,
loadingVersioning,
dispatch,
displayListObjects,
anonymousMode,
]);
useEffect(() => {
if (loadingLocking) {
@@ -497,127 +471,10 @@ const BrowserHandler = () => {
}
}, [bucketName, loadingLocking, dispatch, displayListObjects]);
const openBucketConfiguration = () => {
navigate(`/buckets/${bucketName}/admin`);
};
const configureBucketAllowed = hasPermission(bucketName, [
IAM_SCOPES.S3_GET_BUCKET_POLICY,
IAM_SCOPES.S3_PUT_BUCKET_POLICY,
IAM_SCOPES.S3_GET_BUCKET_VERSIONING,
IAM_SCOPES.S3_PUT_BUCKET_VERSIONING,
IAM_SCOPES.S3_GET_BUCKET_ENCRYPTION_CONFIGURATION,
IAM_SCOPES.S3_PUT_BUCKET_ENCRYPTION_CONFIGURATION,
IAM_SCOPES.S3_DELETE_BUCKET,
IAM_SCOPES.S3_GET_BUCKET_NOTIFICATIONS,
IAM_SCOPES.S3_PUT_BUCKET_NOTIFICATIONS,
IAM_SCOPES.S3_GET_REPLICATION_CONFIGURATION,
IAM_SCOPES.S3_PUT_REPLICATION_CONFIGURATION,
IAM_SCOPES.S3_GET_LIFECYCLE_CONFIGURATION,
IAM_SCOPES.S3_PUT_LIFECYCLE_CONFIGURATION,
IAM_SCOPES.ADMIN_GET_BUCKET_QUOTA,
IAM_SCOPES.ADMIN_SET_BUCKET_QUOTA,
IAM_SCOPES.S3_PUT_BUCKET_TAGGING,
IAM_SCOPES.S3_GET_BUCKET_TAGGING,
IAM_SCOPES.S3_LIST_BUCKET_VERSIONS,
IAM_SCOPES.S3_GET_BUCKET_POLICY_STATUS,
IAM_SCOPES.S3_DELETE_BUCKET_POLICY,
IAM_SCOPES.S3_GET_ACTIONS,
IAM_SCOPES.S3_PUT_ACTIONS,
]);
const searchBar = (
<Fragment>
{!versionsMode ? (
<SecureComponent
scopes={[IAM_SCOPES.S3_LIST_BUCKET, IAM_SCOPES.S3_ALL_LIST_BUCKET]}
resource={bucketName}
errorProps={{ disabled: true }}
>
<SearchBox
placeholder={"Start typing to filter objects in the bucket"}
onChange={(value) => {
dispatch(setSearchObjects(value));
}}
value={searchObjects}
/>
</SecureComponent>
) : (
<Fragment>
<SearchBox
placeholder={`Start typing to filter versions of ${versionedFile}`}
onChange={(value) => {
dispatch(setSearchVersions(value));
}}
value={searchVersions}
/>
</Fragment>
)}
</Fragment>
);
return (
<Fragment>
{!obOnly ? (
<PageHeader
label={
<BackLink
label={"Object Browser"}
to={IAM_PAGES.OBJECT_BROWSER_VIEW}
/>
}
actions={
<SecureComponent
scopes={IAM_PERMISSIONS[IAM_ROLES.BUCKET_ADMIN]}
resource={bucketName}
errorProps={{ disabled: true }}
>
<TooltipWrapper
tooltip={
configureBucketAllowed
? "Configure Bucket"
: "You do not have the required permissions to configure this bucket. Please contact your MinIO administrator to request " +
IAM_ROLES.BUCKET_ADMIN +
" permisions."
}
>
<Button
id={"configure-bucket-main"}
color="primary"
aria-label="Configure Bucket"
onClick={openBucketConfiguration}
icon={
<SettingsIcon
style={{ width: 20, height: 20, marginTop: -3 }}
/>
}
style={{
padding: "0 10px",
}}
/>
</TooltipWrapper>
</SecureComponent>
}
middleComponent={searchBar}
/>
) : (
<Grid
container
sx={{
padding: "20px 32px 0",
}}
>
<Grid>
<AutoColorIcon marginRight={30} marginTop={10} />
</Grid>
<Grid item xs>
{searchBar}
</Grid>
</Grid>
)}
<Grid>
<ListObjects />
</Grid>
{!anonymousMode && <OBHeader bucketName={bucketName} />}
<ListObjects />
</Fragment>
);
};

View File

@@ -241,9 +241,6 @@ const BucketDetails = ({ classes }: IBucketDetailsProps) => {
<PageLayout className={classes.pageContainer}>
<Grid item xs={12}>
<ScreenTitle
classes={{
screenTitle: classes.screenTitle,
}}
icon={
<Fragment>
<BucketsIcon width={40} />

View File

@@ -26,7 +26,16 @@ import { useSelector } from "react-redux";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useDropzone } from "react-dropzone";
import { Theme } from "@mui/material/styles";
import { Button } from "mds";
import {
BucketsIcon,
Button,
DeleteIcon,
DownloadIcon,
HistoryIcon,
PreviewIcon,
RefreshIcon,
ShareIcon,
} from "mds";
import { DateTime } from "luxon";
import createStyles from "@mui/styles/createStyles";
import Grid from "@mui/material/Grid";
@@ -68,15 +77,6 @@ import {
SecureComponent,
} from "../../../../../../common/SecureComponent";
import withSuspense from "../../../../Common/Components/withSuspense";
import {
BucketsIcon,
DownloadIcon,
PreviewIcon,
ShareIcon,
HistoryIcon,
RefreshIcon,
DeleteIcon,
} from "mds";
import UploadFilesButton from "../../UploadFilesButton";
import DetailsListPanel from "./DetailsListPanel";
import ObjectDetailPanel from "./ObjectDetailPanel";
@@ -136,6 +136,8 @@ import {
openShare,
} from "../../../../ObjectBrowser/objectBrowserThunks";
import FilterObjectsSB from "../../../../ObjectBrowser/FilterObjectsSB";
const DeleteMultipleObjects = withSuspense(
React.lazy(() => import("./DeleteMultipleObjects"))
);
@@ -158,12 +160,6 @@ const useStyles = makeStyles((theme: Theme) =>
minWidth: 5,
},
},
screenTitle: {
borderBottom: 0,
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
},
...tableStyles,
...actionsTray,
...searchField,
@@ -174,7 +170,6 @@ const useStyles = makeStyles((theme: Theme) =>
},
screenTitleContainer: {
border: "#EAEDEE 1px solid",
padding: "0.8rem 15px 0",
},
labelStyle: {
color: "#969FA8",
@@ -194,6 +189,11 @@ const useStyles = makeStyles((theme: Theme) =>
display: "none",
},
},
actionsSection: {
display: "flex",
justifyContent: "space-between",
width: "100%",
},
...objectBrowserExtras,
...objectBrowserCommon,
...containerForHeader(theme.spacing(4)),
@@ -273,6 +273,9 @@ const ListObjects = () => {
const selectedBucket = useSelector(
(state: AppState) => state.objectBrowser.selectedBucket
);
const anonymousMode = useSelector(
(state: AppState) => state.system.anonymousMode
);
const loadingBucket = useSelector(selBucketDetailsLoading);
const bucketInfo = useSelector(selBucketDetailsInfo);
@@ -305,12 +308,13 @@ const ListObjects = () => {
IAM_SCOPES.S3_GET_ACTIONS,
]);
const canDelete = hasPermission(bucketName, [IAM_SCOPES.S3_DELETE_OBJECT]);
const canUpload = hasPermission(
uploadPath,
[IAM_SCOPES.S3_PUT_OBJECT, IAM_SCOPES.S3_PUT_ACTIONS],
true,
true
);
const canUpload =
hasPermission(
uploadPath,
[IAM_SCOPES.S3_PUT_OBJECT, IAM_SCOPES.S3_PUT_ACTIONS],
true,
true
) || anonymousMode;
const displayDeleteObject = hasPermission(bucketName, [
IAM_SCOPES.S3_DELETE_OBJECT,
@@ -365,7 +369,7 @@ const ListObjects = () => {
}, [selectedObjects]);
useEffect(() => {
if (!quota) {
if (!quota && !anonymousMode) {
api
.invoke("GET", `/api/v1/buckets/${bucketName}/quota`)
.then((res: BucketQuota) => {
@@ -382,7 +386,7 @@ const ListObjects = () => {
setQuota(null);
});
}
}, [quota, bucketName]);
}, [quota, bucketName, anonymousMode]);
useEffect(() => {
if (selectedObjects.length > 0) {
@@ -408,7 +412,7 @@ const ListObjects = () => {
// bucket info
useEffect(() => {
if (loadingBucket) {
if (loadingBucket && !anonymousMode) {
api
.invoke("GET", `/api/v1/buckets/${bucketName}`)
.then((res: BucketInfo) => {
@@ -421,7 +425,7 @@ const ListObjects = () => {
dispatch(setErrorSnackMessage(err));
});
}
}, [bucketName, loadingBucket, dispatch]);
}, [bucketName, loadingBucket, dispatch, anonymousMode]);
// Load retention Config
@@ -538,6 +542,10 @@ const ListObjects = () => {
let xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl, true);
if (anonymousMode) {
xhr.setRequestHeader("X-Anonymous", "1");
}
// xhr.setRequestHeader("X-Anonymous", "1");
const areMultipleFiles = files.length > 1;
let errorMessage = `An error occurred while uploading the file${
@@ -677,7 +685,7 @@ const ListObjects = () => {
upload(files, bucketName, pathPrefix, folderPath);
},
[bucketName, dispatch, simplePath]
[bucketName, dispatch, simplePath, anonymousMode]
);
const onDrop = useCallback(
@@ -879,60 +887,67 @@ const ListObjects = () => {
}}
/>
)}
<PageLayout variant={"full"}>
{anonymousMode && (
<div style={{ paddingBottom: 16 }}>
<FilterObjectsSB />
</div>
)}
<Grid item xs={12} className={classes.screenTitleContainer}>
<ScreenTitle
className={classes.screenTitle}
icon={
<span className={classes.listIcon}>
<BucketsIcon />
<span>
<BucketsIcon style={{ width: 30 }} />
</span>
}
title={<span className={classes.titleSpacer}>{bucketName}</span>}
subTitle={
<Fragment>
<Grid item xs={12} className={classes.bucketDetails}>
<span className={classes.detailsSpacer}>
Created on:&nbsp;&nbsp;
<strong>
{bucketInfo?.creation_date
? createdTime.toFormat(
"ccc, LLL dd yyyy HH:mm:ss (ZZZZ)"
)
: ""}
</strong>
</span>
<span className={classes.detailsSpacer}>
Access:&nbsp;&nbsp;&nbsp;
<strong>{bucketInfo?.access || ""}</strong>
</span>
{bucketInfo && (
<Fragment>
<span className={classes.detailsSpacer}>
{bucketInfo.size && (
<Fragment>{niceBytesInt(bucketInfo.size)}</Fragment>
)}
{bucketInfo.size && quota && (
<Fragment> / {niceBytesInt(quota.quota)}</Fragment>
)}
{bucketInfo.size && bucketInfo.objects ? " - " : ""}
{bucketInfo.objects && (
<Fragment>
{bucketInfo.objects}&nbsp;Object
{bucketInfo.objects && bucketInfo.objects !== 1
? "s"
: ""}
</Fragment>
)}
</span>
</Fragment>
)}
</Grid>
</Fragment>
!anonymousMode ? (
<Fragment>
<Grid item xs={12} className={classes.bucketDetails}>
<span className={classes.detailsSpacer}>
Created on:&nbsp;&nbsp;
<strong>
{bucketInfo?.creation_date
? createdTime.toFormat(
"ccc, LLL dd yyyy HH:mm:ss (ZZZZ)"
)
: ""}
</strong>
</span>
<span className={classes.detailsSpacer}>
Access:&nbsp;&nbsp;&nbsp;
<strong>{bucketInfo?.access || ""}</strong>
</span>
{bucketInfo && (
<Fragment>
<span className={classes.detailsSpacer}>
{bucketInfo.size && (
<Fragment>{niceBytesInt(bucketInfo.size)}</Fragment>
)}
{bucketInfo.size && quota && (
<Fragment> / {niceBytesInt(quota.quota)}</Fragment>
)}
{bucketInfo.size && bucketInfo.objects ? " - " : ""}
{bucketInfo.objects && (
<Fragment>
{bucketInfo.objects}&nbsp;Object
{bucketInfo.objects && bucketInfo.objects !== 1
? "s"
: ""}
</Fragment>
)}
</span>
</Fragment>
)}
</Grid>
</Fragment>
) : null
}
actions={
<Fragment>
<div className={classes.actionsSection}>
<div className={classes.actionsSection}>
{!anonymousMode && (
<TooltipWrapper tooltip={"Rewind Bucket"}>
<Button
id={"rewind-objects-list"}
@@ -970,61 +985,63 @@ const ListObjects = () => {
}
/>
</TooltipWrapper>
<TooltipWrapper tooltip={"Reload List"}>
<Button
id={"refresh-objects-list"}
label={"Refresh"}
icon={<RefreshIcon />}
variant={"regular"}
onClick={() => {
if (versionsMode) {
dispatch(setLoadingVersions(true));
} else {
dispatch(resetMessages());
dispatch(setLoadingRecords(true));
dispatch(setLoadingObjects(true));
}
}}
disabled={
!hasPermission(bucketName, [
IAM_SCOPES.S3_LIST_BUCKET,
IAM_SCOPES.S3_ALL_LIST_BUCKET,
]) || rewindEnabled
)}
<TooltipWrapper tooltip={"Reload List"}>
<Button
id={"refresh-objects-list"}
label={"Refresh"}
icon={<RefreshIcon />}
variant={"regular"}
onClick={() => {
if (versionsMode) {
dispatch(setLoadingVersions(true));
} else {
dispatch(resetMessages());
dispatch(setLoadingRecords(true));
dispatch(setLoadingObjects(true));
}
/>
</TooltipWrapper>
<input
type="file"
multiple
onChange={handleUploadButton}
style={{ display: "none" }}
ref={fileUpload}
/>
<input
type="file"
multiple
onChange={handleUploadButton}
style={{ display: "none" }}
ref={folderUpload}
/>
<UploadFilesButton
bucketName={bucketName}
uploadPath={uploadPath.join("/")}
uploadFileFunction={(closeMenu) => {
if (fileUpload && fileUpload.current) {
fileUpload.current.click();
}
closeMenu();
}}
uploadFolderFunction={(closeMenu) => {
if (folderUpload && folderUpload.current) {
folderUpload.current.click();
}
closeMenu();
}}
disabled={
anonymousMode
? false
: !hasPermission(bucketName, [
IAM_SCOPES.S3_LIST_BUCKET,
IAM_SCOPES.S3_ALL_LIST_BUCKET,
]) || rewindEnabled
}
/>
</div>
</Fragment>
</TooltipWrapper>
<input
type="file"
multiple
onChange={handleUploadButton}
style={{ display: "none" }}
ref={fileUpload}
/>
<input
type="file"
multiple
onChange={handleUploadButton}
style={{ display: "none" }}
ref={folderUpload}
/>
<UploadFilesButton
bucketName={bucketName}
uploadPath={uploadPath.join("/")}
uploadFileFunction={(closeMenu) => {
if (fileUpload && fileUpload.current) {
fileUpload.current.click();
}
closeMenu();
}}
uploadFolderFunction={(closeMenu) => {
if (folderUpload && folderUpload.current) {
folderUpload.current.click();
}
closeMenu();
}}
/>
</div>
}
/>
</Grid>
@@ -1058,66 +1075,70 @@ const ListObjects = () => {
errorProps={{ disabled: true }}
>
<Grid item xs={12} className={classes.fullContainer}>
<Grid item xs={12} className={classes.breadcrumbsContainer}>
<BrowserBreadcrumbs
bucketName={bucketName}
internalPaths={pageTitle}
additionalOptions={
!isVersioned || rewindEnabled ? null : (
<div>
<CheckboxWrapper
name={"deleted_objects"}
id={"showDeletedObjects"}
value={"deleted_on"}
label={"Show deleted objects"}
onChange={setDeletedAction}
checked={showDeleted}
overrideLabelClasses={classes.labelStyle}
className={classes.overrideShowDeleted}
noTopMargin
/>
</div>
)
}
hidePathButton={false}
/>
</Grid>
<ListObjectsTable />
{!anonymousMode && (
<Grid item xs={12} className={classes.breadcrumbsContainer}>
<BrowserBreadcrumbs
bucketName={bucketName}
internalPaths={pageTitle}
additionalOptions={
!isVersioned || rewindEnabled ? null : (
<div>
<CheckboxWrapper
name={"deleted_objects"}
id={"showDeletedObjects"}
value={"deleted_on"}
label={"Show deleted objects"}
onChange={setDeletedAction}
checked={showDeleted}
overrideLabelClasses={classes.labelStyle}
className={classes.overrideShowDeleted}
noTopMargin
/>
</div>
)
}
hidePathButton={false}
/>
</Grid>
)}
<ListObjectsTable internalPaths={selectedInternalPaths} />
</Grid>
</SecureComponent>
)}
<SecureComponent
scopes={[
IAM_SCOPES.S3_LIST_BUCKET,
IAM_SCOPES.S3_ALL_LIST_BUCKET,
]}
resource={bucketName}
errorProps={{ disabled: true }}
>
<DetailsListPanel
open={detailsOpen}
closePanel={() => {
onClosePanel(false);
}}
className={`${versionsMode ? classes.hideListOnSmall : ""}`}
{!anonymousMode && (
<SecureComponent
scopes={[
IAM_SCOPES.S3_LIST_BUCKET,
IAM_SCOPES.S3_ALL_LIST_BUCKET,
]}
resource={bucketName}
errorProps={{ disabled: true }}
>
{selectedObjects.length > 0 && (
<ActionsListSection
items={multiActionButtons}
title={"Selected Objects:"}
/>
)}
{selectedInternalPaths !== null && (
<ObjectDetailPanel
internalPaths={selectedInternalPaths}
bucketName={bucketName}
onClosePanel={onClosePanel}
versioning={isVersioned}
locking={lockingEnabled}
/>
)}
</DetailsListPanel>
</SecureComponent>
<DetailsListPanel
open={detailsOpen}
closePanel={() => {
onClosePanel(false);
}}
className={`${versionsMode ? classes.hideListOnSmall : ""}`}
>
{selectedObjects.length > 0 && (
<ActionsListSection
items={multiActionButtons}
title={"Selected Objects:"}
/>
)}
{selectedInternalPaths !== null && (
<ObjectDetailPanel
internalPaths={selectedInternalPaths}
bucketName={bucketName}
onClosePanel={onClosePanel}
versioning={isVersioned}
locking={lockingEnabled}
/>
)}
</DetailsListPanel>
</SecureComponent>
)}
</Grid>
</div>
</PageLayout>

View File

@@ -43,6 +43,8 @@ import {
permissionTooltipHelper,
} from "../../../../../../common/SecureComponent/permissions";
import { hasPermission } from "../../../../../../common/SecureComponent";
import { downloadObject } from "../../../../ObjectBrowser/utils";
import { IFileInfo } from "../ObjectDetails/types";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@@ -77,7 +79,11 @@ const useStyles = makeStyles((theme: Theme) =>
})
);
const ListObjectsTable = () => {
interface IListObjectTable {
internalPaths: string | null;
}
const ListObjectsTable = ({ internalPaths }: IListObjectTable) => {
const classes = useStyles();
const dispatch = useAppDispatch();
const params = useParams();
@@ -111,7 +117,9 @@ const ListObjectsTable = () => {
const selectedObjects = useSelector(
(state: AppState) => state.objectBrowser.selectedObjects
);
const anonymousMode = useSelector(
(state: AppState) => state.system.anonymousMode
);
const displayListObjects = hasPermission(bucketName, [
IAM_SCOPES.S3_LIST_BUCKET,
IAM_SCOPES.S3_ALL_LIST_BUCKET,
@@ -141,29 +149,43 @@ const ListObjectsTable = () => {
payload = sortASC.reverse();
}
const openPath = (idElement: string) => {
dispatch(setSelectedObjects([]));
const openPath = (object: IFileInfo) => {
const idElement = object.name;
const newPath = `/browser/${bucketName}${
idElement ? `/${encodeURLString(idElement)}` : ``
}`;
// for anonymous start download
if (anonymousMode && internalPaths !== null && !object.name.endsWith("/")) {
downloadObject(
dispatch,
bucketName,
`${encodeURLString(idElement)}`,
object
);
return;
}
dispatch(setSelectedObjects([]));
navigate(newPath);
dispatch(setObjectDetailsView(true));
dispatch(setLoadingVersions(true));
if (!anonymousMode) {
dispatch(setObjectDetailsView(true));
dispatch(setLoadingVersions(true));
dispatch(setIsOpeningOD(true));
}
dispatch(
setSelectedObjectView(
`${idElement ? `${encodeURLString(idElement)}` : ``}`
)
);
dispatch(setIsOpeningOD(true));
};
const tableActions: ItemActions[] = [
{
type: "view",
label: "View",
onClick: openPath,
sendOnlyId: true,
sendOnlyId: false,
},
];
@@ -218,9 +240,9 @@ const ListObjectsTable = () => {
obOnly ? "isEmbedded" : ""
} ${detailsOpen ? "actionsPanelOpen" : ""}`}
selectedItems={selectedObjects}
onSelect={selectListObjects}
onSelect={!anonymousMode ? selectListObjects : undefined}
customEmptyMessage={
!displayListObjects
!displayListObjects && !anonymousMode
? permissionTooltipHelper(
[IAM_SCOPES.S3_LIST_BUCKET, IAM_SCOPES.S3_ALL_LIST_BUCKET],
"view Objects in this bucket"

View File

@@ -18,7 +18,21 @@ import React, { Fragment, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { Box } from "@mui/material";
import { withStyles } from "@mui/styles";
import { Button } from "mds";
import {
Button,
DeleteIcon,
DownloadIcon,
InspectMenuIcon,
LegalHoldIcon,
Loader,
MetadataIcon,
ObjectInfoIcon,
PreviewIcon,
RetentionIcon,
ShareIcon,
TagsIcon,
VersionsIcon,
} from "mds";
import createStyles from "@mui/styles/createStyles";
import get from "lodash/get";
import Grid from "@mui/material/Grid";
@@ -30,13 +44,11 @@ import {
textStyleUtils,
} from "../../../../Common/FormComponents/common/styleLibrary";
import { IFileInfo, MetadataResponse } from "../ObjectDetails/types";
import { download, extensionPreview } from "../utils";
import { extensionPreview } from "../utils";
import { ErrorResponseHandler } from "../../../../../../common/types";
import {
decodeURLString,
encodeURLString,
getClientOS,
niceBytes,
niceBytesInt,
niceDaysInt,
@@ -46,19 +58,6 @@ import {
permissionTooltipHelper,
} from "../../../../../../common/SecureComponent/permissions";
import { AppState, useAppDispatch } from "../../../../../../store";
import {
DeleteIcon,
DownloadIcon,
LegalHoldIcon,
MetadataIcon,
ObjectInfoIcon,
PreviewIcon,
RetentionIcon,
ShareIcon,
TagsIcon,
VersionsIcon,
} from "mds";
import { InspectMenuIcon } from "mds";
import api from "../../../../../../common/api";
import ShareFile from "../ObjectDetails/ShareFile";
import SetRetention from "../ObjectDetails/SetRetention";
@@ -74,25 +73,16 @@ import ActionsListSection from "./ActionsListSection";
import { displayFileIconName } from "./utils";
import TagsModal from "../ObjectDetails/TagsModal";
import InspectObject from "./InspectObject";
import { Loader } from "mds";
import { selDistSet } from "../../../../../../systemSlice";
import {
makeid,
storeCallForObjectWithID,
} from "../../../../ObjectBrowser/transferManager";
import {
cancelObjectInList,
completeObject,
failObject,
setLoadingObjectInfo,
setLoadingVersions,
setNewObject,
setSelectedVersion,
setVersionsModeEnabled,
updateProgress,
} from "../../../../ObjectBrowser/objectBrowserSlice";
import RenameLongFileName from "../../../../ObjectBrowser/RenameLongFilename";
import TooltipWrapper from "../../../../Common/TooltipWrapper/TooltipWrapper";
import { downloadObject } from "../../../../ObjectBrowser/utils";
const styles = () =>
createStyles({
@@ -325,65 +315,6 @@ const ObjectDetailPanel = ({
setLongFileOpen(false);
};
const downloadObject = (object: IFileInfo) => {
const identityDownload = encodeURLString(
`${bucketName}-${object.name}-${new Date().getTime()}-${Math.random()}`
);
if (
object.name.length > 200 &&
getClientOS().toLowerCase().includes("win")
) {
setLongFileOpen(true);
return;
}
const ID = makeid(8);
const downloadCall = download(
bucketName,
internalPaths,
object.version_id,
parseInt(object.size || "0"),
null,
ID,
(progress) => {
dispatch(
updateProgress({
instanceID: identityDownload,
progress: progress,
})
);
},
() => {
dispatch(completeObject(identityDownload));
},
(msg: string) => {
dispatch(failObject({ instanceID: identityDownload, msg }));
},
() => {
dispatch(cancelObjectInList(identityDownload));
}
);
storeCallForObjectWithID(ID, downloadCall);
dispatch(
setNewObject({
ID,
bucketName,
done: false,
instanceID: identityDownload,
percentage: 0,
prefix: object.name,
type: "download",
waitingForFile: true,
failed: false,
cancelled: false,
errorMessage: "",
})
);
};
const closeDeleteModal = (closeAndReload: boolean) => {
setDeleteOpen(false);
@@ -482,7 +413,7 @@ const ObjectDetailPanel = ({
const multiActionButtons = [
{
action: () => {
downloadObject(actualInfo);
downloadObject(dispatch, bucketName, internalPaths, actualInfo);
},
label: "Download",
disabled: !!actualInfo.is_delete_marker || !canGetObject,

View File

@@ -18,6 +18,7 @@ import { BucketObjectItem } from "./ListObjects/types";
import { IAllowResources } from "../../../types";
import { encodeURLString } from "../../../../../common/utils";
import { removeTrace } from "../../../ObjectBrowser/transferManager";
import store from "../../../../../store";
export const download = (
bucketName: string,
@@ -34,6 +35,8 @@ export const download = (
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;
let path = `${
window.location.origin
@@ -48,6 +51,9 @@ export const download = (
var req = new XMLHttpRequest();
req.open("GET", path, true);
if (anonymousMode) {
req.setRequestHeader("X-Anonymous", "1");
}
req.addEventListener(
"progress",
function (evt) {

View File

@@ -14,21 +14,22 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React, { Fragment } from "react";
import React, { Fragment, useState } from "react";
import { Theme } from "@mui/material/styles";
import { Menu, MenuItem } from "@mui/material";
import createStyles from "@mui/styles/createStyles";
import withStyles from "@mui/styles/withStyles";
import ListItemText from "@mui/material/ListItemText";
import ListItemIcon from "@mui/material/ListItemIcon";
import { UploadFolderIcon, UploadIcon } from "mds";
import { Button, UploadFolderIcon, UploadIcon } from "mds";
import {
IAM_SCOPES,
permissionTooltipHelper,
} from "../../../../common/SecureComponent/permissions";
import { hasPermission } from "../../../../common/SecureComponent";
import { Button } from "mds";
import TooltipWrapper from "../../Common/TooltipWrapper/TooltipWrapper";
import { useSelector } from "react-redux";
import { AppState } from "../../../../store";
interface IUploadFilesButton {
uploadPath: string;
@@ -58,7 +59,10 @@ const UploadFilesButton = ({
uploadFolderFunction,
classes,
}: IUploadFilesButton) => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const anonymousMode = useSelector(
(state: AppState) => state.system.anonymousMode
);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const openUploadMenu = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -67,10 +71,11 @@ const UploadFilesButton = ({
setAnchorEl(null);
};
const uploadObjectAllowed = hasPermission(uploadPath, [
IAM_SCOPES.S3_PUT_OBJECT,
IAM_SCOPES.S3_PUT_ACTIONS,
]);
const uploadObjectAllowed =
hasPermission(uploadPath, [
IAM_SCOPES.S3_PUT_OBJECT,
IAM_SCOPES.S3_PUT_ACTIONS,
]) || anonymousMode;
const uploadFolderAllowed = hasPermission(
bucketName,
[IAM_SCOPES.S3_PUT_OBJECT, IAM_SCOPES.S3_PUT_ACTIONS],

View File

@@ -456,12 +456,6 @@ export const objectBrowserCommon = {
flexDirection: "row" as const,
},
},
actionsSection: {
display: "flex",
justifyContent: "space-between",
width: "100%",
marginTop: 15,
},
};
// ** According to W3 spec, default minimum values for flex width flex-grow is "auto" (https://drafts.csswg.org/css-flexbox/#min-size-auto). So in this case we need to enforce the use of an absolute width.

View File

@@ -27,6 +27,7 @@ import {
cleanList,
deleteFromList,
} from "../../ObjectBrowser/objectBrowserSlice";
import clsx from "clsx";
const styles = (theme: Theme) =>
createStyles({
@@ -36,7 +37,7 @@ const styles = (theme: Theme) =>
backgroundColor: "#fff",
position: "absolute",
right: 20,
top: 60,
top: 62,
width: 400,
overflowY: "hidden",
overflowX: "hidden",
@@ -51,6 +52,9 @@ const styles = (theme: Theme) =>
minHeight: 400,
},
},
downloadContainerAnonymous: {
top: 70,
},
title: {
fontSize: 16,
fontWeight: "bold",
@@ -94,13 +98,17 @@ const ObjectManager = ({ classes }: IObjectManager) => {
const managerOpen = useSelector(
(state: AppState) => state.objectBrowser.objectManager.managerOpen
);
const anonymousMode = useSelector(
(state: AppState) => state.system.anonymousMode
);
return (
<Fragment>
{managerOpen && (
<div
className={`${classes.downloadContainer} ${
managerOpen ? "open" : ""
}`}
className={clsx(classes.downloadContainer, {
[classes.downloadContainerAnonymous]: anonymousMode,
open: managerOpen,
})}
>
<div className={classes.cleanIcon}>
<Tooltip title={"Clean Completed Objects"} placement="bottom-start">

View File

@@ -0,0 +1,107 @@
// 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 <http://www.gnu.org/licenses/>.
import React, { Fragment, useEffect, useState } from "react";
import { Button, CircleIcon, ObjectManagerIcon } from "mds";
import { toggleList } from "../../ObjectBrowser/objectBrowserSlice";
import { AppState, useAppDispatch } from "../../../../store";
import { useSelector } from "react-redux";
import makeStyles from "@mui/styles/makeStyles";
const useStyles = makeStyles((theme) => ({
indicator: {
position: "absolute",
display: "block",
width: 15,
height: 15,
top: 0,
right: 4,
marginTop: 4,
transitionDuration: "0.2s",
color: "#32C787",
"& svg": {
width: 10,
height: 10,
top: "50%",
left: "50%",
transitionDuration: "0.2s",
},
"&.newItem": {
color: "#2781B0",
"& svg": {
width: 15,
height: 15,
},
},
},
}));
const ObjectManagerButton = () => {
const dispatch = useAppDispatch();
const classes = useStyles();
const managerObjects = useSelector(
(state: AppState) => state.objectBrowser.objectManager.objectsToManage
);
const newItems = useSelector(
(state: AppState) => state.objectBrowser.objectManager.newItems
);
const managerOpen = useSelector(
(state: AppState) => state.objectBrowser.objectManager.managerOpen
);
const [newObject, setNewObject] = useState<boolean>(false);
useEffect(() => {
if (managerObjects.length > 0 && !managerOpen) {
setNewObject(true);
setTimeout(() => {
setNewObject(false);
}, 300);
}
}, [managerObjects.length, managerOpen]);
return (
<Fragment>
{managerObjects && managerObjects.length > 0 && (
<Button
aria-label="Refresh List"
onClick={() => {
dispatch(toggleList());
}}
icon={
<Fragment>
<div
className={`${classes.indicator} ${newObject ? "newItem" : ""}`}
style={{
opacity: managerObjects.length > 0 && newItems ? 1 : 0,
}}
>
<CircleIcon />
</div>
<ObjectManagerIcon
style={{ width: 20, height: 20, marginTop: -2 }}
/>
</Fragment>
}
id="object-manager-toggle"
style={{ position: "relative", padding: "0 10px" }}
/>
)}
</Fragment>
);
};
export default ObjectManagerButton;

View File

@@ -33,8 +33,8 @@ const TrafficMonitor = () => {
(state: AppState) => state.objectBrowser.objectManager.objectsToManage
);
const limitVars = useSelector(
(state: AppState) => state.console.session.envConstants
const limitVars = useSelector((state: AppState) =>
state.console.session ? state.console.session.envConstants : null
);
const currentDIP = useSelector(

View File

@@ -14,20 +14,20 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React, { Fragment, useEffect, useState } from "react";
import React, { Fragment } from "react";
import { Theme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import Grid from "@mui/material/Grid";
import createStyles from "@mui/styles/createStyles";
import withStyles from "@mui/styles/withStyles";
import { AppState, useAppDispatch } from "../../../../store";
import { AppState } from "../../../../store";
import { CircleIcon, ObjectManagerIcon } from "mds";
import { ApplicationLogo } from "mds";
import { Box } from "@mui/material";
import { toggleList } from "../../ObjectBrowser/objectBrowserSlice";
import { selFeatures } from "../../consoleSlice";
import { selDirectPVMode, selOpMode } from "../../../../systemSlice";
import { ApplicationLogo, Button } from "mds";
import ObjectManagerButton from "../ObjectManager/ObjectManagerButton";
import { getLogoVar } from "../../../../config";
const styles = (theme: Theme) =>
createStyles({
@@ -63,31 +63,6 @@ const styles = (theme: Theme) =>
justifyContent: "center",
alignItems: "center",
},
indicator: {
position: "absolute",
display: "block",
width: 15,
height: 15,
top: 0,
right: 4,
marginTop: 4,
transitionDuration: "0.2s",
color: "#32C787",
"& svg": {
width: 10,
height: 10,
top: "50%",
left: "50%",
transitionDuration: "0.2s",
},
"&.newItem": {
color: "#2781B0",
"& svg": {
width: 15,
height: 15,
},
},
},
});
interface IPageHeader {
@@ -103,45 +78,12 @@ const PageHeader = ({
actions,
middleComponent,
}: IPageHeader) => {
const dispatch = useAppDispatch();
const sidebarOpen = useSelector(
(state: AppState) => state.system.sidebarOpen
);
const operatorMode = useSelector(selOpMode);
const directPVMode = useSelector(selDirectPVMode);
const managerObjects = useSelector(
(state: AppState) => state.objectBrowser.objectManager.objectsToManage
);
const features = useSelector(selFeatures);
const managerOpen = useSelector(
(state: AppState) => state.objectBrowser.objectManager.managerOpen
);
const newItems = useSelector(
(state: AppState) => state.objectBrowser.objectManager.newItems
);
const licenseInfo = useSelector(
(state: AppState) => state?.system?.licenseInfo
);
const [newObject, setNewObject] = useState<boolean>(false);
const { plan = "" } = licenseInfo || {};
let logoPlan = "AGPL";
if (plan === "STANDARD" || plan === "ENTERPRISE") {
logoPlan = plan.toLowerCase();
}
useEffect(() => {
if (managerObjects.length > 0 && !managerOpen) {
setNewObject(true);
setTimeout(() => {
setNewObject(false);
}, 300);
}
}, [managerObjects.length, managerOpen]);
if (features.includes("hide-menu")) {
return <Fragment />;
@@ -168,14 +110,7 @@ const PageHeader = ({
{!operatorMode && !directPVMode ? (
<ApplicationLogo
applicationName={"console"}
subVariant={
logoPlan as
| "AGPL"
| "simple"
| "standard"
| "enterprise"
| undefined
}
subVariant={getLogoVar()}
/>
) : (
<Fragment>
@@ -220,33 +155,7 @@ const PageHeader = ({
className={classes.rightMenu}
>
{actions && actions}
{managerObjects && managerObjects.length > 0 && (
<Button
aria-label="Refresh List"
onClick={() => {
dispatch(toggleList());
}}
icon={
<Fragment>
<div
className={`${classes.indicator} ${
newObject ? "newItem" : ""
}`}
style={{
opacity: managerObjects.length > 0 && newItems ? 1 : 0,
}}
>
<CircleIcon />
</div>
<ObjectManagerIcon
style={{ width: 20, height: 20, marginTop: -2 }}
/>
</Fragment>
}
id="object-manager-toggle"
style={{ position: "relative", padding: "0 10px" }}
/>
)}
<ObjectManagerButton />
</Grid>
</Grid>
);

View File

@@ -17,12 +17,9 @@
import React from "react";
import Grid from "@mui/material/Grid";
import { Theme } from "@mui/material/styles";
import createStyles from "@mui/styles/createStyles";
import withStyles from "@mui/styles/withStyles";
import makeStyles from "@mui/styles/makeStyles";
interface IScreenTitle {
classes: any;
icon?: any;
title?: any;
subTitle?: any;
@@ -30,80 +27,79 @@ interface IScreenTitle {
className?: any;
}
const styles = (theme: Theme) =>
createStyles({
headerBarIcon: {
marginRight: ".7rem",
color: theme.palette.primary.main,
"& .min-icon": {
width: 44,
height: 44,
},
"@media (max-width: 600px)": {
display: "none",
},
const useStyles = makeStyles((theme: Theme) => ({
headerBarIcon: {
marginRight: ".7rem",
color: theme.palette.primary.main,
"& .min-icon": {
width: 44,
height: 44,
},
headerBarSubheader: {
color: "grey",
"@media (max-width: 900px)": {
maxWidth: 200,
},
"@media (max-width: 600px)": {
display: "none",
},
screenTitle: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1rem",
},
headerBarSubheader: {
color: "grey",
"@media (max-width: 900px)": {
maxWidth: 200,
},
},
stContainer: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: 8,
borderBottom: "1px solid #EAEAEA",
"@media (max-width: 600px)": {
flexFlow: "column",
},
},
titleColumn: {
height: "auto",
justifyContent: "center",
display: "flex",
borderBottom: "1px solid #EAEAEA",
"@media (max-width: 600px)": {
flexFlow: "column",
alignItems: "flex-start",
"& h1": {
fontSize: 19,
},
},
leftItems: {
display: "flex",
alignItems: "center",
"@media (max-width: 600px)": {
flexFlow: "column",
width: "100%",
},
},
titleColumn: {
height: "auto",
justifyContent: "center",
display: "flex",
flexFlow: "column",
alignItems: "flex-start",
"& h1": {
fontSize: 19,
},
rightItems: {
display: "flex",
alignItems: "center",
"& button": {
marginLeft: 8,
},
"@media (max-width: 600px)": {
width: "100%",
},
},
leftItems: {
display: "flex",
alignItems: "center",
"@media (max-width: 600px)": {
flexFlow: "column",
width: "100%",
},
});
},
rightItems: {
display: "flex",
alignItems: "center",
"& button": {
marginLeft: 8,
},
"@media (max-width: 600px)": {
width: "100%",
},
},
}));
const ScreenTitle = ({
classes,
icon,
title,
subTitle,
actions,
className,
}: IScreenTitle) => {
const classes = useStyles();
return (
<Grid container>
<Grid
item
xs={12}
className={`${classes.screenTitle} ${className ? className : ""}`}
className={`${classes.stContainer} ${className ? className : ""}`}
>
<div className={classes.leftItems}>
{icon ? <div className={classes.headerBarIcon}>{icon}</div> : null}
@@ -119,4 +115,4 @@ const ScreenTitle = ({
);
};
export default withStyles(styles)(ScreenTitle);
export default ScreenTitle;

View File

@@ -20,13 +20,29 @@ import { useSelector } from "react-redux";
import CommandBar from "./CommandBar";
import { selFeatures } from "./consoleSlice";
import TrafficMonitor from "./Common/ObjectManager/TrafficMonitor";
import { AppState } from "../../store";
import AnonymousAccess from "../AnonymousAccess/AnonymousAccess";
import { Fragment } from "react";
const ConsoleKBar = () => {
const features = useSelector(selFeatures);
const anonymousMode = useSelector(
(state: AppState) => state.system.anonymousMode
);
// if we are hiding the menu also disable the k-bar so just return console
if (features?.includes("hide-menu")) {
return <Console />;
}
// for anonymous mode, we don't load Console, only AnonymousAccess
if (anonymousMode) {
return (
<Fragment>
<TrafficMonitor />
<AnonymousAccess />
</Fragment>
);
}
return (
<KBarProvider

View File

@@ -48,7 +48,6 @@ import ScreenTitle from "../Common/ScreenTitle/ScreenTitle";
import DeleteIDPConfigurationModal from "./DeleteIDPConfigurationModal";
import FormSwitchWrapper from "../Common/FormComponents/FormSwitchWrapper/FormSwitchWrapper";
import LabelValuePair from "../Common/UsageBarWrapper/LabelValuePair";
type IDPConfigurationDetailsProps = {
classes?: any;
formFields: object;
@@ -384,9 +383,6 @@ const IDPConfigurationDetails = ({
<PageLayout className={classes.pageContainer}>
<Box>
<ScreenTitle
classes={{
screenTitle: classes.screenTitle,
}}
icon={icon}
title={configurationName === "_" ? "Default" : configurationName}
actions={

View File

@@ -79,13 +79,17 @@ const BrowserBreadcrumbs = ({
const versionedFile = useSelector(
(state: AppState) => state.objectBrowser.versionedFile
);
const anonymousMode = useSelector(
(state: AppState) => state.system.anonymousMode
);
const [createFolderOpen, setCreateFolderOpen] = useState<boolean>(false);
const canCreatePath = hasPermission(bucketName, [
IAM_SCOPES.S3_PUT_OBJECT,
IAM_SCOPES.S3_PUT_ACTIONS,
]);
const canCreatePath =
hasPermission(bucketName, [
IAM_SCOPES.S3_PUT_OBJECT,
IAM_SCOPES.S3_PUT_ACTIONS,
]) || anonymousMode;
let paths = internalPaths;
@@ -240,7 +244,7 @@ const BrowserBreadcrumbs = ({
onClick={() => {
setCreateFolderOpen(true);
}}
disabled={rewindEnabled || !canCreatePath}
disabled={anonymousMode ? false : rewindEnabled || !canCreatePath}
icon={<NewPathIcon style={{ fill: "#969FA8" }} />}
style={{
whiteSpace: "nowrap",

View File

@@ -0,0 +1,39 @@
// 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 <http://www.gnu.org/licenses/>.
import React from "react";
import { setSearchObjects } from "./objectBrowserSlice";
import SearchBox from "../Common/SearchBox";
import { AppState, useAppDispatch } from "../../../store";
import { useSelector } from "react-redux";
const FilterObjectsSB = () => {
const dispatch = useAppDispatch();
const searchObjects = useSelector(
(state: AppState) => state.objectBrowser.searchObjects
);
return (
<SearchBox
placeholder={"Start typing to filter objects in the bucket"}
onChange={(value) => {
dispatch(setSearchObjects(value));
}}
value={searchObjects}
/>
);
};
export default FilterObjectsSB;

View File

@@ -0,0 +1,178 @@
// 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 <http://www.gnu.org/licenses/>.
import React, { Fragment } from "react";
import PageHeader from "../Common/PageHeader/PageHeader";
import BackLink from "../../../common/BackLink";
import {
IAM_PAGES,
IAM_PERMISSIONS,
IAM_ROLES,
IAM_SCOPES,
} from "../../../common/SecureComponent/permissions";
import { SecureComponent } from "../../../common/SecureComponent";
import TooltipWrapper from "../Common/TooltipWrapper/TooltipWrapper";
import { Button, SettingsIcon } from "mds";
import { Grid } from "@mui/material";
import AutoColorIcon from "../Common/Components/AutoColorIcon";
import { useSelector } from "react-redux";
import { selFeatures } from "../consoleSlice";
import hasPermission from "../../../common/SecureComponent/accessControl";
import { useNavigate } from "react-router-dom";
import SearchBox from "../Common/SearchBox";
import { setSearchVersions } from "./objectBrowserSlice";
import { AppState, useAppDispatch } from "../../../store";
import FilterObjectsSB from "./FilterObjectsSB";
interface IOBHeader {
bucketName: string;
}
const OBHeader = ({ bucketName }: IOBHeader) => {
const dispatch = useAppDispatch();
const features = useSelector(selFeatures);
const versionsMode = useSelector(
(state: AppState) => state.objectBrowser.versionsMode
);
const versionedFile = useSelector(
(state: AppState) => state.objectBrowser.versionedFile
);
const searchVersions = useSelector(
(state: AppState) => state.objectBrowser.searchVersions
);
const obOnly = !!features?.includes("object-browser-only");
const navigate = useNavigate();
const configureBucketAllowed = hasPermission(bucketName, [
IAM_SCOPES.S3_GET_BUCKET_POLICY,
IAM_SCOPES.S3_PUT_BUCKET_POLICY,
IAM_SCOPES.S3_GET_BUCKET_VERSIONING,
IAM_SCOPES.S3_PUT_BUCKET_VERSIONING,
IAM_SCOPES.S3_GET_BUCKET_ENCRYPTION_CONFIGURATION,
IAM_SCOPES.S3_PUT_BUCKET_ENCRYPTION_CONFIGURATION,
IAM_SCOPES.S3_DELETE_BUCKET,
IAM_SCOPES.S3_GET_BUCKET_NOTIFICATIONS,
IAM_SCOPES.S3_PUT_BUCKET_NOTIFICATIONS,
IAM_SCOPES.S3_GET_REPLICATION_CONFIGURATION,
IAM_SCOPES.S3_PUT_REPLICATION_CONFIGURATION,
IAM_SCOPES.S3_GET_LIFECYCLE_CONFIGURATION,
IAM_SCOPES.S3_PUT_LIFECYCLE_CONFIGURATION,
IAM_SCOPES.ADMIN_GET_BUCKET_QUOTA,
IAM_SCOPES.ADMIN_SET_BUCKET_QUOTA,
IAM_SCOPES.S3_PUT_BUCKET_TAGGING,
IAM_SCOPES.S3_GET_BUCKET_TAGGING,
IAM_SCOPES.S3_LIST_BUCKET_VERSIONS,
IAM_SCOPES.S3_GET_BUCKET_POLICY_STATUS,
IAM_SCOPES.S3_DELETE_BUCKET_POLICY,
IAM_SCOPES.S3_GET_ACTIONS,
IAM_SCOPES.S3_PUT_ACTIONS,
]);
const openBucketConfiguration = () => {
navigate(`/buckets/${bucketName}/admin`);
};
const searchBar = (
<Fragment>
{!versionsMode ? (
<SecureComponent
scopes={[IAM_SCOPES.S3_LIST_BUCKET, IAM_SCOPES.S3_ALL_LIST_BUCKET]}
resource={bucketName}
errorProps={{ disabled: true }}
>
<FilterObjectsSB />
</SecureComponent>
) : (
<Fragment>
<SearchBox
placeholder={`Start typing to filter versions of ${versionedFile}`}
onChange={(value) => {
dispatch(setSearchVersions(value));
}}
value={searchVersions}
/>
</Fragment>
)}
</Fragment>
);
return (
<Fragment>
{!obOnly ? (
<PageHeader
label={
<BackLink
label={"Object Browser"}
to={IAM_PAGES.OBJECT_BROWSER_VIEW}
/>
}
actions={
<SecureComponent
scopes={IAM_PERMISSIONS[IAM_ROLES.BUCKET_ADMIN]}
resource={bucketName}
errorProps={{ disabled: true }}
>
<TooltipWrapper
tooltip={
configureBucketAllowed
? "Configure Bucket"
: "You do not have the required permissions to configure this bucket. Please contact your MinIO administrator to request " +
IAM_ROLES.BUCKET_ADMIN +
" permisions."
}
>
<Button
id={"configure-bucket-main"}
color="primary"
aria-label="Configure Bucket"
onClick={openBucketConfiguration}
icon={
<SettingsIcon
style={{ width: 20, height: 20, marginTop: -3 }}
/>
}
style={{
padding: "0 10px",
}}
/>
</TooltipWrapper>
</SecureComponent>
}
middleComponent={searchBar}
/>
) : (
<Grid
container
sx={{
padding: "20px 32px 0",
}}
>
<Grid>
<AutoColorIcon marginRight={30} marginTop={10} />
</Grid>
<Grid item xs>
{searchBar}
</Grid>
</Grid>
)}
</Fragment>
);
};
export default OBHeader;

View File

@@ -71,6 +71,7 @@ const initialState: ObjectBrowserState = {
unit: "",
validity: 0,
},
longFileOpen: false,
};
export const objectBrowserSlice = createSlice({
@@ -356,6 +357,9 @@ export const objectBrowserSlice = createSlice({
setSelectedBucket: (state, action: PayloadAction<string>) => {
state.selectedBucket = action.payload;
},
setLongFileOpen: (state, action: PayloadAction<boolean>) => {
state.longFileOpen = action.payload;
},
},
});
export const {
@@ -401,6 +405,7 @@ export const {
setIsOpeningOD,
setRetentionConfig,
setSelectedBucket,
setLongFileOpen,
} = objectBrowserSlice.actions;
export default objectBrowserSlice.reducer;

View File

@@ -93,6 +93,7 @@ export interface ObjectBrowserState {
shareFileModalOpen: boolean;
isOpeningObjectDetail: boolean;
retentionConfig: IRetentionConfig | null;
longFileOpen: boolean;
}
export interface ObjectManager {

View File

@@ -0,0 +1,90 @@
// 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 <http://www.gnu.org/licenses/>.
import { IFileInfo } from "../Buckets/ListBuckets/Objects/ObjectDetails/types";
import { encodeURLString, getClientOS } from "../../../common/utils";
import { makeid, storeCallForObjectWithID } from "./transferManager";
import { download } from "../Buckets/ListBuckets/Objects/utils";
import {
cancelObjectInList,
completeObject,
failObject,
setLongFileOpen,
setNewObject,
updateProgress,
} from "./objectBrowserSlice";
import { AppDispatch } from "../../../store";
export const downloadObject = (
dispatch: AppDispatch,
bucketName: string,
internalPaths: string,
object: IFileInfo
) => {
const identityDownload = encodeURLString(
`${bucketName}-${object.name}-${new Date().getTime()}-${Math.random()}`
);
if (object.name.length > 200 && getClientOS().toLowerCase().includes("win")) {
dispatch(setLongFileOpen(true));
return;
}
const ID = makeid(8);
const downloadCall = download(
bucketName,
internalPaths,
object.version_id,
parseInt(object.size || "0"),
null,
ID,
(progress) => {
dispatch(
updateProgress({
instanceID: identityDownload,
progress: progress,
})
);
},
() => {
dispatch(completeObject(identityDownload));
},
(msg: string) => {
dispatch(failObject({ instanceID: identityDownload, msg }));
},
() => {
dispatch(cancelObjectInList(identityDownload));
}
);
storeCallForObjectWithID(ID, downloadCall);
dispatch(
setNewObject({
ID,
bucketName,
done: false,
instanceID: identityDownload,
percentage: 0,
prefix: object.name,
type: "download",
waitingForFile: true,
failed: false,
cancelled: false,
errorMessage: "",
})
);
};

View File

@@ -51,6 +51,7 @@ export const consoleSlice = createSlice({
export const { saveSessionResponse, resetSession } = consoleSlice.actions;
export const selSession = (state: AppState) => state.console.session;
export const selFeatures = (state: AppState) => state.console.session.features;
export const selFeatures = (state: AppState) =>
state.console.session ? state.console.session.features : [];
export default consoleSlice.reducer;

View File

@@ -43,6 +43,7 @@ export interface SystemState {
siteReplicationInfo: SRInfoStateType;
licenseInfo: null | SubnetInfo;
overrideStyles: null | IEmbeddedCustomStyles;
anonymousMode: boolean;
}
const initialState: SystemState = {
@@ -72,6 +73,7 @@ const initialState: SystemState = {
distributedSetup: false,
licenseInfo: null,
overrideStyles: null,
anonymousMode: false,
};
export const systemSlice = createSlice({
@@ -159,6 +161,13 @@ export const systemSlice = createSlice({
) => {
state.overrideStyles = action.payload;
},
setAnonymousMode: (state) => {
state.anonymousMode = true;
state.loggedIn = true;
},
resetSystem: () => {
return initialState;
},
},
});
@@ -181,6 +190,8 @@ export const {
setSiteReplicationInfo,
setLicenseInfo,
setOverrideStyles,
setAnonymousMode,
resetSystem,
} = systemSlice.actions;
export const selDistSet = (state: AppState) => state.system.distributedSetup;

View File

@@ -369,6 +369,9 @@ func NewConsoleCredentials(accessKey, secretKey, location string) (*credentials.
// getConsoleCredentialsFromSession returns the *consoleCredentials.Login associated to the
// provided session token, this is useful for running the Expire() or IsExpired() operations
func getConsoleCredentialsFromSession(claims *models.Principal) *credentials.Credentials {
if claims == nil {
return credentials.NewStaticV4("", "", "")
}
return credentials.NewStaticV4(claims.STSAccessKeyID, claims.STSSecretAccessKey, claims.STSSessionToken)
}

View File

@@ -83,6 +83,9 @@ func configureAPI(api *operations.ConsoleAPI) http.Handler {
api.KeyAuth = func(token string, scopes []string) (*models.Principal, error) {
// we are validating the session token by decrypting the claims inside, if the operation succeed that means the jwt
// was generated and signed by us in the first place
if token == "Anonymous" {
return &models.Principal{}, nil
}
claims, err := auth.ParseClaimsFromToken(token)
if err != nil {
api.Logger("Unable to validate the session token %s: %v", token, err)
@@ -98,6 +101,9 @@ func configureAPI(api *operations.ConsoleAPI) http.Handler {
CustomStyleOb: claims.CustomStyleOB,
}, nil
}
api.AnonymousAuth = func(s string) (*models.Principal, error) {
return &models.Principal{}, nil
}
// Register login handlers
registerLoginHandlers(api)
@@ -291,6 +297,8 @@ func AuthenticationMiddleware(next http.Handler) http.Handler {
// handle it appropriately.
if len(sessionToken) > 0 {
r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", string(sessionToken)))
} else {
r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", "Anonymous"))
}
ctx := r.Context()
claims, _ := auth.ParseClaimsFromToken(string(sessionToken))

View File

@@ -1458,6 +1458,14 @@ func init() {
},
"/buckets/{bucket_name}/objects": {
"get": {
"security": [
{
"key": []
},
{
"anonymous": []
}
],
"tags": [
"Object"
],
@@ -1489,6 +1497,12 @@ func init() {
"type": "boolean",
"name": "with_metadata",
"in": "query"
},
{
"type": "integer",
"format": "int32",
"name": "limit",
"in": "query"
}
],
"responses": {
@@ -1566,6 +1580,14 @@ func init() {
},
"/buckets/{bucket_name}/objects/download": {
"get": {
"security": [
{
"key": []
},
{
"anonymous": []
}
],
"produces": [
"application/octet-stream"
],
@@ -1930,6 +1952,14 @@ func init() {
},
"/buckets/{bucket_name}/objects/upload": {
"post": {
"security": [
{
"key": []
},
{
"anonymous": []
}
],
"consumes": [
"multipart/form-data"
],
@@ -8469,6 +8499,11 @@ func init() {
}
},
"securityDefinitions": {
"anonymous": {
"type": "apiKey",
"name": "X-Anonymous",
"in": "header"
},
"key": {
"type": "oauth2",
"flow": "accessCode",
@@ -9906,6 +9941,14 @@ func init() {
},
"/buckets/{bucket_name}/objects": {
"get": {
"security": [
{
"key": []
},
{
"anonymous": []
}
],
"tags": [
"Object"
],
@@ -9937,6 +9980,12 @@ func init() {
"type": "boolean",
"name": "with_metadata",
"in": "query"
},
{
"type": "integer",
"format": "int32",
"name": "limit",
"in": "query"
}
],
"responses": {
@@ -10014,6 +10063,14 @@ func init() {
},
"/buckets/{bucket_name}/objects/download": {
"get": {
"security": [
{
"key": []
},
{
"anonymous": []
}
],
"produces": [
"application/octet-stream"
],
@@ -10378,6 +10435,14 @@ func init() {
},
"/buckets/{bucket_name}/objects/upload": {
"post": {
"security": [
{
"key": []
},
{
"anonymous": []
}
],
"consumes": [
"multipart/form-data"
],
@@ -17043,6 +17108,11 @@ func init() {
}
},
"securityDefinitions": {
"anonymous": {
"type": "apiKey",
"name": "X-Anonymous",
"in": "header"
},
"key": {
"type": "oauth2",
"flow": "accessCode",

View File

@@ -534,6 +534,10 @@ func NewConsoleAPI(spec *loads.Document) *ConsoleAPI {
return middleware.NotImplemented("operation user.UpdateUserInfo has not yet been implemented")
}),
// Applies when the "X-Anonymous" header is set
AnonymousAuth: func(token string) (*models.Principal, error) {
return nil, errors.NotImplemented("api key auth (anonymous) X-Anonymous from header param [X-Anonymous] has not yet been implemented")
},
KeyAuth: func(token string, scopes []string) (*models.Principal, error) {
return nil, errors.NotImplemented("oauth2 bearer auth (key) has not yet been implemented")
},
@@ -584,6 +588,10 @@ type ConsoleAPI struct {
// - application/json
JSONProducer runtime.Producer
// AnonymousAuth registers a function that takes a token and returns a principal
// it performs authentication based on an api key X-Anonymous provided in the header
AnonymousAuth func(string) (*models.Principal, error)
// KeyAuth registers a function that takes an access token and a collection of required scopes and returns a principal
// it performs authentication based on an oauth2 bearer token provided in the request
KeyAuth func(string, []string) (*models.Principal, error)
@@ -975,6 +983,9 @@ func (o *ConsoleAPI) Validate() error {
unregistered = append(unregistered, "JSONProducer")
}
if o.AnonymousAuth == nil {
unregistered = append(unregistered, "XAnonymousAuth")
}
if o.KeyAuth == nil {
unregistered = append(unregistered, "KeyAuth")
}
@@ -1444,6 +1455,12 @@ func (o *ConsoleAPI) AuthenticatorsFor(schemes map[string]spec.SecurityScheme) m
result := make(map[string]runtime.Authenticator)
for name := range schemes {
switch name {
case "anonymous":
scheme := schemes[name]
result[name] = o.APIKeyAuthenticator(scheme.Name, scheme.In, func(token string) (interface{}, error) {
return o.AnonymousAuth(token)
})
case "key":
result[name] = o.BearerAuthenticator(name, func(token string, scopes []string) (interface{}, error) {
return o.KeyAuth(token, scopes)

View File

@@ -57,6 +57,10 @@ type ListObjectsParams struct {
/*
In: query
*/
Limit *int32
/*
In: query
*/
Prefix *string
/*
In: query
@@ -88,6 +92,11 @@ func (o *ListObjectsParams) BindRequest(r *http.Request, route *middleware.Match
res = append(res, err)
}
qLimit, qhkLimit, _ := qs.GetOK("limit")
if err := o.bindLimit(qLimit, qhkLimit, route.Formats); err != nil {
res = append(res, err)
}
qPrefix, qhkPrefix, _ := qs.GetOK("prefix")
if err := o.bindPrefix(qPrefix, qhkPrefix, route.Formats); err != nil {
res = append(res, err)
@@ -127,6 +136,29 @@ func (o *ListObjectsParams) bindBucketName(rawData []string, hasKey bool, format
return nil
}
// bindLimit binds and validates parameter Limit from query.
func (o *ListObjectsParams) bindLimit(rawData []string, hasKey bool, formats strfmt.Registry) error {
var raw string
if len(rawData) > 0 {
raw = rawData[len(rawData)-1]
}
// Required: false
// AllowEmptyValue: false
if raw == "" { // empty values pass all other validations
return nil
}
value, err := swag.ConvertInt32(raw)
if err != nil {
return errors.InvalidType("limit", "query", "int32", raw)
}
o.Limit = &value
return nil
}
// bindPrefix binds and validates parameter Prefix from query.
func (o *ListObjectsParams) bindPrefix(rawData []string, hasKey bool, formats strfmt.Registry) error {
var raw string

View File

@@ -35,6 +35,7 @@ import (
type ListObjectsURL struct {
BucketName string
Limit *int32
Prefix *string
Recursive *bool
WithMetadata *bool
@@ -81,6 +82,14 @@ func (o *ListObjectsURL) Build() (*url.URL, error) {
qs := make(url.Values)
var limitQ string
if o.Limit != nil {
limitQ = swag.FormatInt32(*o.Limit)
}
if limitQ != "" {
qs.Set("limit", limitQ)
}
var prefixQ string
if o.Prefix != nil {
prefixQ = *o.Prefix

View File

@@ -205,7 +205,16 @@ func getListObjectsResponse(session *models.Principal, params objectApi.ListObje
// defining the client to be used
minioClient := minioClient{client: mClient}
objs, err := listBucketObjects(ctx, minioClient, params.BucketName, prefix, recursive, withVersions, withMetadata)
objs, err := listBucketObjects(ListObjectsOpts{
ctx: ctx,
client: minioClient,
bucketName: params.BucketName,
prefix: prefix,
recursive: recursive,
withVersions: withVersions,
withMetadata: withMetadata,
limit: params.Limit,
})
if err != nil {
return nil, ErrorWithContext(ctx, err)
}
@@ -217,20 +226,35 @@ func getListObjectsResponse(session *models.Principal, params objectApi.ListObje
return resp, nil
}
type ListObjectsOpts struct {
ctx context.Context
client MinioClient
bucketName string
prefix string
recursive bool
withVersions bool
withMetadata bool
limit *int32
}
// listBucketObjects gets an array of objects in a bucket
func listBucketObjects(ctx context.Context, client MinioClient, bucketName string, prefix string, recursive, withVersions bool, withMetadata bool) ([]*models.BucketObject, error) {
func listBucketObjects(listOpts ListObjectsOpts) ([]*models.BucketObject, error) {
var objects []*models.BucketObject
opts := minio.ListObjectsOptions{
Prefix: prefix,
Recursive: recursive,
WithVersions: withVersions,
WithMetadata: withMetadata,
Prefix: listOpts.prefix,
Recursive: listOpts.recursive,
WithVersions: listOpts.withVersions,
WithMetadata: listOpts.withMetadata,
MaxKeys: 100,
}
if withMetadata {
if listOpts.withMetadata {
opts.MaxKeys = 1
}
for lsObj := range client.listObjects(ctx, bucketName, opts) {
if listOpts.limit != nil {
opts.MaxKeys = int(*listOpts.limit)
}
var totalObjs int32
for lsObj := range listOpts.client.listObjects(listOpts.ctx, listOpts.bucketName, opts) {
if lsObj.Err != nil {
return nil, lsObj.Err
}
@@ -248,37 +272,44 @@ func listBucketObjects(ctx context.Context, client MinioClient, bucketName strin
Etag: lsObj.ETag,
}
// only if single object with or without versions; get legalhold, retention and tags
if !lsObj.IsDeleteMarker && prefix != "" && !strings.HasSuffix(prefix, "/") {
if !lsObj.IsDeleteMarker && listOpts.prefix != "" && !strings.HasSuffix(listOpts.prefix, "/") {
// Add Legal Hold Status if available
legalHoldStatus, err := client.getObjectLegalHold(ctx, bucketName, lsObj.Key, minio.GetObjectLegalHoldOptions{VersionID: lsObj.VersionID})
legalHoldStatus, err := listOpts.client.getObjectLegalHold(listOpts.ctx, listOpts.bucketName, lsObj.Key, minio.GetObjectLegalHoldOptions{VersionID: lsObj.VersionID})
if err != nil {
errResp := minio.ToErrorResponse(probe.NewError(err).ToGoError())
if errResp.Code != "InvalidRequest" && errResp.Code != "NoSuchObjectLockConfiguration" {
ErrorWithContext(ctx, fmt.Errorf("error getting legal hold status for %s : %v", lsObj.VersionID, err))
ErrorWithContext(listOpts.ctx, fmt.Errorf("error getting legal hold status for %s : %v", lsObj.VersionID, err))
}
} else if legalHoldStatus != nil {
obj.LegalHoldStatus = string(*legalHoldStatus)
}
// Add Retention Status if available
retention, retUntilDate, err := client.getObjectRetention(ctx, bucketName, lsObj.Key, lsObj.VersionID)
retention, retUntilDate, err := listOpts.client.getObjectRetention(listOpts.ctx, listOpts.bucketName, lsObj.Key, lsObj.VersionID)
if err != nil {
errResp := minio.ToErrorResponse(probe.NewError(err).ToGoError())
if errResp.Code != "InvalidRequest" && errResp.Code != "NoSuchObjectLockConfiguration" {
ErrorWithContext(ctx, fmt.Errorf("error getting retention status for %s : %v", lsObj.VersionID, err))
ErrorWithContext(listOpts.ctx, fmt.Errorf("error getting retention status for %s : %v", lsObj.VersionID, err))
}
} else if retention != nil && retUntilDate != nil {
date := *retUntilDate
obj.RetentionMode = string(*retention)
obj.RetentionUntilDate = date.Format(time.RFC3339)
}
tags, err := client.getObjectTagging(ctx, bucketName, lsObj.Key, minio.GetObjectTaggingOptions{VersionID: lsObj.VersionID})
objTags, err := listOpts.client.getObjectTagging(listOpts.ctx, listOpts.bucketName, lsObj.Key, minio.GetObjectTaggingOptions{VersionID: lsObj.VersionID})
if err != nil {
ErrorWithContext(ctx, fmt.Errorf("error getting object tags for %s : %v", lsObj.VersionID, err))
ErrorWithContext(listOpts.ctx, fmt.Errorf("error getting object tags for %s : %v", lsObj.VersionID, err))
} else {
obj.Tags = tags.ToMap()
obj.Tags = objTags.ToMap()
}
}
objects = append(objects, obj)
totalObjs++
if listOpts.limit != nil {
if totalObjs >= *listOpts.limit {
break
}
}
}
return objects, nil
}
@@ -499,7 +530,15 @@ func getDownloadFolderResponse(session *models.Principal, params objectApi.Downl
return nil, ErrorWithContext(ctx, err)
}
minioClient := minioClient{client: mClient}
objects, err := listBucketObjects(ctx, minioClient, params.BucketName, prefix, true, false, false)
objects, err := listBucketObjects(ListObjectsOpts{
ctx: ctx,
client: minioClient,
bucketName: params.BucketName,
prefix: prefix,
recursive: true,
withVersions: false,
withMetadata: false,
})
if err != nil {
return nil, ErrorWithContext(ctx, err)
}

View File

@@ -121,6 +121,7 @@ func Test_listObjects(t *testing.T) {
recursive bool
withVersions bool
withMetadata bool
limit *int32
listFunc func(ctx context.Context, bucket string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo
objectLegalHoldFunc func(ctx context.Context, bucketName, objectName string, opts minio.GetObjectLegalHoldOptions) (status *minio.LegalHoldStatus, err error)
objectRetentionFunc func(ctx context.Context, bucketName, objectName, versionID string) (mode *minio.RetentionMode, retainUntilDate *time.Time, err error)
@@ -553,6 +554,150 @@ func Test_listObjects(t *testing.T) {
},
wantError: nil,
},
{
test: "Return objects",
args: args{
bucketName: "bucket1",
prefix: "prefix",
recursive: true,
withVersions: false,
withMetadata: false,
listFunc: func(ctx context.Context, bucket string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo {
objectStatCh := make(chan minio.ObjectInfo, 1)
go func(objectStatCh chan<- minio.ObjectInfo) {
defer close(objectStatCh)
for _, bucket := range []minio.ObjectInfo{
{
Key: "obj1",
LastModified: t1,
Size: int64(1024),
ContentType: "content",
},
{
Key: "obj2",
LastModified: t1,
Size: int64(512),
ContentType: "content",
},
} {
objectStatCh <- bucket
}
}(objectStatCh)
return objectStatCh
},
objectLegalHoldFunc: func(ctx context.Context, bucketName, objectName string, opts minio.GetObjectLegalHoldOptions) (status *minio.LegalHoldStatus, err error) {
s := minio.LegalHoldEnabled
return &s, nil
},
objectRetentionFunc: func(ctx context.Context, bucketName, objectName, versionID string) (mode *minio.RetentionMode, retainUntilDate *time.Time, err error) {
m := minio.Governance
return &m, &tretention, nil
},
objectGetTaggingFunc: func(ctx context.Context, bucketName, objectName string, opts minio.GetObjectTaggingOptions) (*tags.Tags, error) {
tagMap := map[string]string{
"tag1": "value1",
}
otags, err := tags.MapToObjectTags(tagMap)
if err != nil {
return nil, err
}
return otags, nil
},
},
expectedResp: []*models.BucketObject{
{
Name: "obj1",
LastModified: t1.Format(time.RFC3339),
Size: int64(1024),
ContentType: "content",
LegalHoldStatus: string(minio.LegalHoldEnabled),
RetentionMode: string(minio.Governance),
RetentionUntilDate: tretention.Format(time.RFC3339),
Tags: map[string]string{
"tag1": "value1",
},
}, {
Name: "obj2",
LastModified: t1.Format(time.RFC3339),
Size: int64(512),
ContentType: "content",
LegalHoldStatus: string(minio.LegalHoldEnabled),
RetentionMode: string(minio.Governance),
RetentionUntilDate: tretention.Format(time.RFC3339),
Tags: map[string]string{
"tag1": "value1",
},
},
},
wantError: nil,
},
{
test: "Limit 1",
args: args{
bucketName: "bucket1",
prefix: "prefix",
recursive: true,
withVersions: false,
withMetadata: false,
limit: swag.Int32(1),
listFunc: func(ctx context.Context, bucket string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo {
objectStatCh := make(chan minio.ObjectInfo, 1)
go func(objectStatCh chan<- minio.ObjectInfo) {
defer close(objectStatCh)
for _, bucket := range []minio.ObjectInfo{
{
Key: "obj1",
LastModified: t1,
Size: int64(1024),
ContentType: "content",
},
{
Key: "obj2",
LastModified: t1,
Size: int64(512),
ContentType: "content",
},
} {
objectStatCh <- bucket
}
}(objectStatCh)
return objectStatCh
},
objectLegalHoldFunc: func(ctx context.Context, bucketName, objectName string, opts minio.GetObjectLegalHoldOptions) (status *minio.LegalHoldStatus, err error) {
s := minio.LegalHoldEnabled
return &s, nil
},
objectRetentionFunc: func(ctx context.Context, bucketName, objectName, versionID string) (mode *minio.RetentionMode, retainUntilDate *time.Time, err error) {
m := minio.Governance
return &m, &tretention, nil
},
objectGetTaggingFunc: func(ctx context.Context, bucketName, objectName string, opts minio.GetObjectTaggingOptions) (*tags.Tags, error) {
tagMap := map[string]string{
"tag1": "value1",
}
otags, err := tags.MapToObjectTags(tagMap)
if err != nil {
return nil, err
}
return otags, nil
},
},
expectedResp: []*models.BucketObject{
{
Name: "obj1",
LastModified: t1.Format(time.RFC3339),
Size: int64(1024),
ContentType: "content",
LegalHoldStatus: string(minio.LegalHoldEnabled),
RetentionMode: string(minio.Governance),
RetentionUntilDate: tretention.Format(time.RFC3339),
Tags: map[string]string{
"tag1": "value1",
},
},
},
wantError: nil,
},
}
t.Parallel()
@@ -563,7 +708,16 @@ func Test_listObjects(t *testing.T) {
minioGetObjectLegalHoldMock = tt.args.objectLegalHoldFunc
minioGetObjectRetentionMock = tt.args.objectRetentionFunc
minioGetObjectTaggingMock = tt.args.objectGetTaggingFunc
resp, err := listBucketObjects(ctx, minClient, tt.args.bucketName, tt.args.prefix, tt.args.recursive, tt.args.withVersions, tt.args.withMetadata)
resp, err := listBucketObjects(ListObjectsOpts{
ctx: ctx,
client: minClient,
bucketName: tt.args.bucketName,
prefix: tt.args.prefix,
recursive: tt.args.recursive,
withVersions: tt.args.withVersions,
withMetadata: tt.args.withMetadata,
limit: tt.args.limit,
})
switch {
case err == nil && tt.wantError != nil:
t.Errorf("listBucketObjects() error: %v, wantErr: %v", err, tt.wantError)

View File

@@ -18,7 +18,7 @@ package restapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
@@ -30,7 +30,7 @@ import (
"github.com/minio/console/pkg/utils"
"github.com/go-openapi/errors"
errorsApi "github.com/go-openapi/errors"
"github.com/minio/console/models"
"github.com/minio/console/pkg/auth"
"github.com/minio/websocket"
@@ -139,15 +139,15 @@ func (c wsConn) readMessage() (messageType int, p []byte, err error) {
// Request should come like ws://<host>:<port>/ws/<api>
func serveWS(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
wsPath := strings.TrimPrefix(req.URL.Path, wsBasePath)
// Perform authentication before upgrading to a Websocket Connection
// authenticate WS connection with Console
session, err := auth.GetClaimsFromTokenInRequest(req)
if err != nil {
if err != nil && (errors.Is(err, auth.ErrReadingToken) && !strings.HasPrefix(wsPath, `/objectManager`)) {
ErrorWithContext(ctx, err)
errors.ServeError(w, req, errors.New(http.StatusUnauthorized, err.Error()))
errorsApi.ServeError(w, req, errorsApi.New(http.StatusUnauthorized, err.Error()))
return
}
// Development mode validation
if getConsoleDevMode() {
upgrader.CheckOrigin = func(r *http.Request) bool {
@@ -159,11 +159,10 @@ func serveWS(w http.ResponseWriter, req *http.Request) {
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
ErrorWithContext(ctx, err)
errors.ServeError(w, req, err)
errorsApi.ServeError(w, req, err)
return
}
wsPath := strings.TrimPrefix(req.URL.Path, wsBasePath)
switch {
case strings.HasPrefix(wsPath, `/trace`):
wsAdminClient, err := newWebSocketAdminClient(conn, session)
@@ -539,227 +538,6 @@ func (wsc *wsAdminClient) profile(ctx context.Context, opts *profileOptions) {
sendWsCloseMessage(wsc.conn, err)
}
func (wsc *wsMinioClient) objectManager(session *models.Principal) {
// Storage of Cancel Contexts for this connection
cancelContexts := make(map[int64]context.CancelFunc)
// Initial goroutine
defer func() {
// We close socket at the end of requests
wsc.conn.close()
for _, c := range cancelContexts {
// invoke cancel
c()
}
}()
writeChannel := make(chan WSResponse)
done := make(chan interface{})
// Read goroutine
go func() {
for {
mType, message, err := wsc.conn.readMessage()
if err != nil {
LogInfo("Error while reading objectManager message", err)
close(done)
return
}
if mType == websocket.TextMessage {
// We get request data & review information
var messageRequest ObjectsRequest
err := json.Unmarshal(message, &messageRequest)
if err != nil {
LogInfo("Error on message request unmarshal")
close(done)
return
}
// new message, new context
ctx, cancel := context.WithCancel(context.Background())
// We store the cancel func associated with this request
cancelContexts[messageRequest.RequestID] = cancel
const itemsPerBatch = 1000
switch messageRequest.Mode {
case "close":
close(done)
return
case "cancel":
// if we have that request id, cancel it
if cancelFunc, ok := cancelContexts[messageRequest.RequestID]; ok {
cancelFunc()
delete(cancelContexts, messageRequest.RequestID)
}
case "objects":
// cancel all previous open objects requests for listing
for rid, c := range cancelContexts {
if rid < messageRequest.RequestID {
// invoke cancel
c()
}
}
// start listing and writing to web socket
go func() {
objectRqConfigs, err := getObjectsOptionsFromReq(messageRequest)
if err != nil {
LogInfo(fmt.Sprintf("Error during Objects OptionsParse %s", err.Error()))
return
}
var buffer []ObjectResponse
for lsObj := range startObjectsListing(ctx, wsc.client, objectRqConfigs) {
if cancelContexts[messageRequest.RequestID] == nil {
return
}
if lsObj.Err != nil {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Error: lsObj.Err.Error(),
Prefix: messageRequest.Prefix,
}
continue
}
objItem := ObjectResponse{
Name: lsObj.Key,
Size: lsObj.Size,
LastModified: lsObj.LastModified.Format(time.RFC3339),
VersionID: lsObj.VersionID,
IsLatest: lsObj.IsLatest,
DeleteMarker: lsObj.IsDeleteMarker,
}
buffer = append(buffer, objItem)
if len(buffer) >= itemsPerBatch {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Data: buffer,
}
buffer = nil
}
}
if len(buffer) > 0 {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Data: buffer,
}
}
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
RequestEnd: true,
}
// remove the cancellation context
delete(cancelContexts, messageRequest.RequestID)
}()
case "rewind":
// cancel all previous open objects requests for listing
for rid, c := range cancelContexts {
if rid < messageRequest.RequestID {
// invoke cancel
c()
}
}
// start listing and writing to web socket
go func() {
objectRqConfigs, err := getObjectsOptionsFromReq(messageRequest)
if err != nil {
LogInfo(fmt.Sprintf("Error during Objects OptionsParse %s", err.Error()))
cancel()
return
}
s3Client, err := newS3BucketClient(session, objectRqConfigs.BucketName, objectRqConfigs.Prefix)
if err != nil {
LogError("error creating S3Client:", err)
close(done)
cancel()
return
}
mcS3C := mcClient{client: s3Client}
var buffer []ObjectResponse
for lsObj := range startRewindListing(ctx, mcS3C, objectRqConfigs) {
if lsObj.Err != nil {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Error: lsObj.Err.String(),
Prefix: messageRequest.Prefix,
}
continue
}
name := strings.ReplaceAll(lsObj.URL.Path, fmt.Sprintf("/%s/", objectRqConfigs.BucketName), "")
objItem := ObjectResponse{
Name: name,
Size: lsObj.Size,
LastModified: lsObj.Time.Format(time.RFC3339),
VersionID: lsObj.VersionID,
IsLatest: lsObj.IsLatest,
DeleteMarker: lsObj.IsDeleteMarker,
}
buffer = append(buffer, objItem)
if len(buffer) >= itemsPerBatch {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Data: buffer,
}
buffer = nil
}
}
if len(buffer) > 0 {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Data: buffer,
}
}
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
RequestEnd: true,
}
// remove the cancellation context
delete(cancelContexts, messageRequest.RequestID)
}()
}
}
}
}()
// Write goroutine
go func() {
for writeM := range writeChannel {
jsonData, err := json.Marshal(writeM)
if err != nil {
LogInfo("Error while parsing the response", err)
return
}
err = wsc.conn.writeMessage(websocket.TextMessage, jsonData)
if err != nil {
LogInfo("Error while writing the message", err)
return
}
}
}()
<-done
}
// sendWsCloseMessage sends Websocket Connection Close Message indicating the Status Code
// see https://tools.ietf.org/html/rfc6455#page-45
func sendWsCloseMessage(conn WSConn, err error) {

248
restapi/ws_objects.go Normal file
View File

@@ -0,0 +1,248 @@
// 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 <http://www.gnu.org/licenses/>.
package restapi
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/minio/console/models"
"github.com/minio/websocket"
)
func (wsc *wsMinioClient) objectManager(session *models.Principal) {
// Storage of Cancel Contexts for this connection
cancelContexts := make(map[int64]context.CancelFunc)
// Initial goroutine
defer func() {
// We close socket at the end of requests
wsc.conn.close()
for _, c := range cancelContexts {
// invoke cancel
c()
}
}()
writeChannel := make(chan WSResponse)
done := make(chan interface{})
// Read goroutine
go func() {
for {
mType, message, err := wsc.conn.readMessage()
if err != nil {
LogInfo("Error while reading objectManager message", err)
close(done)
return
}
if mType == websocket.TextMessage {
// We get request data & review information
var messageRequest ObjectsRequest
err := json.Unmarshal(message, &messageRequest)
if err != nil {
LogInfo("Error on message request unmarshal")
close(done)
return
}
// new message, new context
ctx, cancel := context.WithCancel(context.Background())
// We store the cancel func associated with this request
cancelContexts[messageRequest.RequestID] = cancel
const itemsPerBatch = 1000
switch messageRequest.Mode {
case "close":
close(done)
return
case "cancel":
// if we have that request id, cancel it
if cancelFunc, ok := cancelContexts[messageRequest.RequestID]; ok {
cancelFunc()
delete(cancelContexts, messageRequest.RequestID)
}
case "objects":
// cancel all previous open objects requests for listing
for rid, c := range cancelContexts {
if rid < messageRequest.RequestID {
// invoke cancel
c()
}
}
// start listing and writing to web socket
go func() {
objectRqConfigs, err := getObjectsOptionsFromReq(messageRequest)
if err != nil {
LogInfo(fmt.Sprintf("Error during Objects OptionsParse %s", err.Error()))
return
}
var buffer []ObjectResponse
for lsObj := range startObjectsListing(ctx, wsc.client, objectRqConfigs) {
if cancelContexts[messageRequest.RequestID] == nil {
return
}
if lsObj.Err != nil {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Error: lsObj.Err.Error(),
Prefix: messageRequest.Prefix,
}
continue
}
objItem := ObjectResponse{
Name: lsObj.Key,
Size: lsObj.Size,
LastModified: lsObj.LastModified.Format(time.RFC3339),
VersionID: lsObj.VersionID,
IsLatest: lsObj.IsLatest,
DeleteMarker: lsObj.IsDeleteMarker,
}
buffer = append(buffer, objItem)
if len(buffer) >= itemsPerBatch {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Data: buffer,
}
buffer = nil
}
}
if len(buffer) > 0 {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Data: buffer,
}
}
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
RequestEnd: true,
}
// remove the cancellation context
delete(cancelContexts, messageRequest.RequestID)
}()
case "rewind":
// cancel all previous open objects requests for listing
for rid, c := range cancelContexts {
if rid < messageRequest.RequestID {
// invoke cancel
c()
}
}
// start listing and writing to web socket
go func() {
objectRqConfigs, err := getObjectsOptionsFromReq(messageRequest)
if err != nil {
LogInfo(fmt.Sprintf("Error during Objects OptionsParse %s", err.Error()))
cancel()
return
}
s3Client, err := newS3BucketClient(session, objectRqConfigs.BucketName, objectRqConfigs.Prefix)
if err != nil {
LogError("error creating S3Client:", err)
close(done)
cancel()
return
}
mcS3C := mcClient{client: s3Client}
var buffer []ObjectResponse
for lsObj := range startRewindListing(ctx, mcS3C, objectRqConfigs) {
if lsObj.Err != nil {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Error: lsObj.Err.String(),
Prefix: messageRequest.Prefix,
}
continue
}
name := strings.ReplaceAll(lsObj.URL.Path, fmt.Sprintf("/%s/", objectRqConfigs.BucketName), "")
objItem := ObjectResponse{
Name: name,
Size: lsObj.Size,
LastModified: lsObj.Time.Format(time.RFC3339),
VersionID: lsObj.VersionID,
IsLatest: lsObj.IsLatest,
DeleteMarker: lsObj.IsDeleteMarker,
}
buffer = append(buffer, objItem)
if len(buffer) >= itemsPerBatch {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Data: buffer,
}
buffer = nil
}
}
if len(buffer) > 0 {
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
Data: buffer,
}
}
writeChannel <- WSResponse{
RequestID: messageRequest.RequestID,
RequestEnd: true,
}
// remove the cancellation context
delete(cancelContexts, messageRequest.RequestID)
}()
}
}
}
}()
// Write goroutine
go func() {
for writeM := range writeChannel {
jsonData, err := json.Marshal(writeM)
if err != nil {
LogInfo("Error while parsing the response", err)
return
}
err = wsc.conn.writeMessage(websocket.TextMessage, jsonData)
if err != nil {
LogInfo("Error while writing the message", err)
return
}
}
}()
<-done
}

View File

@@ -17,6 +17,10 @@ securityDefinitions:
flow: accessCode
authorizationUrl: http://min.io
tokenUrl: http://min.io
anonymous:
name: X-Anonymous
in: header
type: apiKey
# Apply the key security definition to all APIs
security:
- key: [ ]
@@ -290,6 +294,9 @@ paths:
/buckets/{bucket_name}/objects:
get:
summary: List Objects
security:
- key: [ ]
- anonymous: [ ]
operationId: ListObjects
parameters:
- name: bucket_name
@@ -312,6 +319,11 @@ paths:
in: query
required: false
type: boolean
- name: limit
in: query
required: false
type: integer
format: int32
responses:
200:
description: A successful response.
@@ -402,6 +414,9 @@ paths:
/buckets/{bucket_name}/objects/upload:
post:
summary: Uploads an Object.
security:
- key: [ ]
- anonymous: [ ]
consumes:
- multipart/form-data
parameters:
@@ -426,6 +441,9 @@ paths:
get:
summary: Download Object
operationId: Download Object
security:
- key: [ ]
- anonymous: [ ]
produces:
- application/octet-stream
parameters:
@@ -5679,7 +5697,7 @@ definitions:
latencyHistogram:
type: array
items:
$ref: "#/definitions/kmsLatencyHistogram"
$ref: "#/definitions/kmsLatencyHistogram"
uptime:
type: integer
cpus: