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:
parent
784c641174
commit
b8c01492b8
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
37
src/components/SmallDisclosure.tsx
Normal file
37
src/components/SmallDisclosure.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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
157
src/photo/storage/index.ts
Normal 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)),
|
||||
);
|
||||
};
|
||||
58
src/photo/storage/server.ts
Normal file
58
src/photo/storage/server.ts
Normal 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));
|
||||
};
|
||||
10
src/photo/update/server.ts
Normal file
10
src/photo/update/server.ts
Normal 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);
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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> => {
|
||||
|
||||
@ -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),
|
||||
})));
|
||||
|
||||
@ -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 = [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user