diff --git a/apps/readest-app/.env.local.example b/apps/readest-app/.env.local.example index ff251c2f..42e39d4f 100644 --- a/apps/readest-app/.env.local.example +++ b/apps/readest-app/.env.local.example @@ -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 diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index b790b37e..755b2dfd 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -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 = ({ const showBookInFinderMenuItem = await MenuItem.new({ text: _(fileRevealLabel), action: async () => { - const folder = `${settings.localBooksDir}/${getFilename(book)}`; + const folder = `${settings.localBooksDir}/${getLocalBookFilename(book)}`; revealItemInDir(folder); }, }); diff --git a/apps/readest-app/src/pages/api/storage/delete.ts b/apps/readest-app/src/pages/api/storage/delete.ts index 170ed4df..b89d66bd 100644 --- a/apps/readest-app/src/pages/api/storage/delete.ts +++ b/apps/readest-app/src/pages/api/storage/delete.ts @@ -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) { diff --git a/apps/readest-app/src/pages/api/storage/download.ts b/apps/readest-app/src/pages/api/storage/download.ts index d4f484f3..3cf6f58d 100644 --- a/apps/readest-app/src/pages/api/storage/download.ts +++ b/apps/readest-app/src/pages/api/storage/download.ts @@ -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, diff --git a/apps/readest-app/src/pages/api/storage/upload.ts b/apps/readest-app/src/pages/api/storage/upload.ts index 886be1f7..8977bc3e 100644 --- a/apps/readest-app/src/pages/api/storage/upload.ts +++ b/apps/readest-app/src/pages/api/storage/upload.ts @@ -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, }); diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 32b1eef0..c55683e9 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -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 { - 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 { + 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 { 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 { - 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 { 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 { 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); } diff --git a/apps/readest-app/src/utils/book.ts b/apps/readest-app/src/utils/book.ts index 71c3e743..4f1319c6 100644 --- a/apps/readest-app/src/utils/book.ts +++ b/apps/readest-app/src/utils/book.ts @@ -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) => { diff --git a/apps/readest-app/src/utils/object.ts b/apps/readest-app/src/utils/object.ts new file mode 100644 index 00000000..87f95aeb --- /dev/null +++ b/apps/readest-app/src/utils/object.ts @@ -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); + } +}; diff --git a/apps/readest-app/src/utils/r2.ts b/apps/readest-app/src/utils/r2.ts index ee0a21f7..6dc123c7 100644 --- a/apps/readest-app/src/utils/r2.ts +++ b/apps/readest-app/src/utils/r2.ts @@ -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', + }); + }, }; diff --git a/apps/readest-app/src/utils/s3.ts b/apps/readest-app/src/utils/s3.ts index 42e0df3d..723a0d88 100644 --- a/apps/readest-app/src/utils/s3.ts +++ b/apps/readest-app/src/utils/s3.ts @@ -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(); + 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); + }, +};