Vercel/src/services/storage/index.ts
2024-01-21 11:40:29 -06:00

206 lines
5.2 KiB
TypeScript

import {
VERCEL_BLOB_BASE_URL,
vercelBlobCopy,
vercelBlobDelete,
vercelBlobList,
vercelBlobUploadFromClient,
} from './vercel-blob';
import {
AWS_S3_BASE_URL,
awsS3Copy,
awsS3Delete,
awsS3List,
isUrlFromAwsS3,
} from './aws-s3';
import {
CURRENT_STORAGE,
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,
cloudflareR2Copy,
cloudflareR2Delete,
cloudflareR2List,
isUrlFromCloudflareR2,
} from './cloudflare-r2';
import { PATH_API_PRESIGNED_URL } from '@/site/paths';
export const generateStorageId = () => generateNanoid(16);
export type StorageType =
'vercel-blob' |
'aws-s3' |
'cloudflare-r2';
export const labelForStorage = (type: StorageType): string => {
switch (type) {
case 'vercel-blob': return 'Vercel Blob';
case 'cloudflare-r2': return 'Cloudflare R2';
case 'aws-s3': return 'AWS S3';
}
};
export const baseUrlForStorage = (type: StorageType) => {
switch (type) {
case 'vercel-blob': return VERCEL_BLOB_BASE_URL;
case 'cloudflare-r2': return CLOUDFLARE_R2_BASE_URL_PUBLIC;
case 'aws-s3': return AWS_S3_BASE_URL;
}
};
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';
const REGEX_UPLOAD_PATH = new RegExp(
`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`,
'i',
);
const REGEX_UPLOAD_ID = new RegExp(
`.${PREFIX_UPLOAD}-([a-z0-9]+)\.[a-z]{1,4}$`,
'i',
);
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 getExtensionFromStorageUrl = (url: string) =>
url.match(/.([a-z]{1,4})$/i)?.[1];
export const getIdFromStorageUrl = (url: string) =>
url.match(REGEX_UPLOAD_ID)?.[1];
export const isUploadPathnameValid = (pathname?: string) =>
pathname?.match(REGEX_UPLOAD_PATH);
const getFileNameFromStorageUrl = (url: string) =>
(new URL(url).pathname.match(/\/(.+)$/)?.[1]) ?? '';
export const uploadFromClientViaPresignedUrl = async (
file: File | Blob,
fileName: string,
extension: string,
addRandomSuffix?: boolean,
) => {
const key = addRandomSuffix
? `${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(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${key}`);
};
export const uploadPhotoFromClient = async (
file: File | Blob,
extension = 'jpg',
) => (
CURRENT_STORAGE === 'cloudflare-r2' ||
CURRENT_STORAGE === 'aws-s3'
)
? uploadFromClientViaPresignedUrl(file, PREFIX_UPLOAD, extension, true)
: vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`);
export const convertUploadToPhoto = async (
uploadUrl: string,
photoId?: string,
): Promise<string> => {
const fileName = photoId ? `${PREFIX_PHOTO}-${photoId}` : `${PREFIX_PHOTO}`;
const fileExtension = getExtensionFromStorageUrl(uploadUrl);
const photoUrl = `${fileName}.${fileExtension ?? 'jpg'}`;
const storageType = storageTypeFromUrl(uploadUrl);
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) {
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 deleteStorageUrl = (url: string) => {
switch (storageTypeFromUrl(url)) {
case 'vercel-blob':
return vercelBlobDelete(url);
case 'cloudflare-r2':
return cloudflareR2Delete(getFileNameFromStorageUrl(url));
case 'aws-s3':
return awsS3Delete(getFileNameFromStorageUrl(url));
}
};
const getStorageUrlsForPrefix = async (prefix = ''): Promise<string[]> => {
const urls: string[] = [];
if (HAS_VERCEL_BLOB_STORAGE) {
urls.push(...await vercelBlobList(prefix));
}
if (HAS_AWS_S3_STORAGE) {
urls.push(...await awsS3List(prefix));
}
if (HAS_CLOUDFLARE_R2_STORAGE) {
urls.push(...await cloudflareR2List(prefix));
}
return urls;
};
export const getStorageUploadUrls = () =>
getStorageUrlsForPrefix(`${PREFIX_UPLOAD}-`);
export const getStoragePhotoUrls = () =>
getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);