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:
parent
f962074af9
commit
351f8869a6
@ -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');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unauthenticated upload');
|
||||
}
|
||||
|
||||
@ -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 {...{
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
<AdminPhotoMenu
|
||||
photo={photo}
|
||||
onDelete={() => revalidatePhoto?.(photo.id, true)}
|
||||
/>}
|
||||
revalidatePhoto={revalidatePhoto}
|
||||
disabled={!canEdit || !canDelete}
|
||||
alwaysVisible
|
||||
/>
|
||||
</div>
|
||||
</Fragment>)}
|
||||
</AdminTable>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
||||
public: 'সর্বজনীন করুন',
|
||||
download: 'ডাউনলোড',
|
||||
sync: 'সিঙ্ক',
|
||||
reUpload: 'পুনরায় আপলোড করুন',
|
||||
delete: 'ডিলিট',
|
||||
deleteConfirm: 'আপনি কি "{{photoTitle}}" মুছে ফেলতে চান?',
|
||||
},
|
||||
|
||||
@ -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}}?"',
|
||||
},
|
||||
|
||||
@ -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}}?"',
|
||||
},
|
||||
|
||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
||||
public: 'सार्वजनिक बनाएं',
|
||||
download: 'डाउनलोड करें',
|
||||
sync: 'सिंक करें',
|
||||
reUpload: 'पुनः अपलोड करें',
|
||||
delete: 'हटाएं',
|
||||
// eslint-disable-next-line max-len
|
||||
deleteConfirm: 'क्या आप सुनिश्चित हैं कि "{{photoTitle}}" को हटाना चाहते हैं?',
|
||||
|
||||
@ -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}}"?',
|
||||
},
|
||||
|
||||
@ -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}}"?',
|
||||
},
|
||||
|
||||
@ -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}}"?',
|
||||
},
|
||||
|
||||
@ -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?',
|
||||
|
||||
@ -135,6 +135,7 @@ export const TEXT: I18N = {
|
||||
public: '设为公开',
|
||||
download: '下载',
|
||||
sync: '同步',
|
||||
reUpload: '重新上传',
|
||||
delete: '删除',
|
||||
deleteConfirm: '确定要删除 "{{photoTitle}}" 吗?',
|
||||
},
|
||||
|
||||
@ -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,
|
||||
}} />;
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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)));
|
||||
|
||||
@ -72,7 +72,7 @@ const THUMBNAIL_SIZE = 300;
|
||||
export default function PhotoForm({
|
||||
type = 'create',
|
||||
initialPhotoForm,
|
||||
photoStorageUrls,
|
||||
photoStorageUrls = [],
|
||||
updatedExifData,
|
||||
updatedBlurData,
|
||||
photoAlbumTitles = [],
|
||||
@ -291,7 +291,11 @@ export default function PhotoForm({
|
||||
const footerForField = (key: keyof PhotoFormData) => {
|
||||
switch (key) {
|
||||
case 'url':
|
||||
return photoStorageUrls && photoStorageUrls.length > 1
|
||||
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 }) => {
|
||||
@ -320,7 +324,7 @@ export default function PhotoForm({
|
||||
})}
|
||||
</div>
|
||||
</SmallDisclosure>
|
||||
: undefined;
|
||||
: null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
) => (
|
||||
addRandomSuffix = true,
|
||||
) => {
|
||||
const fileName = addRandomSuffix
|
||||
? `${_fileName}-${generateStorageId()}.${extension}`
|
||||
: `${_fileName}.${extension}`;
|
||||
|
||||
return (
|
||||
CURRENT_STORAGE === 'cloudflare-r2' ||
|
||||
CURRENT_STORAGE === 'aws-s3' ||
|
||||
CURRENT_STORAGE === 'minio'
|
||||
)
|
||||
? uploadFromClientViaPresignedUrl(file, fileNameBase, extension, true)
|
||||
: vercelBlobUploadFromClient(file, `${fileNameBase}.${extension}`);
|
||||
)
|
||||
? uploadFromClientViaPresignedUrl(file, fileName)
|
||||
: vercelBlobUploadFromClient(file, fileName);
|
||||
};
|
||||
|
||||
export const putFile = (
|
||||
file: Buffer,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user