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
This commit is contained in:
Sam Becker 2026-02-10 21:17:57 -06:00 committed by GitHub
parent f962074af9
commit 351f8869a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 168 additions and 88 deletions

View File

@ -6,7 +6,6 @@ import {
} from '@/photo';
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();
@ -15,18 +14,13 @@ export async function POST(request: Request): Promise<NextResponse> {
const jsonResponse = await handleUpload({
body,
request,
onBeforeGenerateToken: async (pathname) => {
onBeforeGenerateToken: async () => {
const session = await auth();
if (session?.user) {
if (isUploadPathnameValid(pathname)) {
return {
maximumSizeInBytes: MAX_PHOTO_UPLOAD_SIZE_IN_BYTES,
allowedContentTypes: ACCEPTED_PHOTO_FILE_TYPES,
addRandomSuffix: true,
};
} else {
throw new Error('Invalid upload');
}
return {
maximumSizeInBytes: MAX_PHOTO_UPLOAD_SIZE_IN_BYTES,
allowedContentTypes: ACCEPTED_PHOTO_FILE_TYPES,
};
} else {
throw new Error('Unauthenticated upload');
}

View File

@ -1,6 +1,6 @@
'use client';
import { ComponentProps, useMemo } from 'react';
import { ComponentProps, useMemo, useRef } from 'react';
import {
getPathComponents,
PATH_ROOT,
@ -9,6 +9,7 @@ import {
} from '@/app/path';
import {
deletePhotoAction,
replacePhotoStorageAction,
syncPhotoAction,
toggleFavoritePhotoAction,
togglePrivatePhotoAction,
@ -17,6 +18,7 @@ import {
Photo,
deleteConfirmationTextForPhoto,
downloadFileNameForPhoto,
titleForPhoto,
} from '@/photo';
import { isPathFavs, isPhotoFav, TAG_PRIVATE } from '@/tag';
import { usePathname } from 'next/navigation';
@ -34,23 +36,32 @@ import { KEY_COMMANDS } from '@/photo/key-commands';
import { useAppText } from '@/i18n/state/client';
import IconLock from '@/components/icons/IconLock';
import IconTrash from '@/components/icons/IconTrash';
import IconUpload from '@/components/icons/IconUpload';
import { uploadPhotoFromClient } from '@/photo/storage';
import ImageInput from '@/components/ImageInput';
import { PRESERVE_ORIGINAL_UPLOADS } from '@/app/config';
export default function AdminPhotoMenu({
photo,
revalidatePhoto,
includeFavorite = true,
showKeyCommands,
alwaysVisible,
...props
}: Omit<ComponentProps<typeof MoreMenu>, 'sections'> & {
}: Omit<ComponentProps<typeof MoreMenu>, 'sections' | 'ariaLabel'> & {
photo: Photo
revalidatePhoto?: RevalidatePhoto
includeFavorite?: boolean
showKeyCommands?: boolean
alwaysVisible?: boolean
}) {
const { isUserSignedIn, registerAdminUpdate } = useAppState();
const appText = useAppText();
const inputRef = useRef<HTMLInputElement>(null);
const onUploadFinishRef = useRef<() => void>(null);
const path = usePathname();
const pathComponents = getPathComponents(path);
const isOnPhotoDetail = pathComponents.photoId === photo.id;
@ -68,7 +79,7 @@ export default function AdminPhotoMenu({
label: appText.admin.edit,
icon: <IconEdit
size={14}
className="translate-x-[0.5px] translate-y-[0.5px]"
className="translate-x-[1px] translate-y-[-0.5px]"
/>,
href: pathForAdminPhotoEdit(photo.id),
...showKeyCommands && { keyCommand: KEY_COMMANDS.edit },
@ -112,8 +123,8 @@ export default function AdminPhotoMenu({
items.push({
label: appText.admin.download,
icon: <MdOutlineFileDownload
size={17}
className="translate-x-[-1px]"
size={18}
className="translate-x-[-1.5px]"
/>,
href: photo.url,
hrefDownloadName: downloadFileNameForPhoto(photo),
@ -137,6 +148,17 @@ export default function AdminPhotoMenu({
.then(() => revalidatePhoto?.(photo.id)),
...showKeyCommands && { keyCommand: KEY_COMMANDS.sync },
});
items.push({
label: appText.admin.reUpload,
icon: <IconUpload
size={16}
className="translate-x-[-1px] translate-y-px"
/>,
action: () => new Promise(resolve => {
onUploadFinishRef.current = resolve;
inputRef.current?.click();
}),
});
return { items };
}, [
@ -189,11 +211,24 @@ export default function AdminPhotoMenu({
, [sectionMain, sectionDelete]);
return (
isUserSignedIn
? <MoreMenu {...{
...props,
sections,
}}/>
isUserSignedIn || alwaysVisible
? <>
<MoreMenu {...{
...props,
sections,
ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`,
}}/>
<ImageInput
ref={inputRef}
onBlobReady={async ({ blob, extension }) =>
uploadPhotoFromClient(blob, extension)
.then(updatedStorageUrl =>
replacePhotoStorageAction(photo.id, updatedStorageUrl))
.then(() => revalidatePhoto?.(photo.id))
.finally(onUploadFinishRef.current)}
shouldResize={!PRESERVE_ORIGINAL_UPLOADS}
/>
</>
: null
);
}

View File

@ -12,7 +12,6 @@ import EditButton from './EditButton';
import { useAppState } from '@/app/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import PhotoSyncButton from './PhotoSyncButton';
import DeletePhotoButton from './DeletePhotoButton';
import { Timezone } from '@/utility/timezone';
import { photoNeedsToBeUpdated } from '@/photo/update';
import PhotoVisibilityIcon from '@/photo/visibility/PhotoVisibilityIcon';
@ -20,6 +19,7 @@ import { doesPhotoHaveDefaultVisibility } from '@/photo/visibility';
import UpdateTooltip from '@/photo/update/UpdateTooltip';
import PhotoColors from '@/photo/color/PhotoColors';
import SyncColorButton from '@/photo/color/SyncColorButton';
import AdminPhotoMenu from './AdminPhotoMenu';
export default function AdminPhotosTable({
photos,
@ -142,11 +142,12 @@ export default function AdminPhotosTable({
/>
{debugColorData &&
<SyncColorButton photoId={photo.id} />}
{canDelete &&
<DeletePhotoButton
photo={photo}
onDelete={() => revalidatePhoto?.(photo.id, true)}
/>}
<AdminPhotoMenu
photo={photo}
revalidatePhoto={revalidatePhoto}
disabled={!canEdit || !canDelete}
alwaysVisible
/>
</div>
</Fragment>)}
</AdminTable>

View File

@ -30,6 +30,7 @@ export default function MoreMenu({
isOpen: isOpenProp,
setIsOpen: setIsOpenProp,
onOpen,
disabled,
...props
}: {
sections: MoreMenuSection[]
@ -42,6 +43,7 @@ export default function MoreMenu({
isOpen?: boolean
setIsOpen?: (isOpen: boolean) => void
onOpen?: () => void
disabled?: boolean
} & ComponentProps<typeof DropdownMenu.Content>){
const [isOpenInternal, setIsOpenInternal] = useState(isOpenProp ?? false);
@ -62,7 +64,7 @@ export default function MoreMenu({
open={isOpen}
onOpenChange={setIsOpen}
>
<DropdownMenu.Trigger asChild>
<DropdownMenu.Trigger asChild {...{ disabled }}>
<button
type="button"
className={clsx(

View File

@ -135,6 +135,7 @@ export const TEXT: I18N = {
public: 'সর্বজনীন করুন',
download: 'ডাউনলোড',
sync: 'সিঙ্ক',
reUpload: 'পুনরায় আপলোড করুন',
delete: 'ডিলিট',
deleteConfirm: 'আপনি কি "{{photoTitle}}" মুছে ফেলতে চান?',
},

View File

@ -135,6 +135,7 @@ export const TEXT: I18N = {
public: 'Make Public',
download: 'Download',
sync: 'Sync',
reUpload: 'Re-upload',
delete: 'Delete',
deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"',
},

View File

@ -134,6 +134,7 @@ export const TEXT = {
public: 'Make Public',
download: 'Download',
sync: 'Sync',
reUpload: 'Re-upload',
delete: 'Delete',
deleteConfirm: 'Are you sure you want to delete "{{photoTitle}}?"',
},

View File

@ -135,6 +135,7 @@ export const TEXT: I18N = {
public: 'सार्वजनिक बनाएं',
download: 'डाउनलोड करें',
sync: 'सिंक करें',
reUpload: 'पुनः अपलोड करें',
delete: 'हटाएं',
// eslint-disable-next-line max-len
deleteConfirm: 'क्या आप सुनिश्चित हैं कि "{{photoTitle}}" को हटाना चाहते हैं?',

View File

@ -135,6 +135,7 @@ export const TEXT: I18N = {
public: 'Buat Publik',
download: 'Unduh',
sync: 'Sinkronkan',
reUpload: 'Unggah ulang',
delete: 'Hapus',
deleteConfirm: 'Apakah Anda yakin ingin menghapus "{{photoTitle}}"?',
},

View File

@ -135,6 +135,7 @@ export const TEXT: I18N = {
public: 'Tornar Público',
download: 'Baixar',
sync: 'Sincronizar',
reUpload: 'Enviar novamente',
delete: 'Excluir',
deleteConfirm: 'Tem certeza de que deseja excluir "{{photoTitle}}"?',
},

View File

@ -135,6 +135,7 @@ export const TEXT: I18N = {
public: 'Tornar Público',
download: 'Descarregar',
sync: 'Sincronizar',
reUpload: 'Carregar novamente',
delete: 'Excluir',
deleteConfirm: 'Tens certeza de que deseja excluir "{{photoTitle}}"?',
},

View File

@ -135,6 +135,7 @@ export const TEXT: I18N = {
public: 'Herkese Açık Yap',
download: 'İndir',
sync: 'Senkronize Et',
reUpload: 'Yeniden Yükle',
delete: 'Sil',
// eslint-disable-next-line max-len
deleteConfirm: '"{{photoTitle}}" adlı fotoğrafı silmek istediğinize emin misiniz?',

View File

@ -135,6 +135,7 @@ export const TEXT: I18N = {
public: '设为公开',
download: '下载',
sync: '同步',
reUpload: '重新上传',
delete: '删除',
deleteConfirm: '确定要删除 "{{photoTitle}}" 吗?',
},

View File

@ -9,7 +9,6 @@ import {
shouldShowFilmDataForPhoto,
shouldShowLensDataForPhoto,
shouldShowRecipeDataForPhoto,
titleForPhoto,
} from '.';
import AppGrid from '@/components/AppGrid';
import ImageLarge from '@/components/image/ImageLarge';
@ -260,7 +259,6 @@ export default function PhotoLarge({
photo,
revalidatePhoto,
includeFavorite: includeFavoriteInAdminMenu,
ariaLabel: `Admin menu for '${titleForPhoto(photo)}' photo`,
showKeyCommands: showAdminKeyCommands,
}} />;

View File

@ -9,7 +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';
import { uploadTempPhotoFromClient } from './storage';
export default function PhotoUploadWithStatus({
inputRef,
@ -119,7 +119,7 @@ export default function PhotoUploadWithStatus({
},
});
} else {
return uploadPhotoFromClient(
return uploadTempPhotoFromClient(
blob,
extension,
)

View File

@ -22,7 +22,10 @@ import {
convertPhotoToFormData,
} from './form';
import { redirect } from 'next/navigation';
import { deleteFile } from '@/platforms/storage';
import {
deleteFile,
getFileNamePartsFromStorageUrl,
} from '@/platforms/storage';
import {
revalidateAdminPaths,
revalidateAllKeysAndPaths,
@ -58,7 +61,10 @@ import {
} from '@/app/config';
import { generateAiImageQueries } from './ai/server';
import { createStreamableValue } from '@ai-sdk/rsc';
import { convertUploadToPhoto } from './storage/server';
import {
convertUploadToPhoto,
storeOptimizedPhotosForUrl,
} from './storage/server';
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
import { convertStringToArray } from '@/utility/string';
import { after } from 'next/server';
@ -74,6 +80,7 @@ import {
upgradeTagToAlbum,
} from '@/album/server';
import { addPhotoAlbumIds } from '@/album/query';
import { getStorageUrlsForPhoto } from './storage';
// Private actions
@ -512,6 +519,29 @@ export const renamePhotoRecipeGloballyAction = async (formData: FormData) =>
}
});
export const replacePhotoStorageAction = async (
photoId: string,
updatedStorageUrl: string,
) =>
runAuthenticatedAdminServerAction(async () => {
const photo = await getPhoto(photoId, true);
if (photo) {
const {
fileExtension: extension,
} = getFileNamePartsFromStorageUrl(updatedStorageUrl);
await updatePhoto(convertPhotoToPhotoDbInsert({
...photo,
url: updatedStorageUrl,
extension,
}));
await storeOptimizedPhotosForUrl(updatedStorageUrl);
const existingStorageUrls = await getStorageUrlsForPhoto(photo)
.then(urls => urls.map(({ url }) => url));
await Promise.all(existingStorageUrls.map(deleteFile));
revalidatePhoto(photo.id);
}
});
export const deleteUploadsAction = async (urls: string[]) =>
runAuthenticatedAdminServerAction(async () => {
await Promise.all(urls.map(url => deleteFile(url)));

View File

@ -72,7 +72,7 @@ const THUMBNAIL_SIZE = 300;
export default function PhotoForm({
type = 'create',
initialPhotoForm,
photoStorageUrls,
photoStorageUrls = [],
updatedExifData,
updatedBlurData,
photoAlbumTitles = [],
@ -291,36 +291,40 @@ 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,
fileModifier,
} = getFileNamePartsFromStorageUrl(url);
return <div
key={url}
className="flex items-center gap-2"
>
<TbPhoto className="translate-y-[1px] text-medium" />
<Link
href={url}
target="_blank"
return photoStorageUrls.length === 0
? <span className="text-error">
No storage found for photo
</span>
: photoStorageUrls.length > 1
? <SmallDisclosure label="Optimized file set">
<div className="space-y-1">
{photoStorageUrls.map(({ url, size }) => {
const {
fileName,
fileModifier,
} = getFileNamePartsFromStorageUrl(url);
return <div
key={url}
className="flex items-center gap-2"
>
{fileName}
</Link>
<span className="text-dim">
{size}
{/* Show dimensions for original file when available */}
{!fileModifier && formData.width && formData.height &&
` @ ${formData.width}×${formData.height}`}
</span>
</div>;
})}
</div>
</SmallDisclosure>
: undefined;
<TbPhoto className="translate-y-[1px] text-medium" />
<Link
href={url}
target="_blank"
>
{fileName}
</Link>
<span className="text-dim">
{size}
{/* Show dimensions for original file when available */}
{!fileModifier && formData.width && formData.height &&
` @ ${formData.width}×${formData.height}`}
</span>
</div>;
})}
</div>
</SmallDisclosure>
: null;
}
};

View File

@ -71,9 +71,6 @@ export const getOptimizedUrlsFromPhotoUrl = (url: string) => {
`${urlBase}/${fileName}`);
};
export const isUploadPathnameValid = (pathname?: string) =>
pathname?.match(new RegExp(`(?:${PREFIX_UPLOAD})\.[a-z]{1,4}`, 'i'));
export const generateRandomFileNameForPhoto = () =>
generateFileNameWithId(PREFIX_PHOTO);
@ -83,12 +80,18 @@ export const getStorageUploadUrls = () =>
export const getStoragePhotoUrls = () =>
getStorageUrlsForPrefix(`${PREFIX_PHOTO}-`);
export const uploadPhotoFromClient = (
export const uploadTempPhotoFromClient = (
file: File | Blob,
extension = EXTENSION_DEFAULT,
) =>
uploadFileFromClient(file, PREFIX_UPLOAD, extension);
export const uploadPhotoFromClient = (
file: File | Blob,
extension = EXTENSION_DEFAULT,
) =>
uploadFileFromClient(file, PREFIX_PHOTO, extension);
const getSuffixFromNextImageSize = (nextSize: NextImageSize) =>
OPTIMIZED_FILE_SIZES.find(({ size }) => size === nextSize)?.suffix
?? OPTIMIZED_SUFFIX_DEFAULT;

View File

@ -11,10 +11,13 @@ import {
getOptimizedPhotoFileMeta,
} from '.';
export const storeOptimizedPhotos = async (
export const storeOptimizedPhotosForUrl = async (
url: string,
fileBytes: ArrayBuffer,
_fileBytes?: ArrayBuffer,
) => {
const fileBytes = _fileBytes
? _fileBytes
: await fetch(url).then(res => res.arrayBuffer());
const { fileNameBase } = getFileNamePartsFromStorageUrl(url);
const optimizedPhotoFileMeta = getOptimizedPhotoFileMeta(fileNameBase);
for (const { fileName, size, quality } of optimizedPhotoFileMeta) {
@ -55,7 +58,7 @@ export const convertUploadToPhoto = async ({
}
// Store optimized photos after original photo is copied/moved
const updatedUrl = await promise
.then(async url => storeOptimizedPhotos(url, fileBytes));
.then(async url => storeOptimizedPhotosForUrl(url, fileBytes));
return updatedUrl;
};

View File

@ -114,32 +114,33 @@ export const storageTypeFromUrl = (url: string): StorageType => {
export const uploadFromClientViaPresignedUrl = async (
file: File | Blob,
fileNameBase: string,
extension: string,
addRandomSuffix?: boolean,
fileName: string,
) => {
const key = addRandomSuffix
? `${fileNameBase}-${generateStorageId()}.${extension}`
: `${fileNameBase}.${extension}`;
const url = await fetch(`${PATH_API_PRESIGNED_URL}/${key}`)
const url = await fetch(`${PATH_API_PRESIGNED_URL}/${fileName}`)
.then((response) => response.text());
return fetch(url, { method: 'PUT', body: file })
.then(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${key}`);
.then(() => `${baseUrlForStorage(CURRENT_STORAGE)}/${fileName}`);
};
export const uploadFileFromClient = async (
file: File | Blob,
fileNameBase: string,
_fileName: string,
extension: string,
) => (
CURRENT_STORAGE === 'cloudflare-r2' ||
CURRENT_STORAGE === 'aws-s3' ||
CURRENT_STORAGE === 'minio'
)
? uploadFromClientViaPresignedUrl(file, fileNameBase, extension, true)
: vercelBlobUploadFromClient(file, `${fileNameBase}.${extension}`);
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,