Vercel/src/platforms/storage/index.ts
Sam Becker 351f8869a6
Replace backing storage (#374)
* Centralize random suffix generation

* Introduce ••• menu in admin photos table

* Finalize re-upload behavior

* Finalize re-upload locales

* Honor resize config when re-uploading files
2026-02-10 21:17:57 -06:00

253 lines
5.8 KiB
TypeScript

import {
VERCEL_BLOB_BASE_URL,
vercelBlobCopy,
vercelBlobDelete,
vercelBlobList,
vercelBlobPut,
vercelBlobUploadFromClient,
} from './vercel-blob';
import {
AWS_S3_BASE_URL,
awsS3Copy,
awsS3Delete,
awsS3List,
awsS3Put,
isUrlFromAwsS3,
} from './aws-s3';
import {
CURRENT_STORAGE,
HAS_AWS_S3_STORAGE,
HAS_VERCEL_BLOB_STORAGE,
HAS_CLOUDFLARE_R2_STORAGE,
HAS_MINIO_STORAGE,
} from '@/app/config';
import { generateNanoid } from '@/utility/nanoid';
import {
CLOUDFLARE_R2_BASE_URL_PUBLIC,
cloudflareR2Copy,
cloudflareR2Delete,
cloudflareR2List,
cloudflareR2Put,
isUrlFromCloudflareR2,
} from './cloudflare-r2';
import {
MINIO_BASE_URL,
minioCopy,
minioDelete,
minioList,
minioPut,
isUrlFromMinio,
} from './minio';
import { PATH_API_PRESIGNED_URL } from '@/app/path';
export type StorageListItem = {
url: string
fileName: string
uploadedAt?: Date
size?: string
};
export type StorageListResponse = StorageListItem[];
export type StorageType =
'vercel-blob' |
'aws-s3' |
'cloudflare-r2' |
'minio';
export const generateStorageId = () => generateNanoid(16);
export const generateFileNameWithId = (prefix: string) =>
`${prefix}-${generateStorageId()}`;
export const getFileNamePartsFromStorageUrl = (url: string) => {
const [
_,
urlBase = '',
fileName = '',
fileNameBase = '',
fileId = '',
fileModifier = '',
fileExtension = '',
] = url.match(
/^(.+)\/((-*[a-z0-9]+-*([a-z0-9]+)-*([a-z0-9]+)*)\.([a-z]{1,4}))$/i,
) ?? [];
return {
urlBase,
fileName,
fileNameBase,
fileId,
fileModifier,
fileExtension,
};
};
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';
case 'minio': return 'MinIO';
}
};
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;
case 'minio': return MINIO_BASE_URL;
}
};
export const storageTypeFromUrl = (url: string): StorageType => {
if (isUrlFromCloudflareR2(url)) {
return 'cloudflare-r2';
} else if (isUrlFromAwsS3(url)) {
return 'aws-s3';
} else if (isUrlFromMinio(url)) {
return 'minio';
} else {
return 'vercel-blob';
}
};
export const uploadFromClientViaPresignedUrl = async (
file: File | Blob,
fileName: string,
) => {
const url = await fetch(`${PATH_API_PRESIGNED_URL}/${fileName}`)
.then((response) => response.text());
return fetch(url, { method: 'PUT', body: file })
.then(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${fileName}`);
};
export const uploadFileFromClient = async (
file: File | Blob,
_fileName: string,
extension: string,
addRandomSuffix = true,
) => {
const fileName = addRandomSuffix
? `${_fileName}-${generateStorageId()}.${extension}`
: `${_fileName}.${extension}`;
return (
CURRENT_STORAGE === 'cloudflare-r2' ||
CURRENT_STORAGE === 'aws-s3' ||
CURRENT_STORAGE === 'minio'
)
? uploadFromClientViaPresignedUrl(file, fileName)
: vercelBlobUploadFromClient(file, fileName);
};
export const putFile = (
file: Buffer,
fileName: string,
) => {
switch (CURRENT_STORAGE) {
case 'vercel-blob':
return vercelBlobPut(file, fileName);
case 'cloudflare-r2':
return cloudflareR2Put(file, fileName);
case 'aws-s3':
return awsS3Put(file, fileName);
case 'minio':
return minioPut(file, fileName);
}
};
export const copyFile = (
originUrl: string,
destinationFileName: string,
): Promise<string> => {
const { fileName } = getFileNamePartsFromStorageUrl(originUrl);
switch (storageTypeFromUrl(originUrl)) {
case 'vercel-blob':
return vercelBlobCopy(
originUrl,
destinationFileName,
false,
);
case 'cloudflare-r2':
return cloudflareR2Copy(
fileName,
destinationFileName,
false,
);
case 'aws-s3':
return awsS3Copy(
originUrl,
destinationFileName,
false,
);
case 'minio':
return minioCopy(
fileName,
destinationFileName,
false,
);
}
};
export const deleteFile = (url: string) => {
const { fileName } = getFileNamePartsFromStorageUrl(url);
switch (storageTypeFromUrl(url)) {
case 'vercel-blob':
return vercelBlobDelete(url);
case 'cloudflare-r2':
return cloudflareR2Delete(fileName);
case 'aws-s3':
return awsS3Delete(fileName);
case 'minio':
return minioDelete(fileName);
}
};
export const deleteFilesWithPrefix = async (prefix: string) => {
const urls = await getStorageUrlsForPrefix(prefix);
return Promise.all(urls.map(({ url }) => deleteFile(url)));
};
export const moveFile = async (
originUrl: string,
destinationFileName: string,
) => {
const url = await copyFile(originUrl, destinationFileName);
// If successful, delete original file
if (url) { await deleteFile(originUrl); }
return url;
};
export const getStorageUrlsForPrefix = async (prefix = '') => {
const urls: StorageListResponse = [];
if (HAS_VERCEL_BLOB_STORAGE) {
urls.push(...await vercelBlobList(prefix)
.catch(() => []));
}
if (HAS_AWS_S3_STORAGE) {
urls.push(...await awsS3List(prefix)
.catch(() => []));
}
if (HAS_CLOUDFLARE_R2_STORAGE) {
urls.push(...await cloudflareR2List(prefix)
.catch(() => []));
}
if (HAS_MINIO_STORAGE) {
urls.push(...await minioList(prefix)
.catch(() => []));
}
return urls
.sort((a, b) => {
if (!a.uploadedAt) { return 1; }
if (!b.uploadedAt) { return -1; }
return b.uploadedAt.getTime() - a.uploadedAt.getTime();
});
};
export const testStorageConnection = () =>
getStorageUrlsForPrefix();