refactor: support both r2 and s3 storage (#718)

This commit is contained in:
Xi Gou
2025-03-27 14:00:27 +08:00
committed by GitHub
parent 7ba938ea25
commit 2b6320b880
10 changed files with 319 additions and 151 deletions
+9
View File
@@ -9,9 +9,18 @@ SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
DEEPL_FREE_API_KEYS=YOUR_DEEPL_FREE_API_KEYS
# r2, s3
NEXT_PUBLIC_OBJECT_STORAGE_TYPE=r2
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME
R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID
R2_REGION=YOUR_R2_REGION
S3_ENDPOINT=PLACE_HOLDER
S3_ACCESS_KEY_ID=PLACE_HOLDER
S3_SECRET_ACCESS_KEY=PLACE_HOLDER
S3_BUCKET_NAME=PLACE_HOLDER
S3_REGION=PLACE_HOLDER
@@ -9,7 +9,7 @@ import { useLongPress } from '@/hooks/useLongPress';
import { Menu, MenuItem } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { getOSPlatform } from '@/utils/misc';
import { getFilename } from '@/utils/book';
import { getLocalBookFilename } from '@/utils/book';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BookGroupType, BooksGroup } from '@/types/book';
@@ -152,7 +152,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const showBookInFinderMenuItem = await MenuItem.new({
text: _(fileRevealLabel),
action: async () => {
const folder = `${settings.localBooksDir}/${getFilename(book)}`;
const folder = `${settings.localBooksDir}/${getLocalBookFilename(book)}`;
revealItemInDir(folder);
},
});
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { createSupabaseClient } from '@/utils/supabase';
import { validateUserAndToken } from '@/utils/access';
import { deleteObject } from '@/utils/r2';
import { deleteObject } from '@/utils/object';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await runMiddleware(req, res, corsAllMethods);
@@ -41,7 +41,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
try {
await deleteObject(process.env['R2_BUCKET_NAME'] || '', fileKey);
await deleteObject(fileKey);
const { error: deleteError } = await supabase.from('files').delete().eq('id', fileRecord.id);
if (deleteError) {
@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase, createSupabaseClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { getDownloadSignedUrl } from '@/utils/r2';
import { getDownloadSignedUrl } from '@/utils/object';
const getUserAndToken = async (authHeader: string | undefined) => {
if (!authHeader) return {};
@@ -55,8 +55,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
try {
const bucketName = process.env['R2_BUCKET_NAME'] || '';
const downloadUrl = await getDownloadSignedUrl(bucketName, fileKey, 1800);
const downloadUrl = await getDownloadSignedUrl(fileKey, 1800);
res.status(200).json({
downloadUrl,
@@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase, createSupabaseClient } from '@/utils/supabase';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { getStoragePlanData } from '@/utils/access';
import { getUploadSignedUrl } from '@/utils/r2';
import { getUploadSignedUrl } from '@/utils/object';
const getUserAndToken = async (authHeader: string | undefined) => {
if (!authHeader) return {};
@@ -40,13 +40,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(403).json({ error: 'Insufficient storage quota', usage });
}
const objectKey = `${user.id}/${fileName}`;
const fileKey = `${user.id}/${fileName}`;
const supabase = createSupabaseClient(token);
const { data: existingRecord, error: fetchError } = await supabase
.from('files')
.select('*')
.eq('user_id', user.id)
.eq('file_key', objectKey)
.eq('file_key', fileKey)
.limit(1)
.single();
@@ -63,7 +63,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
{
user_id: user.id,
book_hash: bookHash,
file_key: objectKey,
file_key: fileKey,
file_size: fileSize,
},
])
@@ -75,15 +75,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
try {
const uploadUrl = await getUploadSignedUrl(
process.env['R2_BUCKET_NAME'] || '',
objectKey,
fileKey,
objSize,
1800,
);
res.status(200).json({
uploadUrl,
fileKey: objectKey,
fileKey,
usage: usage + fileSize,
quota,
});
+109 -78
View File
@@ -5,7 +5,8 @@ import { FileSystem, BaseDir } from '@/types/system';
import { Book, BookConfig, BookContent, BookFormat } from '@/types/book';
import {
getDir,
getFilename,
getLocalBookFilename,
getRemoteBookFilename,
getBaseFilename,
getCoverFilename,
getConfigFilename,
@@ -31,7 +32,6 @@ import {
DEFAULT_SYSTEM_SETTINGS,
DEFAULT_CJK_VIEW_SETTINGS,
} from './constants';
import { isWebAppPlatform } from './environment';
import { getOSPlatform, isCJKEnv, isValidURL } from '@/utils/misc';
import { deserializeConfig, serializeConfig } from '@/utils/serializer';
import { downloadFile, uploadFile, deleteFile, createProgressHandler } from '@/libs/storage';
@@ -184,11 +184,11 @@ export abstract class BaseAppService implements AppService {
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
if (saveBook && (!(await this.fs.exists(getFilename(book), 'Books')) || overwrite)) {
if (saveBook && (!(await this.fs.exists(getLocalBookFilename(book), 'Books')) || overwrite)) {
if (typeof file === 'string' && !isValidURL(file) && !filename.endsWith('.txt')) {
await this.fs.copyFile(file, getFilename(book), 'Books');
await this.fs.copyFile(file, getLocalBookFilename(book), 'Books');
} else {
await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer());
await this.fs.writeFile(getLocalBookFilename(book), 'Books', await fileobj.arrayBuffer());
}
}
if (saveCover && (!(await this.fs.exists(getCoverFilename(book), 'Books')) || overwrite)) {
@@ -218,12 +218,12 @@ export abstract class BaseAppService implements AppService {
}
async deleteBook(book: Book, includingUploaded = false): Promise<void> {
const fps = [getFilename(book), getCoverFilename(book)];
const localDeleteFps = (
await Promise.all(fps.map(async (fp) => ((await this.fs.exists(fp, 'Books')) ? fp : null)))
).filter(Boolean) as string[];
const fps = [getRemoteBookFilename(book), getCoverFilename(book)];
const localDeleteFps = [getLocalBookFilename(book), getCoverFilename(book)];
for (const fp of localDeleteFps) {
await this.fs.removeFile(fp, 'Books');
if (await this.fs.exists(fp, 'Books')) {
await this.fs.removeFile(fp, 'Books');
}
}
for (const fp of fps) {
if (includingUploaded) {
@@ -243,38 +243,61 @@ export abstract class BaseAppService implements AppService {
}
}
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
async uploadFileToCloud(
lfp: string,
cfp: string,
handleProgress: ProgressHandler,
hash: string
) {
let file: File;
if (this.appPlatform === 'web') {
const content = await this.fs.readFile(lfp, 'Books', 'binary');
file = new File([content], cfp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${lfp}`), cfp).open();
}
console.log('Uploading file:', lfp, 'to', cfp);
const localFullpath = `${this.localBooksDir}/${lfp}`;
await uploadFile(file, localFullpath, handleProgress, hash);
}
async uploadBook(book: Book, onProgress?: ProgressHandler): Promise<void> {
let uploaded = false;
const completedFiles = { count: 0 };
const fps = (
await Promise.all(
[getCoverFilename(book), getFilename(book)].map(async (fp) =>
(await this.fs.exists(fp, 'Books')) ? fp : null,
),
)
).filter(Boolean) as string[];
if (!fps.includes(getFilename(book)) && book.url) {
let toUploadFpCount = 0;
const coverExist = (await this.fs.exists(getCoverFilename(book), 'Books'));
let bookFileExist = (await this.fs.exists(getLocalBookFilename(book), 'Books'));
if (coverExist) {
toUploadFpCount++;
}
if (bookFileExist) {
toUploadFpCount++;
}
if (!bookFileExist && book.url) {
// download the book from the URL
const fileobj = await new RemoteFile(book.url).open();
await this.fs.writeFile(getFilename(book), 'Books', await fileobj.arrayBuffer());
fps.push(getFilename(book));
await this.fs.writeFile(getLocalBookFilename(book), 'Books', await fileobj.arrayBuffer());
bookFileExist = true;
}
const handleProgress = createProgressHandler(fps.length, completedFiles, onProgress);
for (const fp of fps) {
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
const fullpath = `${this.localBooksDir}/${fp}`;
if (this.appPlatform === 'web') {
const content = await this.fs.readFile(fp, 'Books', 'binary');
file = new File([content], cfp);
} else {
file = await new RemoteFile(this.fs.getURL(`${this.localBooksDir}/${fp}`), cfp).open();
}
console.log('Uploading file:', fp);
await uploadFile(file, fullpath, handleProgress, book.hash);
const handleProgress = createProgressHandler(toUploadFpCount, completedFiles, onProgress);
if (coverExist) {
const lfp = getCoverFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getCoverFilename(book)}`;
await this.uploadFileToCloud(lfp, cfp, handleProgress, book.hash);
uploaded = true;
completedFiles.count++;
}
if (bookFileExist) {
const lfp = getLocalBookFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`;
await this.uploadFileToCloud(lfp, cfp, handleProgress, book.hash);
uploaded = true;
completedFiles.count++;
}
if (uploaded) {
book.deletedAt = null;
book.updatedAt = Date.now();
@@ -285,52 +308,60 @@ export abstract class BaseAppService implements AppService {
}
}
async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise<void> {
const fps = onlyCover ? [getCoverFilename(book)] : [getCoverFilename(book), getFilename(book)];
async downloadCloudFile(
lfp: string,
cfp: string,
handleProgress: ProgressHandler,
) {
console.log('Downloading file:', cfp, "to", lfp);
const localFullpath = `${this.localBooksDir}/${lfp}`;
const result = await downloadFile(cfp, localFullpath, handleProgress);
try {
if (this.appPlatform === 'web') {
const fileobj = result as Blob;
await this.fs.writeFile(lfp, 'Books', await fileobj.arrayBuffer());
}
} catch {
console.log('Failed to download file:', cfp);
throw new Error('Failed to download file');
}
}
async downloadBook(book: Book, onlyCover = false, onProgress?: ProgressHandler): Promise<void> {
let bookDownloaded = false;
const completedFiles = { count: 0 };
const toDownloadFps = (
await Promise.all(
[getFilename(book), getCoverFilename(book)].map(async (fp) =>
(await this.fs.exists(fp, 'Books')) ? null : fp,
),
)
).filter(Boolean) as string[];
const handleProgress = createProgressHandler(toDownloadFps.length, completedFiles, onProgress);
for (const fp of fps) {
let downloaded = false;
const existed = !toDownloadFps.includes(fp);
if (existed) {
downloaded = true;
} else {
console.log('Downloading file:', fp);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${fp}`;
const fullpath = `${this.localBooksDir}/${fp}`;
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
try {
const result = await downloadFile(cfp, fullpath, handleProgress);
if (isWebAppPlatform()) {
const fileobj = result as Blob;
await this.fs.writeFile(fp, 'Books', await fileobj.arrayBuffer());
downloaded = true;
} else {
downloaded = await this.fs.exists(fp, 'Books');
}
} catch {
if (fp === getCoverFilename(book)) {
console.log('Failed to download cover image:', fp);
} else {
throw new Error('Failed to download book file');
}
}
completedFiles.count++;
}
if (fp === getFilename(book)) {
bookDownloaded = downloaded;
}
let toDownloadFpCount = 0;
const needDownCover = (!(await this.fs.exists(getCoverFilename(book), 'Books')));
const needDownBook = (!onlyCover) && !(await this.fs.exists(getLocalBookFilename(book), 'Books'));
if (needDownCover) {
toDownloadFpCount++;
}
if (needDownBook) {
toDownloadFpCount++;
}
const handleProgress = createProgressHandler(toDownloadFpCount, completedFiles, onProgress);
if (!(await this.fs.exists(getDir(book), 'Books'))) {
await this.fs.createDir(getDir(book), 'Books');
}
if (needDownCover) {
const lfp = getCoverFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${lfp}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
completedFiles.count++;
}
if (needDownBook) {
const lfp = getLocalBookFilename(book);
const cfp = `${CLOUD_BOOKS_SUBDIR}/${getRemoteBookFilename(book)}`;
await this.downloadCloudFile(lfp, cfp, handleProgress);
const localFullpath = `${this.localBooksDir}/${lfp}`;
bookDownloaded = await this.fs.exists(localFullpath, 'Books');
completedFiles.count++;
}
// some books may not have cover image, so we need to check if the book is downloaded
if (bookDownloaded) {
@@ -340,7 +371,7 @@ export abstract class BaseAppService implements AppService {
async loadBookContent(book: Book, settings: SystemSettings): Promise<BookContent> {
let file: File;
const fp = getFilename(book);
const fp = getLocalBookFilename(book);
if (await this.fs.exists(fp, 'Books')) {
// TODO: fix random access for android
if (this.appPlatform === 'web' || getOSPlatform() === 'android') {
@@ -371,7 +402,7 @@ export abstract class BaseAppService implements AppService {
}
async fetchBookDetails(book: Book, settings: SystemSettings) {
const fp = getFilename(book);
const fp = getLocalBookFilename(book);
if (!(await this.fs.exists(fp, 'Books')) && book.uploadedAt) {
await this.downloadBook(book);
}
+12 -1
View File
@@ -1,6 +1,7 @@
import { EXTS } from '@/libs/document';
import { Book, BookConfig, BookProgress, WritingMode } from '@/types/book';
import { getUserLang, makeSafeFilename } from './misc';
import { getStorageType } from './object';
export const getDir = (book: Book) => {
return `${book.hash}`;
@@ -8,7 +9,17 @@ export const getDir = (book: Book) => {
export const getLibraryFilename = () => {
return 'library.json';
};
export const getFilename = (book: Book) => {
export const getRemoteBookFilename = (book: Book) => {
// S3 storage: https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/userguide/object-keys.html
if (getStorageType() === 'r2') {
return `${book.hash}/${makeSafeFilename(book.title)}.${EXTS[book.format]}`;
} else if (getStorageType() === 's3') {
return `${book.hash}/${book.hash}.${EXTS[book.format]}`;
} else {
return '';
}
};
export const getLocalBookFilename = (book: Book) => {
return `${book.hash}/${makeSafeFilename(book.title)}.${EXTS[book.format]}`;
};
export const getCoverFilename = (book: Book) => {
+53
View File
@@ -0,0 +1,53 @@
import { s3Storage } from './s3';
import { r2Storage } from './r2';
type ObjectStorageType = 'r2' | 's3';
export const getStorageType = (): ObjectStorageType => {
// TODO: do not expose storage type to client
if (process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE']) {
return process.env['NEXT_PUBLIC_OBJECT_STORAGE_TYPE'] as ObjectStorageType;
} else {
return 'r2';
}
};
export const getDownloadSignedUrl = async (
fileKey: string,
expiresIn: number,
) => {
const storageType = getStorageType();
if (storageType === 'r2') {
const bucketName = process.env['R2_BUCKET_NAME'] || '';
return await r2Storage.getDownloadSignedUrl(bucketName, fileKey, expiresIn);
} else {
const bucketName = process.env['S3_BUCKET_NAME'] || '';
return await s3Storage.getDownloadSignedUrl(bucketName, fileKey, expiresIn);
}
};
export const getUploadSignedUrl = async (
fileKey: string,
contentLength: number,
expiresIn: number,
) => {
const storageType = getStorageType();
if (storageType === 'r2') {
const bucketName = process.env['R2_BUCKET_NAME'] || '';
return await r2Storage.getUploadSignedUrl(bucketName, fileKey, contentLength, expiresIn);
} else {
const bucketName = process.env['S3_BUCKET_NAME'] || '';
return await s3Storage.getUploadSignedUrl(bucketName, fileKey, contentLength, expiresIn);
}
};
export const deleteObject = async (fileKey: string) => {
const storageType = getStorageType();
if (storageType === 'r2') {
const bucketName = process.env['R2_BUCKET_NAME'] || '';
return await r2Storage.deleteObject(bucketName, fileKey);
} else {
const bucketName = process.env['S3_BUCKET_NAME'] || '';
return await s3Storage.deleteObject(bucketName, fileKey);
}
};
+54 -52
View File
@@ -1,60 +1,62 @@
import { AwsClient } from 'aws4fetch';
const getR2Client = () => {
return new AwsClient({
service: 's3',
region: process.env['R2_REGION'] || 'auto',
accessKeyId: process.env['R2_ACCESS_KEY_ID']!,
secretAccessKey: process.env['R2_SECRET_ACCESS_KEY']!,
});
};
export const r2Storage = {
getR2Client: () => {
return new AwsClient({
service: 's3',
region: process.env['R2_REGION'] || 'auto',
accessKeyId: process.env['R2_ACCESS_KEY_ID']!,
secretAccessKey: process.env['R2_SECRET_ACCESS_KEY']!,
});
},
const getR2Url = () => {
const R2_ACCOUNT_ID = process.env['R2_ACCOUNT_ID']!;
return `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
};
getR2Url: () => {
const R2_ACCOUNT_ID = process.env['R2_ACCOUNT_ID']!;
return `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
},
export const getDownloadSignedUrl = async (
bucketName: string,
fileKey: string,
expiresIn: number,
) => {
return (
await getR2Client().sign(
new Request(`${getR2Url()}/${bucketName}/${fileKey}?X-Amz-Expires=${expiresIn}`),
{
aws: { signQuery: true },
},
)
).url.toString();
};
export const getUploadSignedUrl = async (
bucketName: string,
fileKey: string,
contentLength: number,
expiresIn: number,
) => {
return (
await getR2Client().sign(
new Request(
`${getR2Url()}/${bucketName}/${fileKey}?X-Amz-Expires=${expiresIn}&X-Amz-SignedHeaders=content-length`,
getDownloadSignedUrl: async (
bucketName: string,
fileKey: string,
expiresIn: number,
) => {
return (
await r2Storage.getR2Client().sign(
new Request(`${r2Storage.getR2Url()}/${bucketName}/${fileKey}?X-Amz-Expires=${expiresIn}`),
{
method: 'PUT',
headers: {
'Content-Length': contentLength.toString(),
},
aws: { signQuery: true },
},
),
{
aws: { signQuery: true },
},
)
).url.toString();
};
)
).url.toString();
},
export const deleteObject = async (bucketName: string, fileKey: string) => {
return await getR2Client().fetch(`${getR2Url()}/${bucketName}/${fileKey}`, {
method: 'DELETE',
});
getUploadSignedUrl: async (
bucketName: string,
fileKey: string,
contentLength: number,
expiresIn: number,
) => {
return (
await r2Storage.getR2Client().sign(
new Request(
`${r2Storage.getR2Url()}/${bucketName}/${fileKey}?X-Amz-Expires=${expiresIn}&X-Amz-SignedHeaders=content-length`,
{
method: 'PUT',
headers: {
'Content-Length': contentLength.toString(),
},
},
),
{
aws: { signQuery: true },
},
)
).url.toString();
},
deleteObject: async (bucketName: string, fileKey: string) => {
return await r2Storage.getR2Client().fetch(`${r2Storage.getR2Url()}/${bucketName}/${fileKey}`, {
method: 'DELETE',
});
},
};
+70 -6
View File
@@ -1,17 +1,81 @@
import { S3Client } from '@aws-sdk/client-s3';
import { GetObjectCommand, DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const S3_ACCOUNT_ID = process.env['R2_ACCOUNT_ID'] || '';
const S3_ACCESS_KEY_ID = process.env['R2_ACCESS_KEY_ID'] || '';
const S3_SECRET_ACCESS_KEY = process.env['R2_SECRET_ACCESS_KEY'] || '';
const S3_REGION = process.env['R2_REGION'] || 'auto';
const S3_ENDPOINT = process.env['S3_ENDPOINT'] || '';
const S3_REGION = process.env['S3_REGION'] || 'auto';
const S3_ACCESS_KEY_ID = process.env['S3_ACCESS_KEY_ID'] || '';
const S3_SECRET_ACCESS_KEY = process.env['S3_SECRET_ACCESS_KEY'] || '';
export const s3Client = new S3Client({
forcePathStyle: true,
region: S3_REGION,
endpoint: `https://${S3_ACCOUNT_ID}.r2.cloudflarestorage.com`,
endpoint: S3_ENDPOINT,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID,
secretAccessKey: S3_SECRET_ACCESS_KEY,
},
});
export const r2Client = s3Client;
export const s3Storage = {
getClient: () => {
return new S3Client({
forcePathStyle: true,
region: S3_REGION,
endpoint: S3_ENDPOINT,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID,
secretAccessKey: S3_SECRET_ACCESS_KEY,
},
});
},
getDownloadSignedUrl: async (
bucketName: string,
fileKey: string,
expiresIn: number,
) => {
const getCommand = new GetObjectCommand({
Bucket: bucketName,
Key: fileKey,
});
const downloadUrl = await getSignedUrl(s3Client, getCommand, {
expiresIn: expiresIn,
});
return downloadUrl;
},
getUploadSignedUrl: async (
bucketName: string,
fileKey: string,
contentLength: number,
expiresIn: number,
) => {
const signableHeaders = new Set<string>();
signableHeaders.add('content-length');
const putCommand = new PutObjectCommand({
Bucket: bucketName,
Key: fileKey,
ContentLength: contentLength,
});
const uploadUrl = await getSignedUrl(s3Client, putCommand, {
expiresIn: expiresIn,
signableHeaders,
});
return uploadUrl;
},
deleteObject: async (bucketName: string, fileKey: string) => {
const deleteCommand = new DeleteObjectCommand({
Bucket: bucketName,
Key: fileKey,
});
return await s3Storage.getClient().send(deleteCommand);
},
};