forked from akai/readest
refactor: support both r2 and s3 storage (#718)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user