From b4c0f24dde52a8f7b25660cc27f60c32ece44655 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sun, 21 Jan 2024 11:14:12 -0600 Subject: [PATCH] Implement upload -> photo copy in R2 --- src/admin/{BlobUrls.tsx => StorageUrls.tsx} | 2 +- src/app/admin/layout.tsx | 4 +- src/app/admin/photos/page.tsx | 8 +-- src/app/admin/uploads/page.tsx | 8 +-- src/cache/index.ts | 20 +++--- src/photo/actions.ts | 6 +- src/services/storage/aws-s3.ts | 4 +- src/services/storage/cloudflare-r2.ts | 20 ++++++ src/services/storage/index.ts | 76 ++++++++++++++------- 9 files changed, 100 insertions(+), 48 deletions(-) rename src/admin/{BlobUrls.tsx => StorageUrls.tsx} (98%) diff --git a/src/admin/BlobUrls.tsx b/src/admin/StorageUrls.tsx similarity index 98% rename from src/admin/BlobUrls.tsx rename to src/admin/StorageUrls.tsx index da354a48..c4c1ef55 100644 --- a/src/admin/BlobUrls.tsx +++ b/src/admin/StorageUrls.tsx @@ -10,7 +10,7 @@ import { clsx } from 'clsx/lite'; import { pathForAdminUploadUrl } from '@/site/paths'; import AddButton from './AddButton'; -export default function BlobUrls({ +export default function StorageUrls({ title, urls, }: { diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 072b0d73..95efd54a 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,6 +1,6 @@ import AdminNav from '@/admin/AdminNav'; import { - getBlobUploadUrlsNoStore, + getStorageUploadUrlsNoStore, getPhotosCountIncludingHiddenCached, getUniqueTagsCached, } from '@/cache'; @@ -21,7 +21,7 @@ export default async function AdminLayout({ countTags, ] = await Promise.all([ getPhotosCountIncludingHiddenCached(), - getBlobUploadUrlsNoStore() + getStorageUploadUrlsNoStore() .then(urls => urls.length) .catch(e => { console.error(`Error getting blob upload urls: ${e}`); diff --git a/src/app/admin/photos/page.tsx b/src/app/admin/photos/page.tsx index 2474f3d2..27f53955 100644 --- a/src/app/admin/photos/page.tsx +++ b/src/app/admin/photos/page.tsx @@ -14,7 +14,7 @@ import { import { titleForPhoto } from '@/photo'; import MorePhotos from '@/photo/MorePhotos'; import { - getBlobPhotoUrlsNoStore, + getStoragePhotoUrlsNoStore, getPhotosCached, getPhotosCountIncludingHiddenCached, } from '@/cache'; @@ -26,7 +26,7 @@ import { import AdminGrid from '@/admin/AdminGrid'; import DeleteButton from '@/admin/DeleteButton'; import EditButton from '@/admin/EditButton'; -import BlobUrls from '@/admin/BlobUrls'; +import StorageUrls from '@/admin/StorageUrls'; import { PRO_MODE_ENABLED } from '@/site/config'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import IconGrSync from '@/site/IconGrSync'; @@ -45,7 +45,7 @@ export default async function AdminPhotosPage({ ] = await Promise.all([ getPhotosCached({ includeHidden: true, sortBy: 'createdAt', limit }), getPhotosCountIncludingHiddenCached(), - DEBUG_PHOTO_BLOBS ? getBlobPhotoUrlsNoStore() : [], + DEBUG_PHOTO_BLOBS ? getStoragePhotoUrlsNoStore() : [], ]); const showMorePhotos = count > photos.length; @@ -60,7 +60,7 @@ export default async function AdminPhotosPage({ 'border-b pb-6', 'border-gray-200 dark:border-gray-700', )}> - diff --git a/src/app/admin/uploads/page.tsx b/src/app/admin/uploads/page.tsx index 9e4e281a..8108e6eb 100644 --- a/src/app/admin/uploads/page.tsx +++ b/src/app/admin/uploads/page.tsx @@ -1,12 +1,12 @@ -import BlobUrls from '@/admin/BlobUrls'; -import { getBlobUploadUrlsNoStore } from '@/cache'; +import StorageUrls from '@/admin/StorageUrls'; +import { getStorageUploadUrlsNoStore } from '@/cache'; import SiteGrid from '@/components/SiteGrid'; export default async function AdminUploadsPage() { - const blobUrls = await getBlobUploadUrlsNoStore(); + const storageUrls = await getStorageUploadUrlsNoStore(); return ( } + contentMain={} /> ); } diff --git a/src/cache/index.ts b/src/cache/index.ts index d67f3ade..179bbbbb 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -24,7 +24,7 @@ import { getPhotosNearId, } from '@/services/vercel-postgres'; import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo'; -import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/storage'; +import { getStoragePhotoUrls, getStorageUploadUrls } from '@/services/storage'; import type { Session } from 'next-auth'; import { createCameraKey } from '@/camera'; import { PATHS_ADMIN } from '@/site/paths'; @@ -218,15 +218,17 @@ export const getPhotoNoStore = (...args: Parameters) => { return getPhoto(...args); }; -export const getBlobUploadUrlsNoStore: typeof getBlobUploadUrls = (...args) => { - unstable_noStore(); - return getBlobUploadUrls(...args); -}; +export const getStorageUploadUrlsNoStore: typeof getStorageUploadUrls = + (...args) => { + unstable_noStore(); + return getStorageUploadUrls(...args); + }; -export const getBlobPhotoUrlsNoStore: typeof getBlobPhotoUrls = (...args) => { - unstable_noStore(); - return getBlobPhotoUrls(...args); -}; +export const getStoragePhotoUrlsNoStore: typeof getStoragePhotoUrls = + (...args) => { + unstable_noStore(); + return getStoragePhotoUrls(...args); + }; export const getImageCacheHeadersForAuth = (session: Session | null) => { return { diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 1aa5ef2a..bbbe1865 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -16,7 +16,7 @@ import { import { redirect } from 'next/navigation'; import { convertUploadToPhoto, - deleteBlobUrl, + deleteStorageUrl, } from '@/services/storage'; import { revalidateAdminPaths, @@ -66,7 +66,7 @@ export async function toggleFavoritePhoto(photoId: string) { export async function deletePhotoAction(formData: FormData) { await Promise.all([ - deleteBlobUrl(formData.get('url') as string), + deleteStorageUrl(formData.get('url') as string), sqlDeletePhoto(formData.get('id') as string), ]); @@ -94,7 +94,7 @@ export async function renamePhotoTagGloballyAction(formData: FormData) { } export async function deleteBlobPhotoAction(formData: FormData) { - await deleteBlobUrl(formData.get('url') as string); + await deleteStorageUrl(formData.get('url') as string); revalidateAdminPaths(); diff --git a/src/services/storage/aws-s3.ts b/src/services/storage/aws-s3.ts index b7bb8f55..2ec518d5 100644 --- a/src/services/storage/aws-s3.ts +++ b/src/services/storage/aws-s3.ts @@ -5,7 +5,7 @@ import { ListObjectsCommand, PutObjectCommand, } from '@aws-sdk/client-s3'; -import { generateBlobId } from '.'; +import { generateStorageId } from '.'; const AWS_S3_BUCKET = process.env.NEXT_PUBLIC_AWS_S3_BUCKET ?? ''; const AWS_S3_REGION = process.env.NEXT_PUBLIC_AWS_S3_REGION ?? ''; @@ -38,7 +38,7 @@ export const awsS3Copy = async ( const name = fileNameSource.split('.')[0]; const extension = fileNameSource.split('.')[1]; const Key = addRandomSuffix - ? `${name}-${generateBlobId()}.${extension}` + ? `${name}-${generateStorageId()}.${extension}` : fileNameDestination; return awsS3Client().send(new CopyObjectCommand({ Bucket: AWS_S3_BUCKET, diff --git a/src/services/storage/cloudflare-r2.ts b/src/services/storage/cloudflare-r2.ts index 5be6d9b0..7866005a 100644 --- a/src/services/storage/cloudflare-r2.ts +++ b/src/services/storage/cloudflare-r2.ts @@ -3,7 +3,9 @@ import { ListObjectsCommand, PutObjectCommand, DeleteObjectCommand, + CopyObjectCommand, } from '@aws-sdk/client-s3'; +import { generateStorageId } from '.'; const CLOUDFLARE_R2_BUCKET = process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? ''; @@ -44,6 +46,24 @@ export const isUrlFromCloudflareR2 = (url: string) => export const cloudflareR2PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key }); +export const cloudflareR2Copy = async ( + fileNameSource: string, + fileNameDestination: string, + addRandomSuffix?: boolean, +) => { + const name = fileNameSource.split('.')[0]; + const extension = fileNameSource.split('.')[1]; + const Key = addRandomSuffix + ? `${name}-${generateStorageId()}.${extension}` + : fileNameDestination; + return cloudflareR2Client().send(new CopyObjectCommand({ + Bucket: CLOUDFLARE_R2_BUCKET, + CopySource: `${CLOUDFLARE_R2_BUCKET}/${fileNameSource}`, + Key, + })) + .then(() => urlForKey(fileNameDestination)); +}; + export const cloudflareR2List = async (Prefix: string) => cloudflareR2Client().send(new ListObjectsCommand({ Bucket: CLOUDFLARE_R2_BUCKET, diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 79d9b73f..64605928 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -21,13 +21,14 @@ import { import { generateNanoid } from '@/utility/nanoid'; import { CLOUDFLARE_R2_BASE_URL_PUBLIC, + cloudflareR2Copy, cloudflareR2Delete, cloudflareR2List, isUrlFromCloudflareR2, } from './cloudflare-r2'; import { PATH_API_PRESIGNED_URL } from '@/site/paths'; -export const generateBlobId = () => generateNanoid(16); +export const generateStorageId = () => generateNanoid(16); export type StorageType = 'vercel-blob' | @@ -42,7 +43,7 @@ export const labelForStorage = (type: StorageType): string => { } }; -export const blobBaseUrlForStorage = (type: StorageType) => { +export const baseUrlForStorage = (type: StorageType) => { switch (type) { case 'vercel-blob': return VERCEL_BLOB_BASE_URL; case 'cloudflare-r2': return CLOUDFLARE_R2_BASE_URL_PUBLIC; @@ -93,7 +94,7 @@ export const getIdFromStorageUrl = (url: string) => export const isUploadPathnameValid = (pathname?: string) => pathname?.match(REGEX_UPLOAD_PATH); -const getFileNameFromBlobUrl = (url: string) => +const getFileNameFromStorageUrl = (url: string) => (new URL(url).pathname.match(/\/(.+)$/)?.[1]) ?? ''; export const uploadFromClientViaPresignedUrl = async ( @@ -103,14 +104,14 @@ export const uploadFromClientViaPresignedUrl = async ( addRandomSuffix?: boolean, ) => { const key = addRandomSuffix - ? `${fileName}-${generateBlobId()}.${extension}` + ? `${fileName}-${generateStorageId()}.${extension}` : `${fileName}.${extension}`; const url = await fetch(`${PATH_API_PRESIGNED_URL}/${key}`) .then((response) => response.text()); return fetch(url, { method: 'PUT', body: file }) - .then(() => `${blobBaseUrlForStorage(STORAGE_PREFERENCE)}/${key}`); + .then(() => `${baseUrlForStorage(STORAGE_PREFERENCE)}/${key}`); }; export const uploadPhotoFromClient = async ( @@ -131,45 +132,74 @@ export const convertUploadToPhoto = async ( const fileExtension = getExtensionFromStorageUrl(uploadUrl); const photoUrl = `${fileName}.${fileExtension ?? 'jpg'}`; - const useAwsS3 = HAS_AWS_S3_STORAGE && isUrlFromAwsS3(uploadUrl); + const storageType = storageTypeFromUrl(uploadUrl); - const url = await (useAwsS3 - ? awsS3Copy(uploadUrl, photoUrl, photoId === undefined) - : vercelBlobCopy(uploadUrl, photoUrl, photoId === undefined)); + let url: string | undefined; + // Copy file + switch (storageType) { + case 'vercel-blob': + url = await vercelBlobCopy(uploadUrl, photoUrl, photoId === undefined); + break; + case 'cloudflare-r2': + url = await cloudflareR2Copy( + getFileNameFromStorageUrl(uploadUrl), + photoUrl, + photoId === undefined, + ); + break; + case 'aws-s3': + url = await awsS3Copy(uploadUrl, photoUrl, photoId === undefined); + break; + } + + // If successful, delete original file if (url) { - await (useAwsS3 - ? awsS3Delete(getFileNameFromBlobUrl(uploadUrl)) - : vercelBlobDelete(uploadUrl)); + switch (storageType) { + case 'vercel-blob': + await vercelBlobDelete(uploadUrl); + break; + case 'cloudflare-r2': + await cloudflareR2Delete(getFileNameFromStorageUrl(uploadUrl)); + break; + case 'aws-s3': + await awsS3Delete(getFileNameFromStorageUrl(uploadUrl)); + break; + } } return url; }; -export const deleteBlobUrl = (url: string) => { +export const deleteStorageUrl = (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)); + case 'vercel-blob': + return vercelBlobDelete(url); + case 'cloudflare-r2': + return cloudflareR2Delete(getFileNameFromStorageUrl(url)); + case 'aws-s3': + return awsS3Delete(getFileNameFromStorageUrl(url)); } }; -export const getBlobUploadUrls = async (): Promise => { +const getStorageUrlsForPrefix = async (prefix = ''): Promise => { const urls: string[] = []; if (HAS_VERCEL_BLOB_STORAGE) { - urls.push(...await vercelBlobList(`${PREFIX_UPLOAD}-`)); + urls.push(...await vercelBlobList(prefix)); } if (HAS_AWS_S3_STORAGE) { - urls.push(...await awsS3List(`${PREFIX_UPLOAD}-`)); + urls.push(...await awsS3List(prefix)); } if (HAS_CLOUDFLARE_R2_STORAGE) { - urls.push(...await cloudflareR2List(`${PREFIX_UPLOAD}-`)); + urls.push(...await cloudflareR2List(prefix)); } return urls; }; -export const getBlobPhotoUrls = (): Promise => HAS_AWS_S3_STORAGE - ? awsS3List(`${PREFIX_PHOTO}-`) - : vercelBlobList(`${PREFIX_PHOTO}-`); +export const getStorageUploadUrls = () => + getStorageUrlsForPrefix(`${PREFIX_UPLOAD}-`); + +export const getStoragePhotoUrls = () => + getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);