Anonymous Access (#2600)
Signed-off-by: Daniel Valdivia <18384552+dvaldivia@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
88
portal-ui/src/screens/AnonymousAccess/AnonymousAccess.tsx
Normal file
88
portal-ui/src/screens/AnonymousAccess/AnonymousAccess.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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:
|
||||
<strong>
|
||||
{bucketInfo?.creation_date
|
||||
? createdTime.toFormat(
|
||||
"ccc, LLL dd yyyy HH:mm:ss (ZZZZ)"
|
||||
)
|
||||
: ""}
|
||||
</strong>
|
||||
</span>
|
||||
<span className={classes.detailsSpacer}>
|
||||
Access:
|
||||
<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} 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:
|
||||
<strong>
|
||||
{bucketInfo?.creation_date
|
||||
? createdTime.toFormat(
|
||||
"ccc, LLL dd yyyy HH:mm:ss (ZZZZ)"
|
||||
)
|
||||
: ""}
|
||||
</strong>
|
||||
</span>
|
||||
<span className={classes.detailsSpacer}>
|
||||
Access:
|
||||
<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} 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
178
portal-ui/src/screens/Console/ObjectBrowser/OBHeader.tsx
Normal file
178
portal-ui/src/screens/Console/ObjectBrowser/OBHeader.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -93,6 +93,7 @@ export interface ObjectBrowserState {
|
||||
shareFileModalOpen: boolean;
|
||||
isOpeningObjectDetail: boolean;
|
||||
retentionConfig: IRetentionConfig | null;
|
||||
longFileOpen: boolean;
|
||||
}
|
||||
|
||||
export interface ObjectManager {
|
||||
|
||||
90
portal-ui/src/screens/Console/ObjectBrowser/utils.ts
Normal file
90
portal-ui/src/screens/Console/ObjectBrowser/utils.ts
Normal 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: "",
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
248
restapi/ws_objects.go
Normal 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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user