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}-`);