diff --git a/apps/dcellar-web-ui/src/facade/object.ts b/apps/dcellar-web-ui/src/facade/object.ts index fb36da15..dec869be 100644 --- a/apps/dcellar-web-ui/src/facade/object.ts +++ b/apps/dcellar-web-ui/src/facade/object.ts @@ -10,7 +10,6 @@ import { import { broadcastFault, commonFault, - downloadPreviewFault, E_NO_QUOTA, E_NOT_FOUND, E_PERMISSION_DENIED, @@ -32,19 +31,16 @@ import { VisibilityType } from '@bnb-chain/greenfield-cosmos-types/greenfield/st import { quotaRemains } from '@/facade/bucket'; import { ObjectInfo } from '@bnb-chain/greenfield-cosmos-types/greenfield/storage/types'; import { encodeObjectName } from '@/utils/string'; -import { - downloadWithProgress, - saveFileByAxiosResponse, - viewFileByAxiosResponse, -} from '@/modules/file/utils'; -import axios, { AxiosResponse } from 'axios'; +import axios from 'axios'; import { SpItem } from '@/store/slices/sp'; import { QueryHeadObjectResponse, QueryLockFeeRequest, } from '@bnb-chain/greenfield-cosmos-types/greenfield/storage/query'; import { signTypedDataV4 } from '@/utils/signDataV4'; -import BigNumber from 'bignumber.js'; +import { getDomain } from '@/utils/getDomain'; +import { generateGetObjectOptions } from '@/modules/file/utils/generateGetObjectOptions'; +import { batchDownload, directlyDownload } from '@/modules/file/utils'; export type DeliverResponse = Awaited>; @@ -101,29 +97,30 @@ export const getCanObjectAccess = async ( }; export type DownloadPreviewParams = { - objectInfo: ObjectInfo; + objectInfo: { bucketName: string; objectName: string; visibility: number }; primarySp: SpItem; address: string; }; -const getObjectBytes = async ( +export const getAuthorizedLink = async ( params: DownloadPreviewParams, seedString: string, -): Promise<[AxiosResponse | null, ErrorMsg?]> => { + view = 1, +): Promise<[null, ErrorMsg] | [string]> => { const { address, primarySp, objectInfo } = params; - const { bucketName, objectName, payloadSize } = objectInfo; - - const [result, error] = await downloadWithProgress({ + const { bucketName, objectName } = objectInfo; + const domain = getDomain(); + const [options, error] = await generateGetObjectOptions({ bucketName, objectName, - primarySp, - payloadSize: payloadSize.toNumber(), - address, + endpoint: primarySp.endpoint, + userAddress: address, + domain, seedString, - }).then(resolve, downloadPreviewFault); - if (!result) return [null, error]; - - return [result]; + }).then(resolve, commonFault); + if (error) return [null, error]; + const { url, params: _params } = options!; + return [`${url}?${_params}&view=${view}`]; }; export const downloadObject = async ( @@ -137,14 +134,14 @@ export const downloadObject = async ( const isPrivate = visibility === VisibilityType.VISIBILITY_TYPE_PRIVATE; const link = `${endpoint}/download/${bucketName}/${encodeObjectName(objectName)}`; if (!isPrivate) { - window.location.href = link; + batchDownload(link); return [true]; } - const [result, error] = await getObjectBytes(params, seedString); - if (!result) return [false, error]; + const [url, error] = await getAuthorizedLink(params, seedString, 0); + if (!url) return [false, error]; - saveFileByAxiosResponse(result, objectName); + batchDownload(url); return [true]; }; @@ -159,14 +156,14 @@ export const previewObject = async ( const isPrivate = visibility === VisibilityType.VISIBILITY_TYPE_PRIVATE; const link = `${endpoint}/view/${bucketName}/${encodeObjectName(objectName)}`; if (!isPrivate) { - window.open(link, '_blank'); + directlyDownload(link, '_blank'); return [true]; } - const [result, error] = await getObjectBytes(params, seedString); - if (!result) return [false, error]; + const [url, error] = await getAuthorizedLink(params, seedString); + if (!url) return [false, error]; - viewFileByAxiosResponse(result); + directlyDownload(url, '_blank'); return [true]; }; diff --git a/apps/dcellar-web-ui/src/modules/file/utils/generateGetObjectOptions.ts b/apps/dcellar-web-ui/src/modules/file/utils/generateGetObjectOptions.ts index 009795f4..5c4b5b9e 100644 --- a/apps/dcellar-web-ui/src/modules/file/utils/generateGetObjectOptions.ts +++ b/apps/dcellar-web-ui/src/modules/file/utils/generateGetObjectOptions.ts @@ -30,10 +30,9 @@ export const generateGetObjectOptions = async ( }); const params = new URLSearchParams(); - // params.append('authorization', body?.authorization || ''); - // params.append('user-address', userAddress); - // params.append('app-domain', domain); - // params.append('view', '1'); + params.append('authorization', body?.authorization || ''); + params.append('user-address', userAddress); + params.append('app-domain', domain); return { url, diff --git a/apps/dcellar-web-ui/src/modules/file/utils/index.tsx b/apps/dcellar-web-ui/src/modules/file/utils/index.tsx index 5a9d1391..3286d3d1 100644 --- a/apps/dcellar-web-ui/src/modules/file/utils/index.tsx +++ b/apps/dcellar-web-ui/src/modules/file/utils/index.tsx @@ -293,7 +293,7 @@ const renderInsufficientBalance = ( ); }; -const directlyDownload = (url: string, name?: string) => { +const directlyDownload = (url: string, target = '_self', name?: string) => { if (!url) { toast.error({ description: 'Download url not existed. Please check.', @@ -302,11 +302,22 @@ const directlyDownload = (url: string, name?: string) => { const link = document.createElement('a'); link.href = url; link.download = name || ''; + link.target = target; document.body.appendChild(link); link.click(); document.body.removeChild(link); }; +export const batchDownload = (url: string | string[]) => { + const urls = Array().concat(url); + urls.forEach((url) => { + const iframe = document.createElement('iframe'); + iframe.src = url; + iframe.style.display = 'none'; + document.body.appendChild(iframe); + }); +}; + const getQuota = async ( bucketName: string, endpoint: string, diff --git a/apps/dcellar-web-ui/src/modules/object/components/BatchOperations.tsx b/apps/dcellar-web-ui/src/modules/object/components/BatchOperations.tsx index 9f573f99..70debd9c 100644 --- a/apps/dcellar-web-ui/src/modules/object/components/BatchOperations.tsx +++ b/apps/dcellar-web-ui/src/modules/object/components/BatchOperations.tsx @@ -5,11 +5,13 @@ import { Text } from '@totejs/uikit'; import { useAppDispatch, useAppSelector } from '@/store'; import { E_NO_QUOTA, E_OFF_CHAIN_AUTH, E_UNKNOWN } from '@/facade/error'; import { OBJECT_ERROR_TYPES, ObjectErrorType } from '@/modules/object/ObjectError'; -import { setStatusDetail } from '@/store/slices/object'; +import { setSelectedRowKeys, setStatusDetail } from '@/store/slices/object'; import { useOffChainAuth } from '@/hooks/useOffChainAuth'; import { useMount } from 'ahooks'; import { setupBucketQuota } from '@/store/slices/bucket'; import { quotaRemains } from '@/facade/bucket'; +import { getSpOffChainData } from '@/store/slices/persist'; +import { downloadObject } from '@/facade/object'; interface BatchOperationsProps {} @@ -20,8 +22,10 @@ export const BatchOperations = memo(function BatchOperatio const { setOpenAuthModal } = useOffChainAuth(); const { loginAccount } = useAppSelector((root) => root.persist); const { bucketName, objects, path } = useAppSelector((root) => root.object); + const { primarySpInfo } = useAppSelector((root) => root.sp); const quotas = useAppSelector((root) => root.bucket.quotas); const quotaData = quotas[bucketName]; + const primarySp = primarySpInfo[bucketName]; useMount(() => { dispatch(setupBucketQuota(bucketName)); @@ -45,10 +49,18 @@ export const BatchOperations = memo(function BatchOperatio items.reduce((x, y) => x + y.payloadSize, 0), ); if (!remainQuota) return onError(E_NO_QUOTA); - // const operator = primarySp.operatorAddress; - // const { seedString } = await dispatch(getSpOffChainData(loginAccount, operator)); - // const domain = getDomain(); - // todo + const operator = primarySp.operatorAddress; + const { seedString } = await dispatch(getSpOffChainData(loginAccount, operator)); + + for (const item of items) { + const payload = { + primarySp, + objectInfo: item, + address: loginAccount, + }; + await downloadObject(payload, seedString); + } + dispatch(setSelectedRowKeys([])); }; return ( diff --git a/apps/dcellar-web-ui/src/modules/object/components/ObjectList.tsx b/apps/dcellar-web-ui/src/modules/object/components/ObjectList.tsx index 87fca572..9d235f92 100644 --- a/apps/dcellar-web-ui/src/modules/object/components/ObjectList.tsx +++ b/apps/dcellar-web-ui/src/modules/object/components/ObjectList.tsx @@ -59,6 +59,8 @@ import { CreateFolder } from './CreateFolder'; import { useOffChainAuth } from '@/hooks/useOffChainAuth'; import { StyledRow } from '@/modules/object/objects.style'; import { UploadFile, selectUploadQueue } from '@/store/slices/global'; +import { copy, encodeObjectName, getShareLink } from '@/utils/string'; +import { toast } from '@totejs/uikit'; const Actions: ActionMenuItem[] = [ { label: 'View Details', value: 'detail' }, @@ -86,7 +88,7 @@ export const ObjectList = memo(function ObjectList() { ); const currentPage = useAppSelector(selectPathCurrent); const { discontinue, owner } = useAppSelector((root) => root.bucket); - const { primarySpInfo} = useAppSelector((root) => root.sp); + const { primarySpInfo } = useAppSelector((root) => root.sp); const loading = useAppSelector(selectPathLoading); const objectList = useAppSelector(selectObjectList); const { setOpenAuthModal } = useOffChainAuth(); @@ -181,7 +183,9 @@ export const ObjectList = memo(function ObjectList() { case 'delete': return dispatch(setEditDelete(record)); case 'share': - return dispatch(setEditShare(record)); + copy(getShareLink(bucketName, record.objectName)); + toast.success({ description: 'Successfully copied to your clipboard.' }); + return; case 'download': return download(record); case 'cancel': @@ -275,7 +279,7 @@ export const ObjectList = memo(function ObjectList() { // It is not allowed to cancel when the chain is sealed, but the SP is not synchronized. const file = find( uploadQueue, - (q) => [...q.prefixFolders, q.file.name].join('/') === record.objectName + (q) => [...q.prefixFolders, q.file.name].join('/') === record.objectName, ); if (file) { fitActions = fitActions.filter((a) => a.value !== 'cancel'); diff --git a/apps/dcellar-web-ui/src/modules/object/components/ShareObject.tsx b/apps/dcellar-web-ui/src/modules/object/components/ShareObject.tsx index 28663d8c..ab1e7ff2 100644 --- a/apps/dcellar-web-ui/src/modules/object/components/ShareObject.tsx +++ b/apps/dcellar-web-ui/src/modules/object/components/ShareObject.tsx @@ -16,7 +16,7 @@ import { GAClick } from '@/components/common/GATracker'; // import { AccessItem } from '@/modules/file/components/AccessItem'; import { useAppDispatch, useAppSelector } from '@/store'; import { ObjectItem, setEditShare } from '@/store/slices/object'; -import { encodeObjectName } from '@/utils/string'; +import { getShareLink } from '@/utils/string'; interface modalProps {} @@ -24,7 +24,6 @@ export const ShareObject = (props: modalProps) => { const dispatch = useAppDispatch(); const { hasCopied, onCopy, setValue } = useClipboard(''); const { editShare, bucketName } = useAppSelector((root) => root.object); - const params = [bucketName, encodeObjectName(editShare.objectName)].join('/'); const isOpen = !!editShare.objectName; const onClose = () => { dispatch(setEditShare({} as ObjectItem)); @@ -33,8 +32,8 @@ export const ShareObject = (props: modalProps) => { }; useEffect(() => { - setValue(`${location.origin}/share?file=${encodeURIComponent(params)}`); - }, [setValue, params]); + setValue(getShareLink(bucketName, editShare.objectName)); + }, [setValue, bucketName, editShare.objectName]); return ( <> diff --git a/apps/dcellar-web-ui/src/modules/object/components/SharePermission.tsx b/apps/dcellar-web-ui/src/modules/object/components/SharePermission.tsx index f8d9792a..7f46fb91 100644 --- a/apps/dcellar-web-ui/src/modules/object/components/SharePermission.tsx +++ b/apps/dcellar-web-ui/src/modules/object/components/SharePermission.tsx @@ -30,7 +30,7 @@ import { import { useOffChainAuth } from '@/hooks/useOffChainAuth'; import { ViewerList } from '@/modules/object/components/ViewerList'; import { CopyButton } from '@/modules/object/components/CopyButton'; -import { encodeObjectName } from '@/utils/string'; +import { encodeObjectName, getShareLink } from '@/utils/string'; interface SharePermissionProps {} @@ -109,8 +109,6 @@ export const SharePermission = memo(function SharePermissi ); }; - const params = [bucketName, encodeObjectName(editDetail.objectName)].join('/'); - return ( <> (function SharePermissi /> )} - + Copy Link diff --git a/apps/dcellar-web-ui/src/modules/object/index.tsx b/apps/dcellar-web-ui/src/modules/object/index.tsx index 172a2a1c..1372e313 100644 --- a/apps/dcellar-web-ui/src/modules/object/index.tsx +++ b/apps/dcellar-web-ui/src/modules/object/index.tsx @@ -22,7 +22,7 @@ import { BatchOperations } from '@/modules/object/components/BatchOperations'; export const ObjectsPage = () => { const dispatch = useAppDispatch(); - const { allSps, primarySpInfo} = useAppSelector((root) => root.sp); + const { allSps, primarySpInfo } = useAppSelector((root) => root.sp); const { bucketInfo } = useAppSelector((root) => root.bucket); const { loginAccount } = useAppSelector((root) => root.persist); const selectedRowKeys = useAppSelector((root) => root.object.selectedRowKeys); @@ -45,11 +45,15 @@ export const ObjectsPage = () => { if (!bucket) return; const primarySp = primarySpInfo[bucketName]; if (!primarySp) { - const [data, error] = await getVirtualGroupFamily({ familyId: bucket.global_virtual_group_family_id }); - const sp = allSps.find((item) => item.id === data?.globalVirtualGroupFamily?.primarySpId) as SpItem; - dispatch(setPrimarySpInfo({ bucketName, sp})); + const [data, error] = await getVirtualGroupFamily({ + familyId: bucket.global_virtual_group_family_id, + }); + const sp = allSps.find( + (item) => item.id === data?.globalVirtualGroupFamily?.primarySpId, + ) as SpItem; + dispatch(setPrimarySpInfo({ bucketName, sp })); } - }, [bucketInfo, bucketName]) + }, [bucketInfo, bucketName]); useAsyncEffect(async () => { const bucket = bucketInfo[bucketName]; diff --git a/apps/dcellar-web-ui/src/modules/share/SharedFile.tsx b/apps/dcellar-web-ui/src/modules/share/SharedFile.tsx index d79813b3..39ab30a4 100644 --- a/apps/dcellar-web-ui/src/modules/share/SharedFile.tsx +++ b/apps/dcellar-web-ui/src/modules/share/SharedFile.tsx @@ -17,7 +17,7 @@ import { useAppDispatch, useAppSelector } from '@/store'; import { getSpOffChainData } from '@/store/slices/persist'; import { setupBucketQuota } from '@/store/slices/bucket'; import { useOffChainAuth } from '@/hooks/useOffChainAuth'; -import { getSpUrlByBucketName, getVirtualGroupFamily } from '@/facade/virtual-group'; +import { getSpUrlByBucketName } from '@/facade/virtual-group'; import { SpItem } from '@/store/slices/sp'; import { VisibilityType } from '../file/type'; diff --git a/apps/dcellar-web-ui/src/modules/upload/UploadObjects.tsx b/apps/dcellar-web-ui/src/modules/upload/UploadObjects.tsx index f3ddc0e8..b300daa5 100644 --- a/apps/dcellar-web-ui/src/modules/upload/UploadObjects.tsx +++ b/apps/dcellar-web-ui/src/modules/upload/UploadObjects.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { memo, useMemo, useState } from 'react'; import { Box, Flex, @@ -65,7 +65,10 @@ import { createTmpAccount } from '@/facade/account'; import { parseEther } from 'ethers/lib/utils.js'; import { useAccount } from 'wagmi'; -export const UploadObjects = () => { +interface UploadObjectsProps {} + +// add memo avoid parent state change rerender +export const UploadObjects = memo(function UploadObjects() { const dispatch = useAppDispatch(); const { _availableBalance: availableBalance } = useAppSelector((root) => root.global); const { editUpload, bucketName, path, objects, folders } = useAppSelector((root) => root.object); @@ -127,7 +130,7 @@ export const UploadObjects = () => { .filter((item) => item); const isExistObjectList = objectListNames.includes(file.name); const isExistUploadList = uploadingNames.includes(file.name); - if ( isExistObjectList || isExistUploadList) { + if (isExistObjectList || isExistUploadList) { return E_OBJECT_NAME_EXISTS; } return ''; @@ -161,11 +164,14 @@ export const UploadObjects = () => { Number(amount) * 1.05 > Number(availableBalance) ? round(Number(availableBalance), 6) : round(Number(amount) * 1.05, 6); - const [tmpAccount, error] = await createTmpAccount({ - address: loginAccount, - bucketName, - amount: parseEther(String(safeAmount)).toString(), - }, connector); + const [tmpAccount, error] = await createTmpAccount( + { + address: loginAccount, + bucketName, + amount: parseEther(String(safeAmount)).toString(), + }, + connector, + ); if (!tmpAccount) { return errorHandler(error); } @@ -203,7 +209,7 @@ export const UploadObjects = () => { }, [preLockFeeObjects, selectedFiles]); const checkedQueue = selectedFiles.filter((item) => item.status === 'WAIT'); - console.log(loading, creating, !checkedQueue?.length, !editUpload.isBalanceAvailable, editUpload) + // console.log(loading, creating, !checkedQueue?.length, !editUpload.isBalanceAvailable, editUpload); return ( @@ -213,7 +219,13 @@ export const UploadObjects = () => { setActiveKey(key)}> {tabOptions.map((item) => ( - + {item.icon} {item.title}({item.len}) @@ -241,10 +253,9 @@ export const UploadObjects = () => { Total Upload:{' '} {formatBytes( - checkedQueue.filter(item => item.status === 'WAIT').reduce( - (accumulator, currentValue) => accumulator + currentValue.size, - 0, - ), + checkedQueue + .filter((item) => item.status === 'WAIT') + .reduce((accumulator, currentValue) => accumulator + currentValue.size, 0), )} {' '} / {checkedQueue.length} Objects @@ -256,7 +267,9 @@ export const UploadObjects = () => { w="100%" variant={'dcPrimary'} onClick={onUploadClick} - isDisabled={loading || creating || !checkedQueue?.length || !editUpload.isBalanceAvailable} + isDisabled={ + loading || creating || !checkedQueue?.length || !editUpload.isBalanceAvailable + } justifyContent={'center'} gaClickName="dc.file.upload_modal.confirm.click" > @@ -274,4 +287,4 @@ export const UploadObjects = () => { ); -}; +}); diff --git a/apps/dcellar-web-ui/src/store/slices/object.ts b/apps/dcellar-web-ui/src/store/slices/object.ts index bda68c29..b68d5e88 100644 --- a/apps/dcellar-web-ui/src/store/slices/object.ts +++ b/apps/dcellar-web-ui/src/store/slices/object.ts @@ -11,6 +11,7 @@ export const SINGLE_OBJECT_MAX_SIZE = 128 * 1024 * 1024; export const SELECT_OBJECT_NUM_LIMIT = 10; export type ObjectItem = { + bucketName: string; objectName: string; name: string; payloadSize: number; @@ -38,8 +39,8 @@ export type TEditUploadContent = { preLockFee: string; totalFee: string; isBalanceAvailable: boolean; -} -export type TEditUpload = TEditUploadContent & { isOpen: boolean; } +}; +export type TEditUpload = TEditUploadContent & { isOpen: boolean }; export interface ObjectState { bucketName: string; folders: string[]; @@ -180,7 +181,7 @@ export const objectSlice = createSlice({ setEditUpload(state, { payload }: PayloadAction) { state.editUpload = { ...state.editUpload, - ...payload + ...payload, }; }, setEditCancel(state, { payload }: PayloadAction) { @@ -199,6 +200,7 @@ export const objectSlice = createSlice({ const folders = list.common_prefixes .reverse() .map((i, index) => ({ + bucketName, objectName: i, name: last(trimEnd(i, '/').split('/'))!, payloadSize: 0, @@ -232,6 +234,7 @@ export const objectSlice = createSlice({ state.objectsInfo[path] = i; return { + bucketName: bucket_name, objectName: object_name, name: last(object_name.split('/'))!, payloadSize: Number(payload_size), @@ -255,7 +258,7 @@ export const objectSlice = createSlice({ }, setListRefreshing(state, { payload }: PayloadAction) { state.refreshing = payload; - } + }, }, }); @@ -291,6 +294,7 @@ export const setupDummyFolder = setDummyFolder({ path, folder: { + bucketName, objectName: prefix + name + '/', name: last(trimEnd(name, '/').split('/'))!, payloadSize: 0, @@ -306,29 +310,28 @@ export const setupDummyFolder = }; export const setupListObjects = (params: Partial, _path?: string) => - async (dispatch: AppDispatch, getState: GetState) => { - - const { prefix, bucketName, path, restoreCurrent } = getState().object; - const { loginAccount: address } = getState().persist; - const _query = new URLSearchParams(params.query?.toString() || ''); - _query.append('max-keys', '1000'); - _query.append('delimiter', '/'); - if (prefix) _query.append('prefix', prefix); - // support any path list objects, bucketName & _path - const payload = { bucketName, ...params, query: _query, address } as ListObjectsParams; - // fix refresh then nav to other pages. - if (!bucketName) return; - const [res, error] = await _getAllList(payload); - if (error) { - toast.error({ description: error }); - return; - } - dispatch(setObjectList({ path: _path || path, list: res! })); - dispatch(setRestoreCurrent(true)); - if (!restoreCurrent) { - dispatch(setCurrentObjectPage({ path, current: 0 })); - } - }; + async (dispatch: AppDispatch, getState: GetState) => { + const { prefix, bucketName, path, restoreCurrent } = getState().object; + const { loginAccount: address } = getState().persist; + const _query = new URLSearchParams(params.query?.toString() || ''); + _query.append('max-keys', '1000'); + _query.append('delimiter', '/'); + if (prefix) _query.append('prefix', prefix); + // support any path list objects, bucketName & _path + const payload = { bucketName, ...params, query: _query, address } as ListObjectsParams; + // fix refresh then nav to other pages. + if (!bucketName) return; + const [res, error] = await _getAllList(payload); + if (error) { + toast.error({ description: error }); + return; + } + dispatch(setObjectList({ path: _path || path, list: res! })); + dispatch(setRestoreCurrent(true)); + if (!restoreCurrent) { + dispatch(setCurrentObjectPage({ path, current: 0 })); + } + }; export const closeStatusDetail = () => async (dispatch: AppDispatch) => { dispatch(setStatusDetail({} as TStatusDetail)); diff --git a/apps/dcellar-web-ui/src/utils/string.ts b/apps/dcellar-web-ui/src/utils/string.ts index 934e7e42..174939b9 100644 --- a/apps/dcellar-web-ui/src/utils/string.ts +++ b/apps/dcellar-web-ui/src/utils/string.ts @@ -46,3 +46,32 @@ export const formatId = (id: number) => { const value = `0x${hex.padStart(64, '0')}`; return value; }; + +export const copy = (text: string) => { + const range = document.createRange(); + const div = document.createElement('div'); + div.innerText = text; + div.style.position = 'absolute'; + div.style.left = '-99999px'; + div.style.top = '-99999px'; + document.body.appendChild(div); + range.selectNode(div); + + const selection = document.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + + document.execCommand('copy'); + range.detach(); + document.body.removeChild(div); +}; + +const getObjectPath = (bucketName: string, objectName: string) => { + return [bucketName, encodeObjectName(objectName)].join('/'); +}; + +export const getShareLink = (bucketName: string, objectName: string) => { + return `${location.origin}/share?file=${encodeURIComponent( + getObjectPath(bucketName, objectName), + )}`; +};