Add listing/deleting Cloudflare blobs

This commit is contained in:
Sam Becker 2024-01-20 23:40:18 -06:00
parent 5176649ad6
commit 3bd89f62b5
6 changed files with 100 additions and 32 deletions

View File

@ -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({
<AdminGrid {...{ title }} >
{urls.map(url => {
const addUploadPath = pathForAdminUploadUrl(url);
const uploadFileName = fileNameForBlobUrl(url);
const uploadFileName = fileNameForStorageUrl(url);
return <Fragment key={url}>
<Link href={addUploadPath}>
<ImageTiny

View File

@ -1,6 +1,6 @@
import {
getExtensionFromBlobUrl,
getIdFromBlobUrl,
getExtensionFromStorageUrl,
getIdFromStorageUrl,
} from '@/services/storage';
import { convertExifToFormData } from '@/photo/form';
import {
@ -19,9 +19,9 @@ export const extractExifDataFromBlobPath = async (
}> => {
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)

View File

@ -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,
}));
};

View File

@ -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,
}));
};

View File

@ -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<string> => {
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<string[]> => HAS_AWS_S3_STORAGE
? awsS3List(`${PREFIX_UPLOAD}-`)
: vercelBlobList(`${PREFIX_UPLOAD}-`);
export const getBlobUploadUrls = async (): Promise<string[]> => {
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<string[]> => HAS_AWS_S3_STORAGE
? awsS3List(`${PREFIX_PHOTO}-`)

View File

@ -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,