Merge pull request #107 from sambecker/exif-gps
Manipulate EXIF directly in source files
This commit is contained in:
commit
2082a269ad
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -18,6 +18,7 @@
|
||||
"favicons",
|
||||
"favs",
|
||||
"ghijklmnopqrstuv",
|
||||
"GPSH",
|
||||
"Hasselblad",
|
||||
"headlessui",
|
||||
"hgetall",
|
||||
|
||||
@ -103,7 +103,7 @@ Application behavior can be changed by configuring the following environment var
|
||||
- `NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES = 1` enables static optimization for OG images, i.e., renders images at build time (results in increased project usage)—⚠️ _Experimental_
|
||||
- `NEXT_PUBLIC_MATTE_PHOTOS = 1` constrains the size of each photo, and enables a surrounding border (potentially useful for photos with tall aspect ratios)
|
||||
- `NEXT_PUBLIC_BLUR_DISABLED = 1` prevents image blur data being stored and displayed (potentially useful for limiting Postgres usage)
|
||||
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data
|
||||
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information)
|
||||
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order
|
||||
- `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api`
|
||||
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo
|
||||
|
||||
@ -11,6 +11,7 @@ import { BiTrash } from 'react-icons/bi';
|
||||
import MoreMenu, { MoreMenuItem } from '@/components/more/MoreMenu';
|
||||
import { useAppState } from '@/state/AppState';
|
||||
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
|
||||
import { MdOutlineFileDownload } from 'react-icons/md';
|
||||
|
||||
export default function AdminPhotoMenuClient({
|
||||
photo,
|
||||
@ -29,7 +30,7 @@ export default function AdminPhotoMenuClient({
|
||||
const shouldRedirectFav = isPathFavs(path) && isFav;
|
||||
const shouldRedirectDelete = pathForPhoto({ photo: photo.id }) === path;
|
||||
|
||||
const favIconClass = 'translate-x-[-1.5px] translate-y-[0.5px]';
|
||||
const favIconClass = 'translate-x-[-1px] translate-y-[0.5px]';
|
||||
|
||||
const items = useMemo(() => {
|
||||
const items: MoreMenuItem[] = [{
|
||||
@ -55,11 +56,20 @@ export default function AdminPhotoMenuClient({
|
||||
).then(() => revalidatePhoto?.(photo.id)),
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
label: 'Download',
|
||||
icon: <MdOutlineFileDownload
|
||||
size={17}
|
||||
className="translate-x-[-1.5px] translate-y-[-0.5px]"
|
||||
/>,
|
||||
href: photo.url,
|
||||
hrefDownloadName: photo.url.split('/').pop(),
|
||||
});
|
||||
items.push({
|
||||
label: 'Delete',
|
||||
icon: <BiTrash
|
||||
size={15}
|
||||
className="translate-x-[-1.5px] "
|
||||
className="translate-x-[-1.5px]"
|
||||
/>,
|
||||
action: () => {
|
||||
if (confirm(deleteConfirmationTextForPhoto(photo))) {
|
||||
|
||||
@ -29,7 +29,7 @@ export default function PhotoSyncButton({
|
||||
if (photoTitle) { confirmText.push(`"${photoTitle}"`); }
|
||||
confirmText.push('data from original file?');
|
||||
if (hasAiTextGeneration) { confirmText.push(
|
||||
'This will also auto-generate AI text for undefined fields.'); }
|
||||
'AI text will be generated for undefined fields.'); }
|
||||
confirmText.push('This action cannot be undone.');
|
||||
return (
|
||||
<FormWithConfirm
|
||||
|
||||
@ -9,6 +9,8 @@ import {
|
||||
BLUR_ENABLED,
|
||||
} from '@/site/config';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
interface Params {
|
||||
params: { uploadPath: string }
|
||||
}
|
||||
@ -18,6 +20,7 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
|
||||
blobId,
|
||||
photoFormExif,
|
||||
imageResizedBase64: imageThumbnailBase64,
|
||||
shouldStripGpsData,
|
||||
} = await extractImageDataFromBlobPath(uploadPath, {
|
||||
includeInitialPhotoFields: true,
|
||||
generateBlurData: BLUR_ENABLED,
|
||||
@ -45,6 +48,7 @@ export default async function UploadPage({ params: { uploadPath } }: Params) {
|
||||
hasAiTextGeneration,
|
||||
textFieldsToAutoGenerate,
|
||||
imageThumbnailBase64,
|
||||
shouldStripGpsData,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -21,7 +21,7 @@ export default function ErrorNote({
|
||||
)}>
|
||||
<BiErrorAlt
|
||||
size={18}
|
||||
className="text-red-600/80 dark:text-red-500/70"
|
||||
className="text-red-600/80 dark:text-red-500/70 shrink-0"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,7 @@ export interface MoreMenuItem {
|
||||
label: ReactNode
|
||||
icon?: ReactNode
|
||||
href?: string
|
||||
hrefDownloadName?: string
|
||||
action?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
@ -51,12 +52,13 @@ export default function MoreMenu({
|
||||
'shadow-lg dark:shadow-xl',
|
||||
)}
|
||||
>
|
||||
{items.map(({ label, icon, href, action }) =>
|
||||
{items.map(({ label, icon, href, hrefDownloadName, action }) =>
|
||||
<MoreMenuItem
|
||||
key={`${label}`}
|
||||
label={label}
|
||||
icon={icon}
|
||||
href={href}
|
||||
hrefDownloadName={hrefDownloadName}
|
||||
action={action}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -10,11 +10,13 @@ export default function MoreMenuItem({
|
||||
label,
|
||||
icon,
|
||||
href,
|
||||
hrefDownloadName,
|
||||
action,
|
||||
}: {
|
||||
label: ReactNode
|
||||
icon?: ReactNode
|
||||
href?: string
|
||||
hrefDownloadName?: string
|
||||
action?: () => Promise<void> | void
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@ -39,7 +41,11 @@ export default function MoreMenuItem({
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
if (href) {
|
||||
startTransition(() => router.push(href));
|
||||
if (Boolean(hrefDownloadName)) {
|
||||
window.open(href, '_blank');
|
||||
} else {
|
||||
startTransition(() => router.push(href));
|
||||
}
|
||||
} else {
|
||||
const result = action?.();
|
||||
if (result instanceof Promise) {
|
||||
|
||||
@ -17,6 +17,7 @@ export default function UploadPageClient({
|
||||
hasAiTextGeneration,
|
||||
textFieldsToAutoGenerate,
|
||||
imageThumbnailBase64,
|
||||
shouldStripGpsData,
|
||||
}: {
|
||||
blobId?: string
|
||||
photoFormExif: Partial<PhotoFormData>
|
||||
@ -24,6 +25,7 @@ export default function UploadPageClient({
|
||||
hasAiTextGeneration?: boolean
|
||||
textFieldsToAutoGenerate?: AiAutoGeneratedField[],
|
||||
imageThumbnailBase64?: string
|
||||
shouldStripGpsData?: boolean
|
||||
}) {
|
||||
const {
|
||||
pending,
|
||||
@ -60,6 +62,7 @@ export default function UploadPageClient({
|
||||
initialPhotoForm={initialPhotoForm}
|
||||
uniqueTags={uniqueTags}
|
||||
aiContent={hasAiTextGeneration ? aiContent : undefined}
|
||||
shouldStripGpsData={shouldStripGpsData}
|
||||
onTitleChange={setUpdatedTitle}
|
||||
onTextContentChange={setHasTextContent}
|
||||
onFormStatusChange={setIsPending}
|
||||
|
||||
@ -16,10 +16,7 @@ import {
|
||||
convertPhotoToFormData,
|
||||
} from './form';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
convertUploadToPhoto,
|
||||
deleteStorageUrl,
|
||||
} from '@/services/storage';
|
||||
import { deleteFile } from '@/services/storage';
|
||||
import {
|
||||
getPhotosCached,
|
||||
getPhotosMetaCached,
|
||||
@ -49,14 +46,21 @@ import {
|
||||
import { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
|
||||
import { generateAiImageQueries } from './ai/server';
|
||||
import { createStreamableValue } from 'ai/rsc';
|
||||
import { convertUploadToPhoto } from './storage';
|
||||
|
||||
// Private actions
|
||||
|
||||
export const createPhotoAction = async (formData: FormData) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
const shouldStripGpsData = formData.get('shouldStripGpsData') === 'true';
|
||||
formData.delete('shouldStripGpsData');
|
||||
|
||||
const photo = convertFormDataToPhotoDbInsert(formData);
|
||||
|
||||
const updatedUrl = await convertUploadToPhoto(photo.url);
|
||||
const updatedUrl = await convertUploadToPhoto(
|
||||
photo.url,
|
||||
shouldStripGpsData,
|
||||
);
|
||||
|
||||
if (updatedUrl) {
|
||||
photo.url = updatedUrl;
|
||||
@ -103,6 +107,8 @@ export const addAllUploadsAction = async ({
|
||||
const {
|
||||
photoFormExif,
|
||||
imageResizedBase64,
|
||||
shouldStripGpsData,
|
||||
fileBytes,
|
||||
} = await extractImageDataFromBlobPath(url, {
|
||||
includeInitialPhotoFields: true,
|
||||
generateBlurData: BLUR_ENABLED,
|
||||
@ -144,7 +150,11 @@ export const addAllUploadsAction = async ({
|
||||
addedUploadUrls: addedUploadUrls.join(','),
|
||||
});
|
||||
|
||||
const updatedUrl = await convertUploadToPhoto(url);
|
||||
const updatedUrl = await convertUploadToPhoto(
|
||||
url,
|
||||
shouldStripGpsData,
|
||||
fileBytes,
|
||||
);
|
||||
if (updatedUrl) {
|
||||
stream.update({
|
||||
headline,
|
||||
@ -176,6 +186,7 @@ export const updatePhotoAction = async (formData: FormData) =>
|
||||
|
||||
let url: 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
|
||||
url = await convertUploadToPhoto(photo.url);
|
||||
@ -214,7 +225,7 @@ export const deletePhotoAction = async (
|
||||
shouldRedirect?: boolean,
|
||||
) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
await deletePhoto(photoId).then(() => deleteStorageUrl(photoUrl));
|
||||
await deletePhoto(photoId).then(() => deleteFile(photoUrl));
|
||||
revalidateAllKeysAndPaths();
|
||||
if (shouldRedirect) {
|
||||
redirect(PATH_ROOT);
|
||||
@ -254,7 +265,7 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) =>
|
||||
|
||||
export const deleteBlobPhotoAction = async (formData: FormData) =>
|
||||
runAuthenticatedAdminServerAction(async () => {
|
||||
await deleteStorageUrl(formData.get('url') as string);
|
||||
await deleteFile(formData.get('url') as string);
|
||||
|
||||
revalidateAdminPaths();
|
||||
|
||||
@ -282,6 +293,7 @@ export const getExifDataAction = async (
|
||||
// Accessed from admin photo table, will:
|
||||
// - update EXIF data
|
||||
// - anonymize storage url if necessary
|
||||
// - strip GPS data if necessary
|
||||
// - update blur data (or destroy if blur is disabled)
|
||||
// - generate AI text data, if enabled, and auto-generated fields are empty
|
||||
export const syncPhotoAction = async (formData: FormData) =>
|
||||
@ -293,6 +305,8 @@ export const syncPhotoAction = async (formData: FormData) =>
|
||||
const {
|
||||
photoFormExif,
|
||||
imageResizedBase64,
|
||||
shouldStripGpsData,
|
||||
fileBytes,
|
||||
} = await extractImageDataFromBlobPath(photo.url, {
|
||||
includeInitialPhotoFields: false,
|
||||
generateBlurData: BLUR_ENABLED,
|
||||
@ -300,10 +314,14 @@ export const syncPhotoAction = async (formData: FormData) =>
|
||||
});
|
||||
|
||||
if (photoFormExif) {
|
||||
if (photo.url.includes(photo.id)) {
|
||||
if (photo.url.includes(photo.id) || shouldStripGpsData) {
|
||||
// Anonymize storage url on update if necessary by
|
||||
// re-running image upload transfer logic
|
||||
const url = await convertUploadToPhoto(photo.url);
|
||||
const url = await convertUploadToPhoto(
|
||||
photo.url,
|
||||
shouldStripGpsData,
|
||||
fileBytes,
|
||||
);
|
||||
if (url) { photo.url = url; }
|
||||
}
|
||||
|
||||
|
||||
@ -40,6 +40,7 @@ export default function PhotoForm({
|
||||
updatedBlurData,
|
||||
uniqueTags,
|
||||
aiContent,
|
||||
shouldStripGpsData,
|
||||
onTitleChange,
|
||||
onTextContentChange,
|
||||
onFormStatusChange,
|
||||
@ -50,6 +51,7 @@ export default function PhotoForm({
|
||||
updatedBlurData?: string
|
||||
uniqueTags?: TagsWithMeta
|
||||
aiContent?: AiContent
|
||||
shouldStripGpsData?: boolean
|
||||
onTitleChange?: (updatedTitle: string) => void
|
||||
onTextContentChange?: (hasContent: boolean) => void,
|
||||
onFormStatusChange?: (pending: boolean) => void
|
||||
@ -353,6 +355,12 @@ export default function PhotoForm({
|
||||
type={type}
|
||||
accessory={accessoryForField(key)}
|
||||
/>)}
|
||||
<input
|
||||
type="hidden"
|
||||
name="shouldStripGpsData"
|
||||
value={shouldStripGpsData ? 'true' : 'false'}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className={clsx(
|
||||
|
||||
@ -11,6 +11,7 @@ import { ExifData, ExifParserFactory } from 'ts-exif-parser';
|
||||
import { PhotoFormData } from './form';
|
||||
import { FilmSimulation } from '@/simulation';
|
||||
import sharp, { Sharp } from 'sharp';
|
||||
import { GEO_PRIVACY_ENABLED, PRO_MODE_ENABLED } from '@/site/config';
|
||||
|
||||
const IMAGE_WIDTH_RESIZE = 200;
|
||||
const IMAGE_WIDTH_BLUR = 200;
|
||||
@ -26,6 +27,8 @@ export const extractImageDataFromBlobPath = async (
|
||||
blobId?: string
|
||||
photoFormExif?: Partial<PhotoFormData>
|
||||
imageResizedBase64?: string
|
||||
shouldStripGpsData?: boolean
|
||||
fileBytes?: ArrayBuffer
|
||||
}> => {
|
||||
const {
|
||||
includeInitialPhotoFields,
|
||||
@ -47,6 +50,7 @@ export const extractImageDataFromBlobPath = async (
|
||||
let filmSimulation: FilmSimulation | undefined;
|
||||
let blurData: string | undefined;
|
||||
let imageResizedBase64: string | undefined;
|
||||
let shouldStripGpsData = false;
|
||||
|
||||
if (fileBytes) {
|
||||
const parser = ExifParserFactory.create(Buffer.from(fileBytes));
|
||||
@ -74,6 +78,11 @@ export const extractImageDataFromBlobPath = async (
|
||||
if (generateResizedImage) {
|
||||
imageResizedBase64 = await resizeImage(fileBytes);
|
||||
}
|
||||
|
||||
shouldStripGpsData = GEO_PRIVACY_ENABLED && (
|
||||
Boolean(exifData.tags?.GPSLatitude) ||
|
||||
Boolean(exifData.tags?.GPSLongitude)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -91,6 +100,8 @@ export const extractImageDataFromBlobPath = async (
|
||||
},
|
||||
},
|
||||
imageResizedBase64,
|
||||
shouldStripGpsData,
|
||||
fileBytes,
|
||||
};
|
||||
};
|
||||
|
||||
@ -124,3 +135,30 @@ export const blurImageFromUrl = async (url: string) =>
|
||||
fetch(decodeURIComponent(url))
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(buffer => blurImage(buffer));
|
||||
|
||||
const GPS_NULL_STRING = '-';
|
||||
|
||||
export const removeGpsData = async (image: ArrayBuffer) =>
|
||||
sharp(image)
|
||||
.withExifMerge({
|
||||
IFD3: {
|
||||
GPSMapDatum: GPS_NULL_STRING,
|
||||
GPSLatitude: GPS_NULL_STRING,
|
||||
GPSLongitude: GPS_NULL_STRING,
|
||||
GPSDateStamp: GPS_NULL_STRING,
|
||||
GPSDateTime: GPS_NULL_STRING,
|
||||
GPSTimeStamp: GPS_NULL_STRING,
|
||||
GPSAltitude: GPS_NULL_STRING,
|
||||
GPSSatellites: GPS_NULL_STRING,
|
||||
GPSAreaInformation: GPS_NULL_STRING,
|
||||
GPSSpeed: GPS_NULL_STRING,
|
||||
GPSImgDirection: GPS_NULL_STRING,
|
||||
GPSDestLatitude: GPS_NULL_STRING,
|
||||
GPSDestLongitude: GPS_NULL_STRING,
|
||||
GPSDestBearing: GPS_NULL_STRING,
|
||||
GPSDestDistance: GPS_NULL_STRING,
|
||||
GPSHPositioningError: GPS_NULL_STRING,
|
||||
},
|
||||
})
|
||||
.toFormat('jpeg', { quality: PRO_MODE_ENABLED ? 95 : 80 })
|
||||
.toBuffer();
|
||||
|
||||
30
src/photo/storage.ts
Normal file
30
src/photo/storage.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import {
|
||||
deleteFile,
|
||||
generateRandomFileNameForPhoto,
|
||||
getExtensionFromStorageUrl,
|
||||
moveFile,
|
||||
putFile,
|
||||
} from '@/services/storage';
|
||||
import { removeGpsData } from './server';
|
||||
|
||||
export const convertUploadToPhoto = async (
|
||||
urlOrigin: string,
|
||||
stripGps?: boolean,
|
||||
fileBytes?: ArrayBuffer,
|
||||
) => {
|
||||
const fileName = generateRandomFileNameForPhoto();
|
||||
const fileExtension = getExtensionFromStorageUrl(urlOrigin);
|
||||
const photoPath = `${fileName}.${fileExtension || 'jpg'}`;
|
||||
if (stripGps) {
|
||||
const fileWithoutGps = await removeGpsData(
|
||||
fileBytes ?? await fetch(urlOrigin, { cache: 'no-store' })
|
||||
.then(res => res.arrayBuffer())
|
||||
);
|
||||
return putFile(fileWithoutGps, photoPath).then(async url => {
|
||||
if (url) { await deleteFile(urlOrigin); }
|
||||
return url;
|
||||
});
|
||||
} else {
|
||||
return moveFile(urlOrigin, photoPath);
|
||||
}
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { POSTGRES_SSL_ENABLED } from '@/site/config';
|
||||
import { Pool, QueryResult, QueryResultRow } from 'pg';
|
||||
import { Pool, QueryResult, QueryResultRow } from 'pg';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.POSTGRES_URL,
|
||||
|
||||
@ -32,6 +32,18 @@ export const isUrlFromAwsS3 = (url?: string) =>
|
||||
export const awsS3PutObjectCommandForKey = (Key: string) =>
|
||||
new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key, ACL: 'public-read' });
|
||||
|
||||
export const awsS3Put = async (
|
||||
file: Buffer,
|
||||
fileName: string,
|
||||
): Promise<string> =>
|
||||
awsS3Client().send(new PutObjectCommand({
|
||||
Bucket: AWS_S3_BUCKET,
|
||||
Key: fileName,
|
||||
Body: file,
|
||||
ACL: 'public-read',
|
||||
}))
|
||||
.then(() => urlForKey(fileName));
|
||||
|
||||
export const awsS3Copy = async (
|
||||
fileNameSource: string,
|
||||
fileNameDestination: string,
|
||||
|
||||
@ -53,6 +53,17 @@ export const isUrlFromCloudflareR2 = (url?: string) => (
|
||||
export const cloudflareR2PutObjectCommandForKey = (Key: string) =>
|
||||
new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key });
|
||||
|
||||
export const cloudflareR2Put = async (
|
||||
file: Buffer,
|
||||
fileName: string,
|
||||
): Promise<string> =>
|
||||
cloudflareR2Client().send(new PutObjectCommand({
|
||||
Bucket: CLOUDFLARE_R2_BUCKET,
|
||||
Key: fileName,
|
||||
Body: file,
|
||||
}))
|
||||
.then(() => urlForKey(fileName));
|
||||
|
||||
export const cloudflareR2Copy = async (
|
||||
fileNameSource: string,
|
||||
fileNameDestination: string,
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
vercelBlobCopy,
|
||||
vercelBlobDelete,
|
||||
vercelBlobList,
|
||||
vercelBlobPut,
|
||||
vercelBlobUploadFromClient,
|
||||
} from './vercel-blob';
|
||||
import {
|
||||
@ -10,6 +11,7 @@ import {
|
||||
awsS3Copy,
|
||||
awsS3Delete,
|
||||
awsS3List,
|
||||
awsS3Put,
|
||||
isUrlFromAwsS3,
|
||||
} from './aws-s3';
|
||||
import {
|
||||
@ -24,6 +26,7 @@ import {
|
||||
cloudflareR2Copy,
|
||||
cloudflareR2Delete,
|
||||
cloudflareR2List,
|
||||
cloudflareR2Put,
|
||||
isUrlFromCloudflareR2,
|
||||
} from './cloudflare-r2';
|
||||
import { PATH_API_PRESIGNED_URL } from '@/site/paths';
|
||||
@ -69,6 +72,9 @@ 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',
|
||||
@ -129,67 +135,47 @@ export const uploadPhotoFromClient = async (
|
||||
? uploadFromClientViaPresignedUrl(file, PREFIX_UPLOAD, extension, true)
|
||||
: vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`);
|
||||
|
||||
const moveFile = async (
|
||||
export const putFile = (
|
||||
file: Buffer,
|
||||
fileName: string,
|
||||
) => {
|
||||
switch (CURRENT_STORAGE) {
|
||||
case 'vercel-blob':
|
||||
return vercelBlobPut(file, fileName);
|
||||
case 'cloudflare-r2':
|
||||
return cloudflareR2Put(file, fileName);
|
||||
case 'aws-s3':
|
||||
return awsS3Put(file, fileName);
|
||||
}
|
||||
};
|
||||
|
||||
export const copyFile = (
|
||||
originUrl: string,
|
||||
destinationFileName: string,
|
||||
) => {
|
||||
const storageType = storageTypeFromUrl(originUrl);
|
||||
|
||||
let url: string | undefined;
|
||||
|
||||
// Copy file
|
||||
switch (storageType) {
|
||||
): Promise<string> => {
|
||||
switch (storageTypeFromUrl(originUrl)) {
|
||||
case 'vercel-blob':
|
||||
url = await vercelBlobCopy(
|
||||
return vercelBlobCopy(
|
||||
originUrl,
|
||||
destinationFileName,
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case 'cloudflare-r2':
|
||||
url = await cloudflareR2Copy(
|
||||
return cloudflareR2Copy(
|
||||
getFileNameFromStorageUrl(originUrl),
|
||||
destinationFileName,
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case 'aws-s3':
|
||||
url = await awsS3Copy(
|
||||
return awsS3Copy(
|
||||
originUrl,
|
||||
destinationFileName,
|
||||
false,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// If successful, delete original file
|
||||
if (url) {
|
||||
switch (storageType) {
|
||||
case 'vercel-blob':
|
||||
await vercelBlobDelete(originUrl);
|
||||
break;
|
||||
case 'cloudflare-r2':
|
||||
await cloudflareR2Delete(getFileNameFromStorageUrl(originUrl));
|
||||
break;
|
||||
case 'aws-s3':
|
||||
await awsS3Delete(getFileNameFromStorageUrl(originUrl));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export const convertUploadToPhoto = async (
|
||||
urlOrigin: string,
|
||||
) => {
|
||||
const fileName = `${PREFIX_PHOTO}-${generateStorageId()}`;
|
||||
const fileExtension = getExtensionFromStorageUrl(urlOrigin);
|
||||
const photoPath = `${fileName}.${fileExtension || 'jpg'}`;
|
||||
return moveFile(urlOrigin, photoPath);
|
||||
};
|
||||
|
||||
export const deleteStorageUrl = (url: string) => {
|
||||
export const deleteFile = (url: string) => {
|
||||
switch (storageTypeFromUrl(url)) {
|
||||
case 'vercel-blob':
|
||||
return vercelBlobDelete(url);
|
||||
@ -200,6 +186,16 @@ export const deleteStorageUrl = (url: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const moveFile = async (
|
||||
originUrl: string,
|
||||
destinationFileName: string,
|
||||
) => {
|
||||
const url = await copyFile(originUrl, destinationFileName);
|
||||
// If successful, delete original file
|
||||
if (url) { await deleteFile(originUrl); }
|
||||
return url;
|
||||
};
|
||||
|
||||
const getStorageUrlsForPrefix = async (prefix = '') => {
|
||||
const urls: StorageListResponse = [];
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { PATH_API_VERCEL_BLOB_UPLOAD } from '@/site/paths';
|
||||
import { copy, del, list } from '@vercel/blob';
|
||||
import { copy, del, list, put } from '@vercel/blob';
|
||||
import { upload } from '@vercel/blob/client';
|
||||
|
||||
const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match(
|
||||
@ -17,7 +17,7 @@ export const isUrlFromVercelBlob = (url?: string) =>
|
||||
export const vercelBlobUploadFromClient = async (
|
||||
file: File | Blob,
|
||||
fileName: string,
|
||||
) =>
|
||||
): Promise<string> =>
|
||||
upload(
|
||||
fileName,
|
||||
file,
|
||||
@ -28,6 +28,16 @@ export const vercelBlobUploadFromClient = async (
|
||||
)
|
||||
.then(({ url }) => url);
|
||||
|
||||
export const vercelBlobPut = (
|
||||
file: Buffer,
|
||||
fileName: string,
|
||||
): Promise<string> =>
|
||||
put(fileName, file, {
|
||||
addRandomSuffix: false,
|
||||
access: 'public',
|
||||
})
|
||||
.then(({ url }) => url);
|
||||
|
||||
export const vercelBlobCopy = (
|
||||
sourceUrl: string,
|
||||
destinationFileName: string,
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { AppStateContext } from './AppState';
|
||||
import { AnimationConfig } from '@/components/AnimateItems';
|
||||
import usePathnames from '@/utility/usePathnames';
|
||||
import { getAuthAction, logClientAuthUpdate } from '@/auth/actions';
|
||||
import { getAuthAction } from '@/auth/actions';
|
||||
import useSWR from 'swr';
|
||||
import { MATTE_PHOTOS } from '@/site/config';
|
||||
import { getPhotosHiddenMetaCachedAction } from '@/photo/actions';
|
||||
@ -47,7 +47,6 @@ export default function AppStateProvider({
|
||||
const { data } = useSWR('getAuth', getAuthAction);
|
||||
useEffect(() => {
|
||||
setUserEmail(data?.user?.email ?? undefined);
|
||||
logClientAuthUpdate(data);
|
||||
}, [data]);
|
||||
const isUserSignedIn = Boolean(userEmail);
|
||||
useEffect(() => {
|
||||
|
||||
@ -29,7 +29,6 @@
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user