diff --git a/.vscode/settings.json b/.vscode/settings.json index bb3ac392..057dda2f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "favicons", "favs", "ghijklmnopqrstuv", + "GPSH", "Hasselblad", "headlessui", "hgetall", diff --git a/README.md b/README.md index ba79b9c5..77d11edf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/admin/AdminPhotoMenuClient.tsx b/src/admin/AdminPhotoMenuClient.tsx index 99154499..9665a8e8 100644 --- a/src/admin/AdminPhotoMenuClient.tsx +++ b/src/admin/AdminPhotoMenuClient.tsx @@ -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: , + href: photo.url, + hrefDownloadName: photo.url.split('/').pop(), + }); items.push({ label: 'Delete', icon: , action: () => { if (confirm(deleteConfirmationTextForPhoto(photo))) { diff --git a/src/admin/PhotoSyncButton.tsx b/src/admin/PhotoSyncButton.tsx index 61a1eb31..2eda5877 100644 --- a/src/admin/PhotoSyncButton.tsx +++ b/src/admin/PhotoSyncButton.tsx @@ -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 ( ); }; diff --git a/src/components/ErrorNote.tsx b/src/components/ErrorNote.tsx index 4ac5f5dc..de1cc3e4 100644 --- a/src/components/ErrorNote.tsx +++ b/src/components/ErrorNote.tsx @@ -21,7 +21,7 @@ export default function ErrorNote({ )}> {children} diff --git a/src/components/more/MoreMenu.tsx b/src/components/more/MoreMenu.tsx index 39188b3e..4c646b43 100644 --- a/src/components/more/MoreMenu.tsx +++ b/src/components/more/MoreMenu.tsx @@ -8,6 +8,7 @@ export interface MoreMenuItem { label: ReactNode icon?: ReactNode href?: string + hrefDownloadName?: string action?: () => Promise | 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 }) => )} diff --git a/src/components/more/MoreMenuItem.tsx b/src/components/more/MoreMenuItem.tsx index 6fd4cf87..213f1601 100644 --- a/src/components/more/MoreMenuItem.tsx +++ b/src/components/more/MoreMenuItem.tsx @@ -10,11 +10,13 @@ export default function MoreMenuItem({ label, icon, href, + hrefDownloadName, action, }: { label: ReactNode icon?: ReactNode href?: string + hrefDownloadName?: string action?: () => Promise | 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) { diff --git a/src/photo/UploadPageClient.tsx b/src/photo/UploadPageClient.tsx index cbd106f1..ad579dd7 100644 --- a/src/photo/UploadPageClient.tsx +++ b/src/photo/UploadPageClient.tsx @@ -17,6 +17,7 @@ export default function UploadPageClient({ hasAiTextGeneration, textFieldsToAutoGenerate, imageThumbnailBase64, + shouldStripGpsData, }: { blobId?: string photoFormExif: Partial @@ -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} diff --git a/src/photo/actions.ts b/src/photo/actions.ts index e3b0ac4b..3311aa61 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -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; } } diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 8fdd37d9..26333b01 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -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)} />)} + {/* Actions */}
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(); diff --git a/src/photo/storage.ts b/src/photo/storage.ts new file mode 100644 index 00000000..b5396615 --- /dev/null +++ b/src/photo/storage.ts @@ -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); + } +}; diff --git a/src/services/postgres.ts b/src/services/postgres.ts index c6442e92..d1169d94 100644 --- a/src/services/postgres.ts +++ b/src/services/postgres.ts @@ -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, diff --git a/src/services/storage/aws-s3.ts b/src/services/storage/aws-s3.ts index ff93d725..9e7e2fa1 100644 --- a/src/services/storage/aws-s3.ts +++ b/src/services/storage/aws-s3.ts @@ -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 => + 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, diff --git a/src/services/storage/cloudflare-r2.ts b/src/services/storage/cloudflare-r2.ts index 9929b766..12a45572 100644 --- a/src/services/storage/cloudflare-r2.ts +++ b/src/services/storage/cloudflare-r2.ts @@ -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 => + cloudflareR2Client().send(new PutObjectCommand({ + Bucket: CLOUDFLARE_R2_BUCKET, + Key: fileName, + Body: file, + })) + .then(() => urlForKey(fileName)); + export const cloudflareR2Copy = async ( fileNameSource: string, fileNameDestination: string, diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 5e575ed9..5303f603 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -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 => { + 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 = []; diff --git a/src/services/storage/vercel-blob.ts b/src/services/storage/vercel-blob.ts index 293ab98a..60cfdd22 100644 --- a/src/services/storage/vercel-blob.ts +++ b/src/services/storage/vercel-blob.ts @@ -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 => upload( fileName, file, @@ -28,6 +28,16 @@ export const vercelBlobUploadFromClient = async ( ) .then(({ url }) => url); +export const vercelBlobPut = ( + file: Buffer, + fileName: string, +): Promise => + put(fileName, file, { + addRandomSuffix: false, + access: 'public', + }) + .then(({ url }) => url); + export const vercelBlobCopy = ( sourceUrl: string, destinationFileName: string, diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index c9b4c902..135972d6 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -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(() => { diff --git a/tsconfig.json b/tsconfig.json index f48e7ee6..0bc1366b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,6 @@ "target": "ES2017" }, "include": [ - "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"