diff --git a/src/admin/BlobUrls.tsx b/src/admin/BlobUrls.tsx index bed92015..da354a48 100644 --- a/src/admin/BlobUrls.tsx +++ b/src/admin/BlobUrls.tsx @@ -2,7 +2,7 @@ import { Fragment } from 'react'; import AdminGrid from './AdminGrid'; import Link from 'next/link'; import ImageTiny from '@/components/ImageTiny'; -import { fileNameForBlobUrl } from '@/services/storage'; +import { fileNameForStorageUrl } from '@/services/storage'; import FormWithConfirm from '@/components/FormWithConfirm'; import { deleteBlobPhotoAction } from '@/photo/actions'; import DeleteButton from './DeleteButton'; @@ -21,7 +21,7 @@ export default function BlobUrls({ {urls.map(url => { const addUploadPath = pathForAdminUploadUrl(url); - const uploadFileName = fileNameForBlobUrl(url); + const uploadFileName = fileNameForStorageUrl(url); return => { const url = decodeURIComponent(blobPath); - const blobId = getIdFromBlobUrl(url); + const blobId = getIdFromStorageUrl(url); - const extension = getExtensionFromBlobUrl(url); + const extension = getExtensionFromStorageUrl(url); const fileBytes = blobPath ? await fetch(url) diff --git a/src/services/storage/aws-s3.ts b/src/services/storage/aws-s3.ts index 4ce72ee5..b7bb8f55 100644 --- a/src/services/storage/aws-s3.ts +++ b/src/services/storage/aws-s3.ts @@ -22,11 +22,11 @@ export const awsS3Client = () => new S3Client({ }, }); +const urlForKey = (key?: string) => `${AWS_S3_BASE_URL}/${key}`; + export const isUrlFromAwsS3 = (url: string) => url.startsWith(AWS_S3_BASE_URL); -const urlForKey = (key?: string) => `${AWS_S3_BASE_URL}/${key}`; - export const awsS3PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key, ACL: 'public-read' }); @@ -49,16 +49,16 @@ export const awsS3Copy = async ( .then(() => urlForKey(fileNameDestination)); }; -export const awsS3Delete = async (Key: string) => { - awsS3Client().send(new DeleteObjectCommand({ - Bucket: AWS_S3_BUCKET, - Key, - })); -}; - export const awsS3List = async (Prefix: string) => awsS3Client().send(new ListObjectsCommand({ Bucket: AWS_S3_BUCKET, Prefix, })) .then((data) => data.Contents?.map(({ Key }) => urlForKey(Key)) ?? []); + +export const awsS3Delete = async (Key: string) => { + awsS3Client().send(new DeleteObjectCommand({ + Bucket: AWS_S3_BUCKET, + Key, + })); +}; diff --git a/src/services/storage/cloudflare-r2.ts b/src/services/storage/cloudflare-r2.ts index 1595e4d1..5be6d9b0 100644 --- a/src/services/storage/cloudflare-r2.ts +++ b/src/services/storage/cloudflare-r2.ts @@ -1,4 +1,9 @@ -import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + S3Client, + ListObjectsCommand, + PutObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3'; const CLOUDFLARE_R2_BUCKET = process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? ''; @@ -28,5 +33,27 @@ export const cloudflareR2Client = () => new S3Client({ }, }); +const urlForKey = (key?: string, isPublic = true) => isPublic + ? `${CLOUDFLARE_R2_BASE_URL_PUBLIC}/${key}` + : `${CLOUDFLARE_R2_BASE_URL_PRIVATE}/${key}`; + +export const isUrlFromCloudflareR2 = (url: string) => + url.startsWith(CLOUDFLARE_R2_BASE_URL_PRIVATE) || + url.startsWith(CLOUDFLARE_R2_BASE_URL_PUBLIC); + export const cloudflareR2PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key }); + +export const cloudflareR2List = async (Prefix: string) => + cloudflareR2Client().send(new ListObjectsCommand({ + Bucket: CLOUDFLARE_R2_BUCKET, + Prefix, + })) + .then((data) => data.Contents?.map(({ Key }) => urlForKey(Key)) ?? []); + +export const cloudflareR2Delete = async (Key: string) => { + cloudflareR2Client().send(new DeleteObjectCommand({ + Bucket: CLOUDFLARE_R2_BUCKET, + Key, + })); +}; diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 8e7b0810..79d9b73f 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -15,9 +15,16 @@ import { import { STORAGE_PREFERENCE, HAS_AWS_S3_STORAGE, + HAS_VERCEL_BLOB_STORAGE, + HAS_CLOUDFLARE_R2_STORAGE, } from '@/site/config'; import { generateNanoid } from '@/utility/nanoid'; -import { CLOUDFLARE_R2_BASE_URL_PUBLIC } from './cloudflare-r2'; +import { + CLOUDFLARE_R2_BASE_URL_PUBLIC, + cloudflareR2Delete, + cloudflareR2List, + isUrlFromCloudflareR2, +} from './cloudflare-r2'; import { PATH_API_PRESIGNED_URL } from '@/site/paths'; export const generateBlobId = () => generateNanoid(16); @@ -35,7 +42,7 @@ export const labelForStorage = (type: StorageType): string => { } }; -const blobBaseUrlForStorage = (type: StorageType) => { +export const blobBaseUrlForStorage = (type: StorageType) => { switch (type) { case 'vercel-blob': return VERCEL_BLOB_BASE_URL; case 'cloudflare-r2': return CLOUDFLARE_R2_BASE_URL_PUBLIC; @@ -43,7 +50,15 @@ const blobBaseUrlForStorage = (type: StorageType) => { } }; -export const BLOB_BASE_URL = blobBaseUrlForStorage(STORAGE_PREFERENCE); +export const storageTypeFromUrl = (url: string): StorageType => { + if (isUrlFromCloudflareR2(url)) { + return 'cloudflare-r2'; + } else if (isUrlFromAwsS3(url)) { + return 'aws-s3'; + } else { + return 'vercel-blob'; + } +}; const PREFIX_UPLOAD = 'upload'; const PREFIX_PHOTO = 'photo'; @@ -58,13 +73,21 @@ const REGEX_UPLOAD_ID = new RegExp( 'i', ); -export const fileNameForBlobUrl = (url: string) => - url.replace(`${BLOB_BASE_URL}/`, ''); +export const fileNameForStorageUrl = (url: string) => { + switch (storageTypeFromUrl(url)) { + case 'vercel-blob': + return url.replace(`${VERCEL_BLOB_BASE_URL}/`, ''); + case 'cloudflare-r2': + return url.replace(`${CLOUDFLARE_R2_BASE_URL_PUBLIC}/`, ''); + case 'aws-s3': + return url.replace(`${AWS_S3_BASE_URL}/`, ''); + } +}; -export const getExtensionFromBlobUrl = (url: string) => +export const getExtensionFromStorageUrl = (url: string) => url.match(/.([a-z]{1,4})$/i)?.[1]; -export const getIdFromBlobUrl = (url: string) => +export const getIdFromStorageUrl = (url: string) => url.match(REGEX_UPLOAD_ID)?.[1]; export const isUploadPathnameValid = (pathname?: string) => @@ -87,7 +110,7 @@ export const uploadFromClientViaPresignedUrl = async ( .then((response) => response.text()); return fetch(url, { method: 'PUT', body: file }) - .then(() => `${BLOB_BASE_URL}/${key}`); + .then(() => `${blobBaseUrlForStorage(STORAGE_PREFERENCE)}/${key}`); }; export const uploadPhotoFromClient = async ( @@ -105,7 +128,7 @@ export const convertUploadToPhoto = async ( photoId?: string, ): Promise => { const fileName = photoId ? `${PREFIX_PHOTO}-${photoId}` : `${PREFIX_PHOTO}`; - const fileExtension = getExtensionFromBlobUrl(uploadUrl); + const fileExtension = getExtensionFromStorageUrl(uploadUrl); const photoUrl = `${fileName}.${fileExtension ?? 'jpg'}`; const useAwsS3 = HAS_AWS_S3_STORAGE && isUrlFromAwsS3(uploadUrl); @@ -123,14 +146,29 @@ export const convertUploadToPhoto = async ( return url; }; -export const deleteBlobUrl = (url: string) => - HAS_AWS_S3_STORAGE && isUrlFromAwsS3(url) - ? awsS3Delete(getFileNameFromBlobUrl(url)) - : vercelBlobDelete(url); +export const deleteBlobUrl = (url: string) => { + switch (storageTypeFromUrl(url)) { + case 'vercel-blob': return vercelBlobDelete(url); + case 'cloudflare-r2': return cloudflareR2Delete(getFileNameFromBlobUrl(url)); + case 'aws-s3': return awsS3Delete(getFileNameFromBlobUrl(url)); + } +}; -export const getBlobUploadUrls = (): Promise => HAS_AWS_S3_STORAGE - ? awsS3List(`${PREFIX_UPLOAD}-`) - : vercelBlobList(`${PREFIX_UPLOAD}-`); +export const getBlobUploadUrls = async (): Promise => { + const urls: string[] = []; + + if (HAS_VERCEL_BLOB_STORAGE) { + urls.push(...await vercelBlobList(`${PREFIX_UPLOAD}-`)); + } + if (HAS_AWS_S3_STORAGE) { + urls.push(...await awsS3List(`${PREFIX_UPLOAD}-`)); + } + if (HAS_CLOUDFLARE_R2_STORAGE) { + urls.push(...await cloudflareR2List(`${PREFIX_UPLOAD}-`)); + } + + return urls; +}; export const getBlobPhotoUrls = (): Promise => HAS_AWS_S3_STORAGE ? awsS3List(`${PREFIX_PHOTO}-`) diff --git a/src/services/storage/vercel-blob.ts b/src/services/storage/vercel-blob.ts index 5a664f6b..a8b5094f 100644 --- a/src/services/storage/vercel-blob.ts +++ b/src/services/storage/vercel-blob.ts @@ -9,6 +9,9 @@ const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match( export const VERCEL_BLOB_BASE_URL = `https://${VERCEL_BLOB_STORE_ID}.public.blob.vercel-storage.com`; +export const isUrlFromVercelBlob = (url: string) => + url.startsWith(VERCEL_BLOB_BASE_URL); + export const vercelBlobUploadFromClient = async ( file: File | Blob, fileName: string,