Store optimized assets for all photos (#304)

* Begin storing optimized photo files

* Increase optimized image size to 1080

* Refactor photo/storage modules

* Refine storage file naming api

* Simplify photo storage api

* Finalize photo storage api

* Start storing/serving optimized photos

* Finalize optimized photo asset generation

* Temporarily allow static optimization on PREVIEW branches

* Restore static optimization as production-only

* Remove og image inline-flex class

* Tweak convert upload signature

* Refactor optimized file storage

* Display optimized files when they exist in photo form

* Create small disclosure component

* Report photo storage files more accurately

* Sort optimized files

* Generate optimized storage files when updating/syncing photos

* Include source bucket when copying files with MinIO

* Make deleting files more resilient
This commit is contained in:
Sam Becker 2025-09-06 23:20:20 -05:00 committed by GitHub
parent 784c641174
commit b8c01492b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 457 additions and 182 deletions

View File

@ -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 (
<PhotoEditPageClient {...{
photo,
photoStorageUrls,
uniqueTags,
uniqueRecipes,
uniqueFilms,

View File

@ -4,9 +4,9 @@ import {
ACCEPTED_PHOTO_FILE_TYPES,
MAX_PHOTO_UPLOAD_SIZE_IN_BYTES,
} from '@/photo';
import { isUploadPathnameValid } from '@/platforms/storage';
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
import { NextResponse } from 'next/server';
import { isUploadPathnameValid } from '@/photo/storage';
export async function POST(request: Request): Promise<NextResponse> {
const body: HandleUploadBody = await request.json();

View File

@ -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<HTMLDivElement>(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',
)}>
<ImageMedium
title={getIdFromStorageUrl(url)}
title={fileId}
src={url}
alt={url}
aspectRatio={3.0 / 2.0}

View File

@ -41,6 +41,7 @@ export default function FieldsetWithStatus({
type = 'text',
inputRef: inputRefProp,
accessory,
footer,
hideLabel,
tabIndex,
}: {
@ -70,7 +71,8 @@ export default function FieldsetWithStatus({
capitalize?: boolean
type?: FieldSetType
inputRef?: RefObject<HTMLInputElement | null>
accessory?: React.ReactNode
accessory?: ReactNode
footer?: ReactNode
hideLabel?: boolean
tabIndex?: number
}) {
@ -240,6 +242,9 @@ export default function FieldsetWithStatus({
{accessory}
</div>}
</div>
{footer && <div className="mt-3">
{footer}
</div>}
</div>
);
};

View File

@ -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 (
<div className="space-y-2">
<button
type="button"
className={clsx(
'flex items-center gap-1.5 link',
'hover:opacity-100!',
)}
onClick={() => setIsOpen(!isOpen)}
>
<span className={clsx(
'transition-transform duration-200',
isOpen && 'rotate-90',
)}>
<LuChevronRight size={16} />
</span>
<span>{label}</span>
</button>
{isOpen &&
<div className="pl-5.5">
{children}
</div>}
</div>
);
}

View File

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

View File

@ -34,7 +34,7 @@ export default function TagImageResponse({
height,
fontFamily,
icon: isTagFavs(tag)
? <span tw="text-amber-500 inline-flex ">
? <span tw="text-amber-500">
<IconFavs
size={height * .066}
style={{

View File

@ -1,13 +1,14 @@
/* eslint-disable jsx-a11y/alt-text */
import { Photo } from '@/photo';
import {
NextImageSize,
getNextImageUrlForRequest,
} from '@/platforms/next-image';
import { NextImageSize } from '@/platforms/next-image';
import { IS_PREVIEW } from '@/app/config';
import {
doAllPhotosHaveOptimizedFiles,
getOptimizedPhotoUrl,
} from '@/photo/storage';
export default function ImagePhotoGrid({
export default async function ImagePhotoGrid({
photos,
width,
widthArbitrary,
@ -47,6 +48,8 @@ export default function ImagePhotoGrid({
const cellHeight= height / rows -
(rows - 1) * gap / rows;
const doOptimizedFilesExist = await doAllPhotosHaveOptimizedFiles(photos);
return (
<div
style={{
@ -69,10 +72,11 @@ export default function ImagePhotoGrid({
}}
>
<img {...{
src: getNextImageUrlForRequest({
src: getOptimizedPhotoUrl({
imageUrl: url,
size: nextImageWidth,
addBypassSecret: IS_PREVIEW,
compatibilityMode: !doOptimizedFilesExist,
}),
style: {
...imageStyle,

View File

@ -15,9 +15,11 @@ import ExifCaptureButton from '@/admin/ExifCaptureButton';
import { useState } from 'react';
import { Recipes } from '@/recipe';
import { Films } from '@/film';
import { StorageListResponse } from '@/platforms/storage';
export default function PhotoEditPageClient({
photo,
photoStorageUrls,
uniqueTags,
uniqueRecipes,
uniqueFilms,
@ -26,6 +28,7 @@ export default function PhotoEditPageClient({
blurData,
}: {
photo: Photo
photoStorageUrls?: StorageListResponse
uniqueTags: Tags
uniqueRecipes: Recipes
uniqueFilms: Films
@ -77,6 +80,7 @@ export default function PhotoEditPageClient({
<PhotoForm
type="edit"
initialPhotoForm={photoForm}
photoStorageUrls={photoStorageUrls}
updatedExifData={updatedExifData}
updatedBlurData={blurData}
uniqueTags={uniqueTags}

View File

@ -1,6 +1,5 @@
'use client';
import { uploadPhotoFromClient } from '@/platforms/storage';
import { usePathname, useRouter } from 'next/navigation';
import { PATH_ADMIN_UPLOADS, pathForAdminUploadUrl } from '@/app/path';
import ImageInput from '../components/ImageInput';
@ -10,6 +9,7 @@ import { RefObject, useTransition, useRef, useEffect } from 'react';
import Spinner from '@/components/Spinner';
import ResponsiveText from '@/components/primitives/ResponsiveText';
import { useAppText } from '@/i18n/state/client';
import { uploadPhotoFromClient } from './storage';
export default function PhotoUploadWithStatus({
inputRef,

View File

@ -1,7 +1,6 @@
'use server';
import {
deletePhoto,
insertPhoto,
deletePhotoTagGlobally,
updatePhoto,
@ -43,6 +42,7 @@ import {
import {
blurImageFromUrl,
convertFormDataToPhotoDbInsertAndLookupRecipeTitle,
deletePhotoAndFiles,
extractImageDataFromBlobPath,
propagateRecipeTitleIfNecessary,
} from './server';
@ -58,7 +58,7 @@ import {
} from '@/app/config';
import { generateAiImageQueries } from './ai/server';
import { createStreamableValue } from '@ai-sdk/rsc';
import { convertUploadToPhoto } from './storage';
import { convertUploadToPhoto } from './storage/server';
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
import { convertStringToArray } from '@/utility/string';
import { after } from 'next/server';
@ -66,6 +66,7 @@ import {
getColorFieldsForImageUrl,
getColorFieldsForPhotoDbInsert,
} from '@/photo/color/server';
import { shouldBackfillPhotoStorage } from './update/server';
// Private actions
@ -78,7 +79,7 @@ export const createPhotoAction = async (formData: FormData) =>
);
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,
});
@ -279,12 +280,9 @@ export const updatePhotoAction = async (formData: FormData) =>
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,

View File

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

View File

@ -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<PhotoFormData>
photoStorageUrls?: StorageListResponse
updatedExifData?: Partial<PhotoFormData>
updatedBlurData?: string
uniqueTags?: Tags
@ -245,7 +253,7 @@ export default function PhotoForm({
case 'blurData':
return shouldDebugImageFallbacks && type === 'edit' && formData.url
? <UpdateBlurDataButton
photoUrl={getNextImageUrlForManipulation(
photoUrl={getOptimizedPhotoUrlForManipulation(
formData.url,
IS_PREVIEW,
)}
@ -257,6 +265,34 @@ export default function PhotoForm({
}
};
const footerForField = (key: keyof PhotoFormData) => {
switch (key) {
case 'url':
return photoStorageUrls && photoStorageUrls.length > 1
? <SmallDisclosure label="Optimized file set">
<div className="space-y-1">
{photoStorageUrls.map(({ url, size }) => {
const { fileName } = getFileNamePartsFromStorageUrl(url);
return <div
key={url}
className="flex items-center gap-2"
>
<TbPhoto className="translate-y-[1px] text-medium" />
<Link
href={url}
target="_blank"
>
{fileName}
</Link>
<span className="text-dim">{size}</span>
</div>;
})}
</div>
</SmallDisclosure>
: 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':

View File

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

View File

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

157
src/photo/storage/index.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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<boolean> =>
photos.length > 0 &&
fetch(getNextImageUrlForRequest({
fetch(getOptimizedPhotoUrl({
imageUrl: photos[0].url,
size: 640,
addBypassSecret: IS_PREVIEW,

View File

@ -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) => {

View File

@ -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) => {

View File

@ -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) => {

View File

@ -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<string> => {
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();

View File

@ -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<void> => {

View File

@ -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<StorageListResponse> => list({ prefix })
.then(({ blobs }) => blobs.map(({ url, uploadedAt, size }) => ({
url,
fileName: getFilePathFromStorageUrl(url),
fileName: getFileNamePartsFromStorageUrl(url).fileName,
uploadedAt,
size: formatBytesToMB(size),
size: formatBytes(size),
})));

View File

@ -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 = [