mirror of
https://github.com/rustfs/console.git
synced 2026-10-30 19:33:40 +08:00
feat: 添加对象保留功能及相关界面优化
This commit is contained in:
@@ -22,11 +22,11 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<n-space class="w-24">{{ t("Version") }}:</n-space>
|
||||
<n-switch v-model:value="version" @change="handleVersionChange" />
|
||||
<n-switch v-model:value="version" @update:value="handleVersionChange" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<n-space class="w-24">{{ t("Object Lock") }}:</n-space>
|
||||
<n-switch v-model:value="objectLock" @change="handleObjectLock" />
|
||||
<n-switch v-model:value="objectLock" @update:value="handleObjectLock" />
|
||||
</div>
|
||||
|
||||
<!-- Retention 配置区块,仅对象锁开启时显示 -->
|
||||
|
||||
+100
-7
@@ -17,14 +17,10 @@
|
||||
<span>{{ t("Tags") }}</span>
|
||||
</n-button>
|
||||
|
||||
<!-- <n-button v-if="lockStatus" @click="() => true == true">
|
||||
<n-button v-if="lockStatus" @click="() => (showRetentionView = true)">
|
||||
<Icon name="ri:save-2-line" class="mr-2" />
|
||||
<span>{{ t("Retention") }}</span>
|
||||
</n-button>
|
||||
<n-button v-if="lockStatus" @click="() => true == true">
|
||||
<Icon name="ri:auction-line" class="mr-2" />
|
||||
<span>{{ t("Legal Hold") }}</span>
|
||||
</n-button> -->
|
||||
|
||||
<n-button id="copyTag" ref="copyRef" @click="copySignUrl">
|
||||
<Icon name="ri:file-copy-line" class="mr-2" />
|
||||
@@ -64,7 +60,14 @@
|
||||
</n-descriptions-item>
|
||||
|
||||
<!-- 保留 -->
|
||||
<n-descriptions-item :label="t('Retention') + t('Policy')">compliance</n-descriptions-item>
|
||||
<n-descriptions-item :label="t('Retention') + t('Policy')">
|
||||
<n-space>
|
||||
<span>{{ t("Retention Type") + ": " + retentionMode }}</span>
|
||||
</n-space>
|
||||
<n-space>
|
||||
<span>{{ t("Retention RetainUntilDate") + ": " + retainUntilDate }}</span>
|
||||
</n-space>
|
||||
</n-descriptions-item>
|
||||
|
||||
<!-- <n-descriptions-item label="版本ID">{{ object?.VersionId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="存储类型">{{ object?.StorageClass }}</n-descriptions-item> -->
|
||||
@@ -105,6 +108,42 @@
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showRetentionView"
|
||||
preset="card"
|
||||
:title="t('Retention') + t('Policy')"
|
||||
class="max-w-screen-md">
|
||||
<n-card class="flex-col justify-center items-center">
|
||||
<n-form
|
||||
ref="retentionFormRef"
|
||||
:label-width="200"
|
||||
label-placement="left"
|
||||
label-align="left"
|
||||
class="w-[500px]">
|
||||
<n-form-item :label="t('Retention Mode')" path="retentionMode" class="flex-auto">
|
||||
<n-radio-group v-model:value="retentionMode">
|
||||
<n-radio value="COMPLIANCE">Compliance</n-radio>
|
||||
<n-radio value="GOVERNANCE">Governance</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item :label="t('Retention RetainUntilDate')" path="retainUntilDate" class="flex-auto">
|
||||
<n-date-picker
|
||||
v-model:formatted-value="retainUntilDate"
|
||||
value-format="yyyy-MM-dd HH:mm:ss"
|
||||
type="datetime"
|
||||
clearable />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item>
|
||||
<n-button type="primary" @click="submitRetention">{{ t("Confirm") }}</n-button>
|
||||
<n-button class="mx-4" @click="resetRetention">{{ t("Reset") }}</n-button>
|
||||
<n-button @click="showRetentionView = false">{{ t("Cancel") }}</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
@@ -112,6 +151,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const { t } = useI18n();
|
||||
const visibel = ref(false);
|
||||
@@ -178,6 +218,7 @@ const getObjectLockConfig = async () => {
|
||||
// 如果桶开启了object lock,则获取合法保留状态
|
||||
if (lockStatus.value) {
|
||||
getObjectLegalHoldFn();
|
||||
getObjectRetentionFn();
|
||||
}
|
||||
} else {
|
||||
lockStatus.value = false;
|
||||
@@ -211,7 +252,59 @@ const handleChangeLegalStatus = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/****************************************************************** */
|
||||
/**======================================================= */
|
||||
// 保留相关
|
||||
const retentionFormRef = ref();
|
||||
|
||||
const retentionMode = ref<string | null>(null);
|
||||
const retainUntilDate = ref<string | null>(null);
|
||||
console.log("🚀 ~ retainUntilDate:", retainUntilDate.value);
|
||||
|
||||
const retentionLoading = ref(false);
|
||||
const showRetentionView = ref(false);
|
||||
// 获取保留状态
|
||||
const getObjectRetentionFn = () => {
|
||||
const { getObjectRetention } = useObject({
|
||||
bucket: bucketName.value,
|
||||
});
|
||||
getObjectRetention(key.value).then((res) => {
|
||||
if (res.Retention) {
|
||||
retentionMode.value = res.Retention.Mode || "";
|
||||
retainUntilDate.value = res.Retention.RetainUntilDate
|
||||
? dayjs(res.Retention.RetainUntilDate).format("YYYY-MM-DD HH:mm:ss")
|
||||
: null;
|
||||
} else {
|
||||
retentionMode.value = "";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submitRetention = () => {
|
||||
retentionLoading.value = true;
|
||||
|
||||
const { putObjectRetention } = useObject({
|
||||
bucket: bucketName.value,
|
||||
});
|
||||
putObjectRetention(key.value, {
|
||||
Mode: retentionMode.value,
|
||||
RetainUntilDate: retainUntilDate.value ? new Date(retainUntilDate.value) : null,
|
||||
})
|
||||
.then(() => {
|
||||
message.success(t("Retention Update Success"));
|
||||
getObjectRetentionFn();
|
||||
})
|
||||
.finally(() => {
|
||||
retentionLoading.value = false;
|
||||
showRetentionView.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const resetRetention = () => {
|
||||
retentionMode.value = null;
|
||||
retainUntilDate.value = null;
|
||||
};
|
||||
|
||||
/**======================================================= */
|
||||
|
||||
// 删除标签
|
||||
const handledeleteTag = async (index: number) => {
|
||||
|
||||
+70
-41
@@ -38,10 +38,24 @@
|
||||
</div>
|
||||
</template>
|
||||
</n-page-header>
|
||||
<n-data-table class="border dark:border-neutral-700 rounded overflow-hidden" :columns="columns" :data="filteredObjects" :row-key="rowKey" @update:checked-row-keys="handleCheck"
|
||||
:pagination="false" :bordered="false" />
|
||||
<object-upload-picker :show="uploadPickerVisible" @update:show="(val: any) => (uploadPickerVisible = val && refresh())" :bucketName="bucketName" :prefix="prefix" />
|
||||
<object-new-form :show="newObjectFormVisible" :asPrefix="newObjectAsPrefix" @update:show="(val: any) => (newObjectFormVisible = val && refresh())" :bucketName="bucketName"
|
||||
<n-data-table
|
||||
class="border dark:border-neutral-700 rounded overflow-hidden"
|
||||
:columns="columns"
|
||||
:data="filteredObjects"
|
||||
:row-key="rowKey"
|
||||
@update:checked-row-keys="handleCheck"
|
||||
:pagination="false"
|
||||
:bordered="false" />
|
||||
<object-upload-picker
|
||||
:show="uploadPickerVisible"
|
||||
@update:show="(val: any) => (uploadPickerVisible = val && refresh())"
|
||||
:bucketName="bucketName"
|
||||
:prefix="prefix" />
|
||||
<object-new-form
|
||||
:show="newObjectFormVisible"
|
||||
:asPrefix="newObjectAsPrefix"
|
||||
@update:show="(val: any) => (newObjectFormVisible = val && refresh())"
|
||||
:bucketName="bucketName"
|
||||
:prefix="prefix" />
|
||||
<n-button-group class="ml-auto">
|
||||
<n-button @click="goToPreviousPage" :disabled="!continuationToken">
|
||||
@@ -58,15 +72,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const { $s3Client } = useNuxtApp();
|
||||
import { useAsyncData, useRoute, useRouter } from "#app"
|
||||
import { NuxtLink } from "#components"
|
||||
import { ListObjectsV2Command, type _Object, type CommonPrefix } from "@aws-sdk/client-s3"
|
||||
import dayjs from "dayjs"
|
||||
import type { DataTableColumns, DataTableRowKey } from 'naive-ui'
|
||||
import { joinRelativeURL } from "ufo"
|
||||
import { computed, ref, watch, type VNode } from "vue"
|
||||
import { useDeleteTaskManagerStore } from "~/store/delete-tasks"
|
||||
import { useUploadTaskManagerStore } from "~/store/upload-tasks"
|
||||
import { useAsyncData, useRoute, useRouter } from "#app";
|
||||
import { NuxtLink } from "#components";
|
||||
import { ListObjectsV2Command, type _Object, type CommonPrefix } from "@aws-sdk/client-s3";
|
||||
import dayjs from "dayjs";
|
||||
import type { DataTableColumns, DataTableRowKey } from "naive-ui";
|
||||
import { joinRelativeURL } from "ufo";
|
||||
import { computed, ref, watch, type VNode } from "vue";
|
||||
import { useDeleteTaskManagerStore } from "~/store/delete-tasks";
|
||||
import { useUploadTaskManagerStore } from "~/store/upload-tasks";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -77,7 +91,7 @@ const props = defineProps<{ bucket: string; path: string }>();
|
||||
const uploadPickerVisible = ref(false);
|
||||
const newObjectFormVisible = ref(false);
|
||||
const newObjectAsPrefix = ref(false);
|
||||
const searchTerm = ref('');
|
||||
const searchTerm = ref("");
|
||||
|
||||
// Add debounce function for search
|
||||
const debounce = (fn: Function, delay: number) => {
|
||||
@@ -107,8 +121,16 @@ const deleteTaskStore = useDeleteTaskManagerStore();
|
||||
const deleteTasks = computed(() => deleteTaskStore.tasks);
|
||||
|
||||
// 当任务变化时,刷新数据
|
||||
watch(() => uploadTasks, () => setTimeout(refresh, 500), { deep: true });
|
||||
watch(() => deleteTasks, () => setTimeout(refresh, 500), { deep: true });
|
||||
watch(
|
||||
() => uploadTasks,
|
||||
() => setTimeout(refresh, 500),
|
||||
{ deep: true }
|
||||
);
|
||||
watch(
|
||||
() => deleteTasks,
|
||||
() => setTimeout(refresh, 500),
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// bucketName
|
||||
const bucketName = computed(() => props.bucket as string);
|
||||
@@ -139,7 +161,6 @@ interface RowData {
|
||||
type: "prefix" | "object";
|
||||
Size: number;
|
||||
LastModified: string;
|
||||
|
||||
}
|
||||
|
||||
const infoRef = ref();
|
||||
@@ -159,13 +180,19 @@ const columns: DataTableColumns<RowData> = [
|
||||
}
|
||||
|
||||
const keyInUri = row.Key;
|
||||
return h(NuxtLink, {
|
||||
href: row.type === "prefix" ? bucketPath(keyInUri) : '', class: "block text-cyan-400 cursor-pointer", onClick: (e: MouseEvent) => {
|
||||
if (row.type === "prefix") return
|
||||
infoRef.value.openDrawer(bucketName.value, row.Key)
|
||||
return
|
||||
}
|
||||
}, () => label);
|
||||
return h(
|
||||
NuxtLink,
|
||||
{
|
||||
href: row.type === "prefix" ? bucketPath(keyInUri) : "",
|
||||
class: "block text-cyan-400 cursor-pointer",
|
||||
onClick: (e: MouseEvent) => {
|
||||
if (row.type === "prefix") return;
|
||||
infoRef.value.openDrawer(bucketName.value, row.Key);
|
||||
return;
|
||||
},
|
||||
},
|
||||
() => label
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: "Size", title: "大小", render: (row: { Size: number }) => (row.Size ? formatBytes(row.Size) : "") },
|
||||
@@ -173,7 +200,7 @@ const columns: DataTableColumns<RowData> = [
|
||||
key: "LastModified",
|
||||
title: "更新时间",
|
||||
render: (row: { LastModified: string }) => {
|
||||
return row.LastModified ? dayjs(row.LastModified).format('YYYY-MM-DD HH:mm:ss') : "";
|
||||
return row.LastModified ? dayjs(row.LastModified).format("YYYY-MM-DD HH:mm:ss") : "";
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -185,14 +212,14 @@ interface ListObjectsResponse {
|
||||
isTruncated: boolean;
|
||||
}
|
||||
const randomString = () => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const salt = ref(randomString())
|
||||
const salt = ref(randomString());
|
||||
// 在服务端获取数据
|
||||
const { data, refresh } = await useAsyncData<ListObjectsResponse>(
|
||||
`objectsData-${salt.value}&${prefix.value}&${pageSize.value}&${continuationToken.value}`,
|
||||
@@ -256,7 +283,7 @@ const filteredObjects = computed(() => {
|
||||
}
|
||||
|
||||
const term = searchTerm.value.toLowerCase();
|
||||
return objects.value.filter(obj => {
|
||||
return objects.value.filter((obj) => {
|
||||
const displayKey = prefix.value ? obj.Key?.substring(prefix.value.length) : obj.Key;
|
||||
return displayKey?.toLowerCase().includes(term);
|
||||
});
|
||||
@@ -287,20 +314,22 @@ function handleBatchDelete() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Promise.all(checkedKeys.value.map(async (item) => {
|
||||
const findOne = objects.value.find((obj) => obj.Key === item);
|
||||
// 目录删除
|
||||
// 递归查询目录下的所有文件,然后删除
|
||||
if (findOne?.type === "prefix" && findOne?.Key) {
|
||||
return await objectApi.mapAllFiles(bucketName.value, findOne.Key, (fileKey: string) => {
|
||||
deleteTaskStore.addKeys([fileKey], bucketName.value);
|
||||
});
|
||||
}
|
||||
await Promise.all(
|
||||
checkedKeys.value.map(async (item) => {
|
||||
const findOne = objects.value.find((obj) => obj.Key === item);
|
||||
// 目录删除
|
||||
// 递归查询目录下的所有文件,然后删除
|
||||
if (findOne?.type === "prefix" && findOne?.Key) {
|
||||
return await objectApi.mapAllFiles(bucketName.value, findOne.Key, (fileKey: string) => {
|
||||
deleteTaskStore.addKeys([fileKey], bucketName.value);
|
||||
});
|
||||
}
|
||||
|
||||
return deleteTaskStore.addKeys([String(item)], bucketName.value);
|
||||
}));
|
||||
return deleteTaskStore.addKeys([String(item)], bucketName.value);
|
||||
})
|
||||
);
|
||||
message.success("删除中");
|
||||
salt.value = randomString()
|
||||
salt.value = randomString();
|
||||
refresh();
|
||||
} catch (error) {
|
||||
message.error("删除失败");
|
||||
|
||||
@@ -131,13 +131,25 @@ export function useObject({ bucket, region }: { bucket: string; region?: string
|
||||
Key: key,
|
||||
};
|
||||
|
||||
return await $client.send(new GetObjectRetentionCommand(params));
|
||||
try {
|
||||
const response = await $client.send(new GetObjectRetentionCommand(params));
|
||||
if (!response || !response.Retention || Object.keys(response.Retention).length === 0) {
|
||||
return { Retention: null };
|
||||
}
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes("Deserialization error")) {
|
||||
return { Retention: null };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function putObjectRetention(key: string, retention: any) {
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Retention: retention,
|
||||
};
|
||||
return await $client.send(new PutObjectRetentionCommand(params));
|
||||
}
|
||||
@@ -147,7 +159,6 @@ export function useObject({ bucket, region }: { bucket: string; region?: string
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
};
|
||||
console.log("🚀 ~ getObjectLegalHold ~ params:", params);
|
||||
|
||||
return await $client.send(new GetObjectLegalHoldCommand(params));
|
||||
}
|
||||
|
||||
@@ -328,5 +328,10 @@
|
||||
"Please enter retention period": "Please enter retention period",
|
||||
"Please select retention unit": "Please select retention unit",
|
||||
"Set Retention": "Set Retention",
|
||||
"Legal Hold": "Legal Hold"
|
||||
"Legal Hold": "Legal Hold",
|
||||
"Retention RetainUntilDate": "Retention RetainUntilDate",
|
||||
"Retention Update Success": "Retention Update Success",
|
||||
"Retention Update Failed": "Retention Update Failed",
|
||||
"Retention RetainUntilDate Placeholder": "Retention RetainUntilDate Placeholder",
|
||||
"Retention RetainUntilDate Clear": "Retention RetainUntilDate Clear"
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
"Last Modified": "更新时间",
|
||||
"Ellipsis": "...",
|
||||
"Object Details": "对象详情",
|
||||
"Copy URL": "复制临时链接",
|
||||
"Copy URL": "复制链接",
|
||||
"Delete Confirm": "删除对象 {key} ?",
|
||||
"Info": "对象信息",
|
||||
"Object Name": "对象名称",
|
||||
@@ -520,5 +520,10 @@
|
||||
"Please enter retention period": "请输入保留期限",
|
||||
"Please select retention unit": "请选择保留单位",
|
||||
"Set Retention": "设置保留",
|
||||
"Legal Hold": "合法保留"
|
||||
"Legal Hold": "合法保留",
|
||||
"Retention RetainUntilDate": "保留至日期",
|
||||
"Retention Update Success": "保留更新成功",
|
||||
"Retention Update Failed": "保留更新失败",
|
||||
"Retention RetainUntilDate Placeholder": "请输入保留至日期",
|
||||
"Retention RetainUntilDate Clear": "清除保留至日期"
|
||||
}
|
||||
|
||||
+54
-26
@@ -1,8 +1,16 @@
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import type { SiteConfig } from "~/types/config";
|
||||
import type { DeserializeHandler, DeserializeHandlerArguments, DeserializeHandlerOutput } from "@aws-sdk/types";
|
||||
|
||||
interface S3Response {
|
||||
response?: {
|
||||
body?: string;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin({
|
||||
name: 's3-client',
|
||||
name: "s3-client",
|
||||
setup(nuxtApp) {
|
||||
const { credentials, isAuthenticated } = useAuth();
|
||||
const siteConfig = nuxtApp.$siteConfig as SiteConfig;
|
||||
@@ -11,44 +19,64 @@ export default defineNuxtPlugin({
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const client = new S3Client({
|
||||
endpoint: siteConfig.s3.endpoint,
|
||||
region: siteConfig.s3.region || 'us-east-1',
|
||||
region: siteConfig.s3.region || "us-east-1",
|
||||
forcePathStyle: true,
|
||||
// https://github.com/aws/aws-sdk-js-v3/issues/6834#issuecomment-2611346849
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
credentials: {
|
||||
accessKeyId: credentials.value?.AccessKeyId || '',
|
||||
secretAccessKey: credentials.value?.SecretAccessKey || '',
|
||||
sessionToken: credentials.value?.SessionToken || '',
|
||||
}
|
||||
accessKeyId: credentials.value?.AccessKeyId || "",
|
||||
secretAccessKey: credentials.value?.SecretAccessKey || "",
|
||||
sessionToken: credentials.value?.SessionToken || "",
|
||||
},
|
||||
});
|
||||
|
||||
// 添加中间件捕捉 401 错误
|
||||
// 添加中间件处理XML响应和401错误
|
||||
client.middlewareStack.add(
|
||||
(next, context) => async (args) => {
|
||||
try {
|
||||
return await next(args);
|
||||
} catch (error: any) {
|
||||
if (error?.$metadata?.httpStatusCode === 401) {
|
||||
await useAuth().logoutAndRedirect();
|
||||
(next: DeserializeHandler<any, any>) =>
|
||||
async (args: DeserializeHandlerArguments<any>): Promise<DeserializeHandlerOutput<any>> => {
|
||||
try {
|
||||
const response = (await next(args)) as S3Response;
|
||||
|
||||
// 检查响应是否为XML格式
|
||||
if (response.response?.body && typeof response.response.body === "string") {
|
||||
const body = response.response.body.trim();
|
||||
|
||||
// 检查是否是空的XML响应
|
||||
if (body.match(/^<\?xml[^>]*\?><[^>]*><\/[^>]*>$/)) {
|
||||
// 从XML标签名提取属性名
|
||||
const tagName = body.match(/<([^>]*)><\/\1>/)?.[1];
|
||||
if (tagName) {
|
||||
// 将XML标签名转换为驼峰命名
|
||||
const propertyName = tagName.replace(/(?:^|_)([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
// 返回null,保持response结构
|
||||
return {
|
||||
response: response.response,
|
||||
[propertyName]: null,
|
||||
} as unknown as DeserializeHandlerOutput<any>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response as DeserializeHandlerOutput<any>;
|
||||
} catch (error: any) {
|
||||
if (error?.$metadata?.httpStatusCode === 401) {
|
||||
await useAuth().logoutAndRedirect();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// 其他错误处理
|
||||
console.error('S3Client error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
step: "finalizeRequest",
|
||||
name: "handleUnauthorizedMiddleware",
|
||||
step: "deserialize",
|
||||
name: "handleXmlResponse",
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
provide: {
|
||||
s3Client: client
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
s3Client: client,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user