diff --git a/app/admin/photos/[photoId]/edit/page.tsx b/app/admin/photos/[photoId]/edit/page.tsx index 0bede7e3..f0a588a9 100644 --- a/app/admin/photos/[photoId]/edit/page.tsx +++ b/app/admin/photos/[photoId]/edit/page.tsx @@ -13,7 +13,10 @@ import { IS_PREVIEW, } from '@/app/config'; import { blurImageFromUrl, resizeImageFromUrl } from '@/photo/server'; -import { getNextImageUrlForManipulation } from '@/platforms/next-image'; +import { + getOptimizedPhotoUrlForManipulation, + getStorageUrlsForPhoto, +} from '@/photo/storage'; export default async function PhotoEditPage({ params, @@ -36,24 +39,27 @@ export default async function PhotoEditPage({ if (!photo) { redirect(PATH_ADMIN); } + const photoStorageUrls = await getStorageUrlsForPhoto(photo); + const hasAiTextGeneration = AI_CONTENT_GENERATION_ENABLED; // Only generate image thumbnails when AI generation is enabled const imageThumbnailBase64 = AI_CONTENT_GENERATION_ENABLED ? await resizeImageFromUrl( - getNextImageUrlForManipulation(photo.url, IS_PREVIEW), + getOptimizedPhotoUrlForManipulation(photo.url, IS_PREVIEW), ) : ''; const blurData = BLUR_ENABLED ? await blurImageFromUrl( - getNextImageUrlForManipulation(photo.url, IS_PREVIEW), + getOptimizedPhotoUrlForManipulation(photo.url, IS_PREVIEW), ) : ''; return ( { const body: HandleUploadBody = await request.json(); diff --git a/src/admin/AdminUploadsTableRow.tsx b/src/admin/AdminUploadsTableRow.tsx index 235ea6d6..59099906 100644 --- a/src/admin/AdminUploadsTableRow.tsx +++ b/src/admin/AdminUploadsTableRow.tsx @@ -1,9 +1,5 @@ import ImageMedium from '@/components/image/ImageMedium'; import { UrlAddStatus } from './AdminUploadsClient'; -import { - getExtensionFromStorageUrl, - getIdFromStorageUrl, -} from '@/platforms/storage'; import clsx from 'clsx/lite'; import ResponsiveDate from '@/components/ResponsiveDate'; import Spinner from '@/components/Spinner'; @@ -15,6 +11,7 @@ import { isElementEntirelyInViewport } from '@/utility/dom'; import FieldsetWithStatus from '@/components/FieldsetWithStatus'; import EditButton from './EditButton'; import AddUploadButton from './AddUploadButton'; +import { getFileNamePartsFromStorageUrl } from '@/platforms/storage'; export default function AdminUploadsTableRow({ url, @@ -41,7 +38,12 @@ export default function AdminUploadsTableRow({ }) { const ref = useRef(null); - const extension = getExtensionFromStorageUrl(url)?.toUpperCase(); + const { + fileExtension, + fileId, + } = getFileNamePartsFromStorageUrl(url); + + const extension = fileExtension?.toUpperCase(); useEffect(() => { if ( @@ -86,7 +88,7 @@ export default function AdminUploadsTableRow({ 'transition-transform', )}> - accessory?: React.ReactNode + accessory?: ReactNode + footer?: ReactNode hideLabel?: boolean tabIndex?: number }) { @@ -240,6 +242,9 @@ export default function FieldsetWithStatus({ {accessory} } + {footer &&
+ {footer} +
} ); }; diff --git a/src/components/SmallDisclosure.tsx b/src/components/SmallDisclosure.tsx new file mode 100644 index 00000000..92cee9da --- /dev/null +++ b/src/components/SmallDisclosure.tsx @@ -0,0 +1,37 @@ +import clsx from 'clsx/lite'; +import { ReactNode, useState } from 'react'; +import { LuChevronRight } from 'react-icons/lu'; + +export default function SmallDisclosure({ + label, + children, +}: { + label: ReactNode + children: ReactNode +}) { + const [isOpen, setIsOpen] = useState(false); + return ( +
+ + {isOpen && +
+ {children} +
} +
+ ); +} diff --git a/src/feed/programmatic.ts b/src/feed/programmatic.ts index 97c4ae1e..66549592 100644 --- a/src/feed/programmatic.ts +++ b/src/feed/programmatic.ts @@ -1,8 +1,6 @@ import { descriptionForPhoto, Photo, titleForPhoto } from '@/photo'; -import { - getNextImageUrlForRequest, - NextImageSize, -} from '@/platforms/next-image'; +import { getOptimizedPhotoUrl } from '@/photo/storage'; +import { NextImageSize } from '@/platforms/next-image'; export const FEED_PHOTO_REQUEST_LIMIT = 40; @@ -20,7 +18,7 @@ export const generateFeedMedia = ( photo: Photo, size: NextImageSize, ): FeedMedia => ({ - url: getNextImageUrlForRequest({ imageUrl: photo.url, size }), + url: getOptimizedPhotoUrl({ imageUrl: photo.url, size }), width: size, height: Math.round(size / photo.aspectRatio), }); diff --git a/src/image-response/TagImageResponse.tsx b/src/image-response/TagImageResponse.tsx index 8a0aa3a4..e7d1e002 100644 --- a/src/image-response/TagImageResponse.tsx +++ b/src/image-response/TagImageResponse.tsx @@ -34,7 +34,7 @@ export default function TagImageResponse({ height, fontFamily, icon: isTagFavs(tag) - ? + ? ); const updatedUrl = await convertUploadToPhoto({ - urlOrigin: photo.url, + uploadUrl: photo.url, shouldStripGpsData, }); @@ -174,7 +175,7 @@ const addUpload = async ({ onStreamUpdate?.('Transferring to photo storage'); const updatedUrl = await convertUploadToPhoto({ - urlOrigin: url, + uploadUrl: url, fileBytes, shouldStripGpsData, }); @@ -277,14 +278,11 @@ export const updatePhotoAction = async (formData: FormData) => runAuthenticatedAdminServerAction(async () => { const photo = await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(formData); - + let urlToDelete: string | undefined; - if (photo.hidden && photo.url.includes(photo.id)) { - // Backfill: - // Anonymize storage url on update if necessary by - // re-running image upload transfer logic + if (await shouldBackfillPhotoStorage(photo)) { const url = await convertUploadToPhoto({ - urlOrigin: photo.url, + uploadUrl: photo.url, shouldDeleteOrigin: false, }); if (url) { @@ -356,7 +354,7 @@ export const deletePhotosAction = async (photoIds: string[]) => for (const photoId of photoIds) { const photo = await getPhoto(photoId, true); if (photo) { - await deletePhoto(photoId).then(() => deleteFile(photo.url)); + await deletePhotoAndFiles(photoId, photo.url); } } revalidateAllKeysAndPaths(); @@ -368,7 +366,7 @@ export const deletePhotoAction = async ( shouldRedirect?: boolean, ) => runAuthenticatedAdminServerAction(async () => { - await deletePhoto(photoId).then(() => deleteFile(photoUrl)); + await deletePhotoAndFiles(photoId, photoUrl); revalidateAllKeysAndPaths(); if (shouldRedirect) { redirect(PATH_ROOT); @@ -511,11 +509,11 @@ export const syncPhotoAction = async (photoId: string, isBatch?: boolean) => let urlToDelete: string | undefined; if (formDataFromExif) { - if (photo.url.includes(photo.id) || shouldStripGpsData) { + if (await shouldBackfillPhotoStorage(photo) || shouldStripGpsData) { // Anonymize storage url on update if necessary by // re-running image upload transfer logic const url = await convertUploadToPhoto({ - urlOrigin: photo.url, + uploadUrl: photo.url, fileBytes, shouldStripGpsData, shouldDeleteOrigin: false, diff --git a/src/photo/color/server.ts b/src/photo/color/server.ts index e5be7b35..24bf6f65 100644 --- a/src/photo/color/server.ts +++ b/src/photo/color/server.ts @@ -1,5 +1,4 @@ import { convertRgbToOklab, parseHex } from 'culori'; -import { getNextImageUrlForManipulation } from '@/platforms/next-image'; import { AI_CONTENT_GENERATION_ENABLED, IS_PREVIEW, @@ -11,6 +10,7 @@ import { extractColors } from 'extract-colors'; import { getImageBase64FromUrl } from '../server'; import { generateOpenAiImageQuery } from '@/platforms/openai'; import { calculateColorSort } from './sort'; +import { getOptimizedPhotoUrlForManipulation } from '../storage'; const NULL_RGB = { r: 0, g: 0, b: 0 }; @@ -29,7 +29,7 @@ export const convertHexToOklch = (hex: string): Oklch => { // Convert image url to byte array const getImageDataFromUrl = async (_url: string) => { - const url = getNextImageUrlForManipulation(_url, IS_PREVIEW); + const url = getOptimizedPhotoUrlForManipulation(_url, IS_PREVIEW); const imageBuffer = await fetch(decodeURIComponent(url)) .then(res => res.arrayBuffer()); const image = sharp(imageBuffer); @@ -121,7 +121,7 @@ export const getColorFromAI = async ( _url: string, useBatch?: boolean, ) => { - const url = getNextImageUrlForManipulation(_url, IS_PREVIEW); + const url = getOptimizedPhotoUrlForManipulation(_url, IS_PREVIEW); const image = await getImageBase64FromUrl(url); const hexColor = await generateOpenAiImageQuery(image, ` Does this image have a primary subject color? diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 10f27e3a..1de94270 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -36,7 +36,6 @@ import Spinner from '@/components/Spinner'; import usePreventNavigation from '@/utility/usePreventNavigation'; import { useAppState } from '@/app/AppState'; import UpdateBlurDataButton from '../UpdateBlurDataButton'; -import { getNextImageUrlForManipulation } from '@/platforms/next-image'; import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config'; import ErrorNote from '@/components/ErrorNote'; import { convertRecipesForForm, Recipes } from '@/recipe'; @@ -56,12 +55,20 @@ import { capitalize } from '@/utility/string'; import AnchorSections from '@/components/AnchorSections'; import useIsVisible from '@/utility/useIsVisible'; import useHash from '@/utility/useHash'; +import { getOptimizedPhotoUrlForManipulation } from '../storage'; +import { + getFileNamePartsFromStorageUrl, + StorageListResponse, +} from '@/platforms/storage'; +import SmallDisclosure from '@/components/SmallDisclosure'; +import { TbPhoto } from 'react-icons/tb'; const THUMBNAIL_SIZE = 300; export default function PhotoForm({ type = 'create', initialPhotoForm, + photoStorageUrls, updatedExifData, updatedBlurData, uniqueTags, @@ -75,6 +82,7 @@ export default function PhotoForm({ }: { type?: 'create' | 'edit' initialPhotoForm: Partial + photoStorageUrls?: StorageListResponse updatedExifData?: Partial updatedBlurData?: string uniqueTags?: Tags @@ -245,7 +253,7 @@ export default function PhotoForm({ case 'blurData': return shouldDebugImageFallbacks && type === 'edit' && formData.url ? { + switch (key) { + case 'url': + return photoStorageUrls && photoStorageUrls.length > 1 + ? +
+ {photoStorageUrls.map(({ url, size }) => { + const { fileName } = getFileNamePartsFromStorageUrl(url); + return
+ + + {fileName} + + {size} +
; + })} +
+
+ : undefined; + } + }; + const isFieldHidden = ( key: FormFields, hideIfEmpty?: boolean, @@ -500,6 +536,7 @@ export default function PhotoForm({ ), type, accessory: accessoryForField(key), + footer: footerForField(key), }; switch (key) { case 'film': diff --git a/src/photo/server.ts b/src/photo/server.ts index 6d6aa8eb..948196b3 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -1,6 +1,6 @@ import { - getExtensionFromStorageUrl, - getIdFromStorageUrl, + deleteFilesWithPrefix, + getFileNamePartsFromStorageUrl, } from '@/platforms/storage'; import { convertFormDataToPhotoDbInsert } from '@/photo/form'; import { @@ -20,6 +20,7 @@ import { getFujifilmRecipeFromMakerNote, } from '@/platforms/fujifilm/recipe'; import { + deletePhoto, getRecipeTitleForData, updateAllMatchingRecipeTitles, } from './db/query'; @@ -28,8 +29,9 @@ import { convertExifToFormData } from './form/server'; import { getColorFieldsForPhotoForm } from './color/server'; import exifr from 'exifr'; -const IMAGE_WIDTH_RESIZE = 200; const IMAGE_WIDTH_BLUR = 200; +const IMAGE_WIDTH_DEFAULT = 200; +const IMAGE_QUALITY_DEFAULT = 80; export const extractImageDataFromBlobPath = async ( blobPath: string, @@ -54,9 +56,10 @@ export const extractImageDataFromBlobPath = async ( const url = decodeURIComponent(blobPath); - const blobId = getIdFromStorageUrl(url); - - const extension = getExtensionFromStorageUrl(url); + const { + fileExtension: extension, + fileId: blobId, + } = getFileNamePartsFromStorageUrl(url); let exifData: ExifData | undefined; let exifrData: any | undefined; @@ -146,13 +149,13 @@ const generateBase64 = async ( ) => (middleware ? middleware(sharp(image)) : sharp(image)) .withMetadata() - .toFormat('jpeg', { quality: 90 }) + .toFormat('jpeg', { quality: IMAGE_QUALITY_DEFAULT }) .toBuffer() .then(data => `data:image/jpeg;base64,${data.toString('base64')}`); const resizeImage = async ( image: ArrayBuffer, - width = IMAGE_WIDTH_RESIZE, + width = IMAGE_WIDTH_DEFAULT, ) => generateBase64(image, sharp => sharp .resize(width), @@ -195,6 +198,16 @@ export const blurImageFromUrl = async (url: string) => return ''; }); +export const resizeImageToBytes = async ( + image: ArrayBuffer, + width: number, + quality = IMAGE_QUALITY_DEFAULT, +) => + sharp(image) + .resize(width) + .toFormat('jpeg', { quality }) + .toBuffer(); + const GPS_NULL_STRING = '-'; export const removeGpsData = async (image: ArrayBuffer) => @@ -260,3 +273,13 @@ export const propagateRecipeTitleIfNecessary = async ( ); } }; + +export const deletePhotoAndFiles = async ( + photoId: string, + photoUrl: string, +) => + deletePhoto(photoId) + .then(() => { + const { fileNameBase } = getFileNamePartsFromStorageUrl(photoUrl); + return deleteFilesWithPrefix(fileNameBase); + }); diff --git a/src/photo/storage.ts b/src/photo/storage.ts deleted file mode 100644 index 49f71052..00000000 --- a/src/photo/storage.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - copyFile, - deleteFile, - generateRandomFileNameForPhoto, - getExtensionFromStorageUrl, - moveFile, - putFile, -} from '@/platforms/storage'; -import { removeGpsData } from './server'; - -export const convertUploadToPhoto = async ({ - urlOrigin, - fileBytes, - shouldStripGpsData, - shouldDeleteOrigin = true, -} : { - urlOrigin: string - fileBytes?: ArrayBuffer - shouldStripGpsData?: boolean - shouldDeleteOrigin?: boolean -}) => { - const fileName = generateRandomFileNameForPhoto(); - const fileExtension = getExtensionFromStorageUrl(urlOrigin); - const photoPath = `${fileName}.${fileExtension || 'jpg'}`; - if (shouldStripGpsData) { - const fileWithoutGps = await removeGpsData( - fileBytes ?? await fetch(urlOrigin, { cache: 'no-store' }) - .then(res => res.arrayBuffer()), - ); - return putFile(fileWithoutGps, photoPath).then(async url => { - if (url && shouldDeleteOrigin) { await deleteFile(urlOrigin); } - return url; - }); - } else { - return shouldDeleteOrigin - ? moveFile(urlOrigin, photoPath) - : copyFile(urlOrigin, photoPath); - } -}; diff --git a/src/photo/storage/index.ts b/src/photo/storage/index.ts new file mode 100644 index 00000000..35b9f37f --- /dev/null +++ b/src/photo/storage/index.ts @@ -0,0 +1,157 @@ +import { + getNextImageUrlForRequest, + NextImageSize, +} from '@/platforms/next-image'; +import { + generateFileNameWithId, + getFileNamePartsFromStorageUrl, + getStorageUrlsForPrefix, + uploadFileFromClient, +} from '@/platforms/storage'; +import { Photo } from '..'; + +const PREFIX_PHOTO = 'photo'; +const PREFIX_UPLOAD = 'upload'; + +const EXTENSION_DEFAULT = 'jpg'; +const EXTENSION_OPTIMIZED = 'jpg'; + +// For the time being, make compatible with `next/image` sizes +const OPTIMIZED_FILE_SIZES = [{ + suffix: 'sm', + size: 200, + quality: 90, +}, { + suffix: 'md', + size: 640, + quality: 90, +}, { + suffix: 'lg', + size: 1080, + quality: 80, +}] as const satisfies { + suffix: string + size: NextImageSize + quality: number +}[]; + +type OptimizedSuffix = (typeof OPTIMIZED_FILE_SIZES)[number]['suffix']; + +const OPTIMIZED_SUFFIX_DEFAULT: OptimizedSuffix = 'md'; + +const getOptimizedFileName = ({ + fileNameBase, + suffix, +}: { + fileNameBase: string + suffix: OptimizedSuffix +}) => + `${fileNameBase}-${suffix}.${EXTENSION_OPTIMIZED}`; + +const getOptimizedUrl =({ + urlBase, + fileNameBase, + suffix, +}: { + urlBase: string + fileNameBase: string + suffix: OptimizedSuffix +}) => + `${urlBase}/${getOptimizedFileName({ fileNameBase, suffix })}`; + +export const getOptimizedPhotoFileMeta = (fileNameBase: string) => + OPTIMIZED_FILE_SIZES.map(({ suffix, ...rest }) => ({ + ...rest, + fileName: getOptimizedFileName({ fileNameBase, suffix }), + })); + +export const getOptimizedUrlsFromPhotoUrl = (url: string) => { + const { urlBase, fileNameBase } = getFileNamePartsFromStorageUrl(url); + return getOptimizedPhotoFileMeta(fileNameBase).map(({ fileName }) => + `${urlBase}/${fileName}`); +}; + +export const isUploadPathnameValid = (pathname?: string) => + pathname?.match(new RegExp(`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`, 'i')); + +export const generateRandomFileNameForPhoto = () => + generateFileNameWithId(PREFIX_PHOTO); + +export const getStorageUploadUrls = () => + getStorageUrlsForPrefix(`${PREFIX_UPLOAD}-`); + +export const getStoragePhotoUrls = () => + getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`); + +export const uploadPhotoFromClient = ( + file: File | Blob, + extension = EXTENSION_DEFAULT, +) => + uploadFileFromClient(file, PREFIX_UPLOAD, extension); + +const getSuffixFromNextImageSize = (nextSize: NextImageSize) => + OPTIMIZED_FILE_SIZES.find(({ size }) => size === nextSize)?.suffix + ?? OPTIMIZED_SUFFIX_DEFAULT; + +export const getOptimizedPhotoUrl = ( + args: Parameters[0] & { + compatibilityMode?: boolean + }, +) => { + const { compatibilityMode = true } = args; + const suffix = getSuffixFromNextImageSize(args.size); + const { + urlBase, + fileNameBase, + } = getFileNamePartsFromStorageUrl(args.imageUrl); + return compatibilityMode + ? getNextImageUrlForRequest(args) + : getOptimizedUrl({ urlBase, fileNameBase, suffix }); +}; + +// Generate small, low-bandwidth images for quick manipulations such as +// generating blur data or image thumbnails for AI text generation +export const getOptimizedPhotoUrlForManipulation = ( + imageUrl: string, + addBypassSecret: boolean, + compatibilityMode?: boolean, +) => + getOptimizedPhotoUrl({ + imageUrl, + size: 640, + addBypassSecret, + compatibilityMode, + }); + +const getTestOptimizedPhotoUrl = (url: string) => { + const { urlBase, fileNameBase } = getFileNamePartsFromStorageUrl(url); + return getOptimizedUrl({ + urlBase, + fileNameBase, + suffix: OPTIMIZED_SUFFIX_DEFAULT, + }); +}; + +export const doesPhotoUrlHaveOptimizedFiles = async (url: string) => + fetch(getTestOptimizedPhotoUrl(url)).then(res => res.ok); + +export const doAllPhotosHaveOptimizedFiles = async (photos: Photo[]) => + Promise.all(photos.map(({ url }) => fetch(getTestOptimizedPhotoUrl(url)))) + .then(urls => urls.every(url => url.ok)) + .catch(() => false); + +export const getStorageUrlsForPhoto = async ({ url }: Photo) => { + const getSortScoreForUrl = (url: string) => { + const { fileNameBase } = getFileNamePartsFromStorageUrl(url); + if (fileNameBase.endsWith('-sm')) { return 1; } + if (fileNameBase.endsWith('-md')) { return 2; } + if (fileNameBase.endsWith('-lg')) { return 3; } + return 0; + }; + + const { fileNameBase } = getFileNamePartsFromStorageUrl(url); + + return getStorageUrlsForPrefix(fileNameBase).then(urls => + urls.sort((a, b) => getSortScoreForUrl(a.url) - getSortScoreForUrl(b.url)), + ); +}; diff --git a/src/photo/storage/server.ts b/src/photo/storage/server.ts new file mode 100644 index 00000000..3402271b --- /dev/null +++ b/src/photo/storage/server.ts @@ -0,0 +1,58 @@ +import { + copyFile, + deleteFile, + getFileNamePartsFromStorageUrl, + moveFile, + putFile, +} from '@/platforms/storage'; +import { removeGpsData, resizeImageToBytes } from '../server'; +import { + generateRandomFileNameForPhoto, + getOptimizedPhotoFileMeta, +} from '.'; + +export const storeOptimizedPhotos = async ( + url: string, + fileBytes: ArrayBuffer, +) => { + const { fileNameBase } = getFileNamePartsFromStorageUrl(url); + const optimizedPhotoFileMeta = getOptimizedPhotoFileMeta(fileNameBase); + for (const { fileName, size, quality } of optimizedPhotoFileMeta) { + await putFile(await resizeImageToBytes(fileBytes, size, quality), fileName); + } + return url; +}; + +export const convertUploadToPhoto = async ({ + uploadUrl, + fileBytes: _fileBytes, + shouldStripGpsData, + shouldDeleteOrigin = true, +} : { + uploadUrl: string + fileBytes?: ArrayBuffer + shouldStripGpsData?: boolean + shouldDeleteOrigin?: boolean +}) => { + const fileNameBase = generateRandomFileNameForPhoto(); + const { fileExtension } = getFileNamePartsFromStorageUrl(uploadUrl); + const fileName = `${fileNameBase}.${fileExtension}`; + const fileBytes = _fileBytes + ? _fileBytes + : await fetch(uploadUrl).then(res => res.arrayBuffer()); + let promise: Promise; + if (shouldStripGpsData) { + const fileWithoutGps = await removeGpsData(fileBytes); + promise = putFile(fileWithoutGps, fileName) + .then(async url => { + if (url && shouldDeleteOrigin) { await deleteFile(uploadUrl); } + return url; + }); + } else { + promise = shouldDeleteOrigin + ? moveFile(uploadUrl, fileName) + : copyFile(uploadUrl, fileName); + } + // Store optimized photos after original photo is copied/moved + return promise.then(async url => storeOptimizedPhotos(url, fileBytes)); +}; diff --git a/src/photo/update/server.ts b/src/photo/update/server.ts new file mode 100644 index 00000000..c2fd73d8 --- /dev/null +++ b/src/photo/update/server.ts @@ -0,0 +1,10 @@ +import { Photo, PhotoDbInsert } from '..'; +import { doesPhotoUrlHaveOptimizedFiles } from '../storage'; + +// Used to anonymize storage/create optimized files if necessary +// by re-running convertUploadToPhoto (image upload transfer logic) +export const shouldBackfillPhotoStorage = async ( + photo: Photo | PhotoDbInsert, +) => + photo.url.includes(photo.id) || + !await doesPhotoUrlHaveOptimizedFiles(photo.url); diff --git a/src/platforms/next-image.ts b/src/platforms/next-image.ts index 23bc71b6..e8ef6be4 100644 --- a/src/platforms/next-image.ts +++ b/src/platforms/next-image.ts @@ -39,15 +39,3 @@ export const getNextImageUrlForRequest = ({ return url.toString(); }; - -// Generate small, low-bandwidth images for quick manipulations such as -// generating blur data or image thumbnails for AI text generation -export const getNextImageUrlForManipulation = ( - imageUrl: string, - addBypassSecret: boolean, -) => - getNextImageUrlForRequest({ - imageUrl, - size: 640, - addBypassSecret, - }); diff --git a/src/platforms/safe-photo-image-response.ts b/src/platforms/safe-photo-image-response.ts index 3744f628..3fd7901f 100644 --- a/src/platforms/safe-photo-image-response.ts +++ b/src/platforms/safe-photo-image-response.ts @@ -1,14 +1,14 @@ import { Photo } from '@/photo'; import { ImageResponse } from 'next/og'; import { JSX } from 'react'; -import { getNextImageUrlForRequest } from './next-image'; import { IS_PREVIEW } from '@/app/config'; +import { getOptimizedPhotoUrl } from '@/photo/storage'; const isNextImageReadyBasedOnPhotos = async ( photos: Photo[], ): Promise => photos.length > 0 && - fetch(getNextImageUrlForRequest({ + fetch(getOptimizedPhotoUrl({ imageUrl: photos[0].url, size: 640, addBypassSecret: IS_PREVIEW, diff --git a/src/platforms/storage/aws-s3.ts b/src/platforms/storage/aws-s3.ts index af418912..d7d5a740 100644 --- a/src/platforms/storage/aws-s3.ts +++ b/src/platforms/storage/aws-s3.ts @@ -6,7 +6,7 @@ import { PutObjectCommand, } from '@aws-sdk/client-s3'; import { StorageListResponse, generateStorageId } from '.'; -import { formatBytesToMB } from '@/utility/number'; +import { formatBytes } from '@/utility/number'; const AWS_S3_BUCKET = process.env.NEXT_PUBLIC_AWS_S3_BUCKET ?? ''; const AWS_S3_REGION = process.env.NEXT_PUBLIC_AWS_S3_REGION ?? ''; @@ -75,7 +75,7 @@ export const awsS3List = async ( url: urlForKey(Key), fileName: Key ?? '', uploadedAt: LastModified, - size: Size ? formatBytesToMB(Size) : undefined, + size: Size ? formatBytes(Size) : undefined, })) ?? []); export const awsS3Delete = async (Key: string) => { diff --git a/src/platforms/storage/cache.ts b/src/platforms/storage/cache.ts index 54647c8c..d6ec5d70 100644 --- a/src/platforms/storage/cache.ts +++ b/src/platforms/storage/cache.ts @@ -1,5 +1,8 @@ import { unstable_noStore } from 'next/cache'; -import { getStoragePhotoUrls, getStorageUploadUrls } from '@/platforms/storage'; +import { + getStoragePhotoUrls, + getStorageUploadUrls, +} from '@/photo/storage'; export const getStorageUploadUrlsNoStore: typeof getStorageUploadUrls = (...args) => { @@ -11,4 +14,4 @@ export const getStoragePhotoUrlsNoStore: typeof getStoragePhotoUrls = (...args) => { unstable_noStore(); return getStoragePhotoUrls(...args); - }; \ No newline at end of file + }; diff --git a/src/platforms/storage/cloudflare-r2.ts b/src/platforms/storage/cloudflare-r2.ts index 4d2d83b3..96fd228a 100644 --- a/src/platforms/storage/cloudflare-r2.ts +++ b/src/platforms/storage/cloudflare-r2.ts @@ -7,7 +7,7 @@ import { } from '@aws-sdk/client-s3'; import { StorageListResponse, generateStorageId } from '.'; import { removeUrlProtocol } from '@/utility/url'; -import { formatBytesToMB } from '@/utility/number'; +import { formatBytes } from '@/utility/number'; const CLOUDFLARE_R2_BUCKET = process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? ''; @@ -95,7 +95,7 @@ export const cloudflareR2List = async ( url: urlForKey(Key), fileName: Key ?? '', uploadedAt: LastModified, - size: Size ? formatBytesToMB(Size) : undefined, + size: Size ? formatBytes(Size) : undefined, })) ?? []); export const cloudflareR2Delete = async (Key: string) => { diff --git a/src/platforms/storage/index.ts b/src/platforms/storage/index.ts index b6358c7e..9ad89218 100644 --- a/src/platforms/storage/index.ts +++ b/src/platforms/storage/index.ts @@ -40,8 +40,6 @@ import { } from './minio'; import { PATH_API_PRESIGNED_URL } from '@/app/path'; -export const generateStorageId = () => generateNanoid(16); - export type StorageListItem = { url: string fileName: string @@ -57,6 +55,29 @@ export type StorageType = '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 = '', + fileExtension = '', + ] = url.match(/^(.+)\/((-*[a-z0-9]+-*([a-z0-9-]+))\.([a-z]{1,4}))$/i) ?? []; + return { + urlBase, + fileName, + fileNameBase, + fileId, + fileExtension, + }; +}; + export const labelForStorage = (type: StorageType): string => { switch (type) { case 'vercel-blob': return 'Vercel Blob'; @@ -87,56 +108,15 @@ export const storageTypeFromUrl = (url: string): StorageType => { } }; -const PREFIX_UPLOAD = 'upload'; -const PREFIX_PHOTO = 'photo'; - -export const generateRandomFileNameForPhoto = () => - `${PREFIX_PHOTO}-${generateStorageId()}`; - -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 getFilePathFromStorageUrl = (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}/`, ''); - case 'minio': - return url.replace(`${MINIO_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, + fileNameBase: string, extension: string, addRandomSuffix?: boolean, ) => { const key = addRandomSuffix - ? `${fileName}-${generateStorageId()}.${extension}` - : `${fileName}.${extension}`; + ? `${fileNameBase}-${generateStorageId()}.${extension}` + : `${fileNameBase}.${extension}`; const url = await fetch(`${PATH_API_PRESIGNED_URL}/${key}`) .then((response) => response.text()); @@ -145,16 +125,17 @@ export const uploadFromClientViaPresignedUrl = async ( .then(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${key}`); }; -export const uploadPhotoFromClient = async ( +export const uploadFileFromClient = async ( file: File | Blob, - extension = 'jpg', + fileNameBase: string, + extension: string, ) => ( CURRENT_STORAGE === 'cloudflare-r2' || CURRENT_STORAGE === 'aws-s3' || CURRENT_STORAGE === 'minio' ) - ? uploadFromClientViaPresignedUrl(file, PREFIX_UPLOAD, extension, true) - : vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`); + ? uploadFromClientViaPresignedUrl(file, fileNameBase, extension, true) + : vercelBlobUploadFromClient(file, `${fileNameBase}.${extension}`); export const putFile = ( file: Buffer, @@ -176,6 +157,7 @@ export const copyFile = ( originUrl: string, destinationFileName: string, ): Promise => { + const { fileName } = getFileNamePartsFromStorageUrl(originUrl); switch (storageTypeFromUrl(originUrl)) { case 'vercel-blob': return vercelBlobCopy( @@ -185,7 +167,7 @@ export const copyFile = ( ); case 'cloudflare-r2': return cloudflareR2Copy( - getFileNameFromStorageUrl(originUrl), + fileName, destinationFileName, false, ); @@ -197,7 +179,7 @@ export const copyFile = ( ); case 'minio': return minioCopy( - getFileNameFromStorageUrl(originUrl), + fileName, destinationFileName, false, ); @@ -205,18 +187,24 @@ export const copyFile = ( }; export const deleteFile = (url: string) => { + const { fileName } = getFileNamePartsFromStorageUrl(url); switch (storageTypeFromUrl(url)) { case 'vercel-blob': return vercelBlobDelete(url); case 'cloudflare-r2': - return cloudflareR2Delete(getFilePathFromStorageUrl(url)); + return cloudflareR2Delete(fileName); case 'aws-s3': - return awsS3Delete(getFilePathFromStorageUrl(url)); + return awsS3Delete(fileName); case 'minio': - return minioDelete(getFilePathFromStorageUrl(url)); + 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, @@ -227,7 +215,7 @@ export const moveFile = async ( return url; }; -const getStorageUrlsForPrefix = async (prefix = '') => { +export const getStorageUrlsForPrefix = async (prefix = '') => { const urls: StorageListResponse = []; if (HAS_VERCEL_BLOB_STORAGE) { @@ -255,11 +243,5 @@ const getStorageUrlsForPrefix = async (prefix = '') => { }); }; -export const getStorageUploadUrls = () => - getStorageUrlsForPrefix(`${PREFIX_UPLOAD}-`); - -export const getStoragePhotoUrls = () => - getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`); - export const testStorageConnection = () => getStorageUrlsForPrefix(); diff --git a/src/platforms/storage/minio.ts b/src/platforms/storage/minio.ts index b0feec7f..cf8cbf80 100644 --- a/src/platforms/storage/minio.ts +++ b/src/platforms/storage/minio.ts @@ -6,7 +6,7 @@ import { DeleteObjectCommand, } from '@aws-sdk/client-s3'; import { StorageListResponse, generateStorageId } from '.'; -import { formatBytesToMB } from '@/utility/number'; +import { formatBytes } from '@/utility/number'; const MINIO_BUCKET = process.env.NEXT_PUBLIC_MINIO_BUCKET ?? ''; const MINIO_DOMAIN = process.env.NEXT_PUBLIC_MINIO_DOMAIN ?? ''; @@ -65,7 +65,8 @@ export const minioCopy = async ( : fileNameDestination; return minioClient().send(new CopyObjectCommand({ Bucket: MINIO_BUCKET, - CopySource: fileNameSource, + // Bucket behavior seems to differ from R2 + S3 + CopySource: `${MINIO_BUCKET}/${fileNameSource}`, Key, })) .then(() => urlForKey(Key)); @@ -82,7 +83,7 @@ export const minioList = async ( url: urlForKey(Key), fileName: Key ?? '', uploadedAt: LastModified, - size: Size ? formatBytesToMB(Size) : undefined, + size: Size ? formatBytes(Size) : undefined, })) ?? []); export const minioDelete = async (Key: string): Promise => { diff --git a/src/platforms/storage/vercel-blob.ts b/src/platforms/storage/vercel-blob.ts index 1633ad6c..46dd5762 100644 --- a/src/platforms/storage/vercel-blob.ts +++ b/src/platforms/storage/vercel-blob.ts @@ -1,8 +1,8 @@ import { PATH_API_VERCEL_BLOB_UPLOAD } from '@/app/path'; import { copy, del, list, put } from '@vercel/blob'; import { upload } from '@vercel/blob/client'; -import { getFilePathFromStorageUrl, StorageListResponse } from '.'; -import { formatBytesToMB } from '@/utility/number'; +import { getFileNamePartsFromStorageUrl, StorageListResponse } from '.'; +import { formatBytes } from '@/utility/number'; const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match( /^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i, @@ -55,7 +55,7 @@ export const vercelBlobList = ( ): Promise => list({ prefix }) .then(({ blobs }) => blobs.map(({ url, uploadedAt, size }) => ({ url, - fileName: getFilePathFromStorageUrl(url), + fileName: getFileNamePartsFromStorageUrl(url).fileName, uploadedAt, - size: formatBytesToMB(size), + size: formatBytes(size), }))); diff --git a/src/utility/number.ts b/src/utility/number.ts index dd66caa0..099d3e3f 100644 --- a/src/utility/number.ts +++ b/src/utility/number.ts @@ -88,12 +88,13 @@ export const formatNumberToFraction = (number: number) => { } }; -export const formatBytesToMB = ( +export const formatBytes = ( bytes: number, byteSize = 1000, - precision = 1, ) => - `${(bytes / byteSize / byteSize).toFixed(precision)}MB`; + bytes < byteSize * byteSize + ? `${Math.round(bytes / byteSize)}KB` + : `${(bytes / byteSize / byteSize).toFixed(1)}MB`; export const convertNumberToRomanNumeral = (number: number) => { const romanNumerals = [