From 1975e7f940b2a1ccda0496e53eaae6e608f41126 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 6 Jun 2024 22:44:43 -0500 Subject: [PATCH 01/20] Add function for stripping GPS data from original files --- .vscode/settings.json | 2 ++ package.json | 1 + pnpm-lock.yaml | 56 ++++++++++++++++++++++++------------------- src/types/piexif.d.ts | 6 +++++ src/utility/exif.ts | 19 +++++++++++++++ tsconfig.json | 4 +--- 6 files changed, 61 insertions(+), 27 deletions(-) create mode 100644 src/types/piexif.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index bb3ac392..920390c5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,6 +30,8 @@ "nanoids", "nextjs", "parameterizes", + "piexif", + "piexifjs", "presigner", "Provia", "qaub", diff --git a/package.json b/package.json index 7e48c9d3..98906589 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "next-auth": "5.0.0-beta.18", "next-themes": "^0.3.0", "pg": "^8.12.0", + "piexifjs": "^1.0.6", "postcss": "8.4.38", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96d53ed6..f07c44e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: pg: specifier: ^8.12.0 version: 8.12.0 + piexifjs: + specifier: ^1.0.6 + version: 1.0.6 postcss: specifier: 8.4.38 version: 8.4.38 @@ -3447,6 +3450,9 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + piexifjs@1.0.6: + resolution: {integrity: sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag==} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -4397,10 +4403,10 @@ snapshots: '@aws-crypto/sha1-browser': 3.0.0 '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.590.0(@aws-sdk/client-sts@3.590.0) - '@aws-sdk/client-sts': 3.590.0 + '@aws-sdk/client-sso-oidc': 3.590.0 + '@aws-sdk/client-sts': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) '@aws-sdk/core': 3.588.0 - '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0) + '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) '@aws-sdk/middleware-bucket-endpoint': 3.587.0 '@aws-sdk/middleware-expect-continue': 3.577.0 '@aws-sdk/middleware-flexible-checksums': 3.587.0 @@ -4455,13 +4461,13 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0)': + '@aws-sdk/client-sso-oidc@3.590.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.590.0 + '@aws-sdk/client-sts': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) '@aws-sdk/core': 3.588.0 - '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0) + '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) '@aws-sdk/middleware-host-header': 3.577.0 '@aws-sdk/middleware-logger': 3.577.0 '@aws-sdk/middleware-recursion-detection': 3.577.0 @@ -4498,7 +4504,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.6.2 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso@3.590.0': @@ -4544,13 +4549,13 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.590.0': + '@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.590.0(@aws-sdk/client-sts@3.590.0) + '@aws-sdk/client-sso-oidc': 3.590.0 '@aws-sdk/core': 3.588.0 - '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0) + '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) '@aws-sdk/middleware-host-header': 3.577.0 '@aws-sdk/middleware-logger': 3.577.0 '@aws-sdk/middleware-recursion-detection': 3.577.0 @@ -4587,6 +4592,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.6.2 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/core@3.588.0': @@ -4618,14 +4624,14 @@ snapshots: '@smithy/util-stream': 3.0.1 tslib: 2.6.2 - '@aws-sdk/credential-provider-ini@3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0)': + '@aws-sdk/credential-provider-ini@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0))': dependencies: - '@aws-sdk/client-sts': 3.590.0 + '@aws-sdk/client-sts': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.587.0 '@aws-sdk/credential-provider-process': 3.587.0 - '@aws-sdk/credential-provider-sso': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0)) - '@aws-sdk/credential-provider-web-identity': 3.587.0(@aws-sdk/client-sts@3.590.0) + '@aws-sdk/credential-provider-sso': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) + '@aws-sdk/credential-provider-web-identity': 3.587.0(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) '@aws-sdk/types': 3.577.0 '@smithy/credential-provider-imds': 3.1.0 '@smithy/property-provider': 3.1.0 @@ -4636,14 +4642,14 @@ snapshots: - '@aws-sdk/client-sso-oidc' - aws-crt - '@aws-sdk/credential-provider-node@3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0)': + '@aws-sdk/credential-provider-node@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0))': dependencies: '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.587.0 - '@aws-sdk/credential-provider-ini': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0) + '@aws-sdk/credential-provider-ini': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) '@aws-sdk/credential-provider-process': 3.587.0 - '@aws-sdk/credential-provider-sso': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0)) - '@aws-sdk/credential-provider-web-identity': 3.587.0(@aws-sdk/client-sts@3.590.0) + '@aws-sdk/credential-provider-sso': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) + '@aws-sdk/credential-provider-web-identity': 3.587.0(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) '@aws-sdk/types': 3.577.0 '@smithy/credential-provider-imds': 3.1.0 '@smithy/property-provider': 3.1.0 @@ -4663,10 +4669,10 @@ snapshots: '@smithy/types': 3.0.0 tslib: 2.6.2 - '@aws-sdk/credential-provider-sso@3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))': + '@aws-sdk/credential-provider-sso@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)': dependencies: '@aws-sdk/client-sso': 3.590.0 - '@aws-sdk/token-providers': 3.587.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0)) + '@aws-sdk/token-providers': 3.587.0(@aws-sdk/client-sso-oidc@3.590.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.0 '@smithy/shared-ini-file-loader': 3.1.0 @@ -4676,9 +4682,9 @@ snapshots: - '@aws-sdk/client-sso-oidc' - aws-crt - '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.590.0)': + '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0))': dependencies: - '@aws-sdk/client-sts': 3.590.0 + '@aws-sdk/client-sts': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.0 '@smithy/types': 3.0.0 @@ -4803,9 +4809,9 @@ snapshots: '@smithy/types': 3.0.0 tslib: 2.6.2 - '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))': + '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.590.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.590.0(@aws-sdk/client-sts@3.590.0) + '@aws-sdk/client-sso-oidc': 3.590.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.0 '@smithy/shared-ini-file-loader': 3.1.0 @@ -8544,6 +8550,8 @@ snapshots: picomatch@2.3.1: {} + piexifjs@1.0.6: {} + pify@2.3.0: {} pirates@4.0.6: {} diff --git a/src/types/piexif.d.ts b/src/types/piexif.d.ts new file mode 100644 index 00000000..10ad2c63 --- /dev/null +++ b/src/types/piexif.d.ts @@ -0,0 +1,6 @@ +declare module 'piexifjs' { + export function load(base64Url: string): Record + export function dump(exifObject: Record): string + export function insert(exifDataWithoutGps: string, base64Url: string): string + export function remove(exifData: string): string +} diff --git a/src/utility/exif.ts b/src/utility/exif.ts index 8760cfe5..0730ddc5 100644 --- a/src/utility/exif.ts +++ b/src/utility/exif.ts @@ -1,5 +1,6 @@ import { OrientationTypes, type ExifData } from 'ts-exif-parser'; import { formatNumberToFraction, roundToString } from './number'; +import * as PiExif from 'piexifjs'; const OFFSET_REGEX = /[+-]\d\d:\d\d/; @@ -56,3 +57,21 @@ export const formatExposureCompensation = (exposureCompensation?: number) => { return undefined; } }; + +export const removeGpsFromFile = async ( + fileBytes: ArrayBuffer +): Promise => { + const base64 = Buffer.from(fileBytes).toString('base64'); + const base64Url = `data:image/jpeg;base64,${base64}`; + + const exifObject = PiExif.load(base64Url) as Record; + delete exifObject.GPS; + const exifDataWithoutGps = PiExif.dump(exifObject); + + const data = PiExif.insert( + exifDataWithoutGps, + base64Url, + ); + + return fetch(data, { cache: 'no-store' }).then(res => res.blob()); +}; diff --git a/tsconfig.json b/tsconfig.json index f48e7ee6..72d3c462 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,10 +29,8 @@ "target": "ES2017" }, "include": [ - "next-env.d.ts", "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts" + "**/*.tsx" ], "exclude": [ "node_modules" From 67c392bf621d62771f99d9fd633936876abe10ca Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Thu, 6 Jun 2024 22:50:32 -0500 Subject: [PATCH 02/20] Move piexif import to server file for code splitting --- src/utility/exif-server.ts | 19 +++++++++++++++++++ src/utility/exif.ts | 19 ------------------- 2 files changed, 19 insertions(+), 19 deletions(-) create mode 100644 src/utility/exif-server.ts diff --git a/src/utility/exif-server.ts b/src/utility/exif-server.ts new file mode 100644 index 00000000..668919b5 --- /dev/null +++ b/src/utility/exif-server.ts @@ -0,0 +1,19 @@ +import * as PiExif from 'piexifjs'; + +export const removeGpsFromFile = async ( + fileBytes: ArrayBuffer +): Promise => { + const base64 = Buffer.from(fileBytes).toString('base64'); + const base64Url = `data:image/jpeg;base64,${base64}`; + + const exifObject = PiExif.load(base64Url) as Record; + delete exifObject.GPS; + const exifDataWithoutGps = PiExif.dump(exifObject); + + const data = PiExif.insert( + exifDataWithoutGps, + base64Url, + ); + + return fetch(data, { cache: 'no-store' }).then(res => res.blob()); +}; diff --git a/src/utility/exif.ts b/src/utility/exif.ts index 0730ddc5..8760cfe5 100644 --- a/src/utility/exif.ts +++ b/src/utility/exif.ts @@ -1,6 +1,5 @@ import { OrientationTypes, type ExifData } from 'ts-exif-parser'; import { formatNumberToFraction, roundToString } from './number'; -import * as PiExif from 'piexifjs'; const OFFSET_REGEX = /[+-]\d\d:\d\d/; @@ -57,21 +56,3 @@ export const formatExposureCompensation = (exposureCompensation?: number) => { return undefined; } }; - -export const removeGpsFromFile = async ( - fileBytes: ArrayBuffer -): Promise => { - const base64 = Buffer.from(fileBytes).toString('base64'); - const base64Url = `data:image/jpeg;base64,${base64}`; - - const exifObject = PiExif.load(base64Url) as Record; - delete exifObject.GPS; - const exifDataWithoutGps = PiExif.dump(exifObject); - - const data = PiExif.insert( - exifDataWithoutGps, - base64Url, - ); - - return fetch(data, { cache: 'no-store' }).then(res => res.blob()); -}; From 11362450f10652db3733c294bfb02b5461a4d1c3 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 00:24:52 -0500 Subject: [PATCH 03/20] Strip GPS data when uploading/syncing photos --- src/admin/PhotoSyncButton.tsx | 2 +- src/app/admin/uploads/[uploadPath]/page.tsx | 2 + src/photo/UploadPageClient.tsx | 3 + src/photo/actions.ts | 30 +++++--- src/photo/form/PhotoForm.tsx | 7 ++ src/photo/server.ts | 9 +++ src/photo/storage.ts | 28 ++++++++ src/services/storage/aws-s3.ts | 12 ++++ src/services/storage/cloudflare-r2.ts | 11 +++ src/services/storage/index.ts | 78 ++++++++++----------- src/services/storage/vercel-blob.ts | 14 +++- src/utility/exif-server.ts | 2 +- tsconfig.json | 3 +- 13 files changed, 145 insertions(+), 56 deletions(-) create mode 100644 src/photo/storage.ts 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/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..3878db82 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,6 +46,7 @@ import { import { getStorageUploadUrlsNoStore } from '@/services/storage/cache'; import { generateAiImageQueries } from './ai/server'; import { createStreamableValue } from 'ai/rsc'; +import { convertUploadToPhoto } from './storage'; // Private actions @@ -56,7 +54,10 @@ export const createPhotoAction = async (formData: FormData) => runAuthenticatedAdminServerAction(async () => { const photo = convertFormDataToPhotoDbInsert(formData); - const updatedUrl = await convertUploadToPhoto(photo.url); + const updatedUrl = await convertUploadToPhoto( + photo.url, + formData.get('shouldStripGpsData') === 'true', + ); if (updatedUrl) { photo.url = updatedUrl; @@ -103,6 +104,7 @@ export const addAllUploadsAction = async ({ const { photoFormExif, imageResizedBase64, + shouldStripGpsData, } = await extractImageDataFromBlobPath(url, { includeInitialPhotoFields: true, generateBlurData: BLUR_ENABLED, @@ -144,7 +146,10 @@ export const addAllUploadsAction = async ({ addedUploadUrls: addedUploadUrls.join(','), }); - const updatedUrl = await convertUploadToPhoto(url); + const updatedUrl = await convertUploadToPhoto( + url, + shouldStripGpsData, + ); if (updatedUrl) { stream.update({ headline, @@ -214,7 +219,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 +259,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 +287,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 +299,7 @@ export const syncPhotoAction = async (formData: FormData) => const { photoFormExif, imageResizedBase64, + shouldStripGpsData, } = await extractImageDataFromBlobPath(photo.url, { includeInitialPhotoFields: false, generateBlurData: BLUR_ENABLED, @@ -300,10 +307,13 @@ 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, + ); if (url) { photo.url = url; } } diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index 8fdd37d9..867e9dfd 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,11 @@ export default function PhotoForm({ type={type} accessory={accessoryForField(key)} />)} + {/* Actions */}
imageResizedBase64?: string + shouldStripGpsData?: boolean }> => { const { includeInitialPhotoFields, @@ -47,6 +49,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 +77,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 +99,7 @@ export const extractImageDataFromBlobPath = async ( }, }, imageResizedBase64, + shouldStripGpsData, }; }; diff --git a/src/photo/storage.ts b/src/photo/storage.ts new file mode 100644 index 00000000..ab3c3320 --- /dev/null +++ b/src/photo/storage.ts @@ -0,0 +1,28 @@ +import { + deleteFile, + generateRandomFileNameForPhoto, + getExtensionFromStorageUrl, + moveFile, + putFile, +} from '@/services/storage'; +import { stripGpsFromFile } from '@/utility/exif-server'; + +export const convertUploadToPhoto = async ( + urlOrigin: string, + stripGps?: boolean, +) => { + const fileName = generateRandomFileNameForPhoto(); + const fileExtension = getExtensionFromStorageUrl(urlOrigin); + const photoPath = `${fileName}.${fileExtension || 'jpg'}`; + if (stripGps) { + const fileBytes = await fetch(urlOrigin, { cache: 'no-store' }) + .then(res => res.arrayBuffer()); + const fileWithoutGps = await stripGpsFromFile(fileBytes); + return putFile(fileWithoutGps, photoPath).then(async url => { + if (url) { await deleteFile(urlOrigin); } + return url; + }); + } else { + return moveFile(urlOrigin, photoPath); + } +}; diff --git a/src/services/storage/aws-s3.ts b/src/services/storage/aws-s3.ts index ff93d725..1c7d3218 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 = ( + file: File | Blob, + 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..d556446c 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 = ( + file: File | Blob, + 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..e8c57e30 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: File | Blob, + 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..8d6cc26c 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: File | Blob, + fileName: string, +): Promise => + put(fileName, file, { + addRandomSuffix: false, + access: 'public', + }) + .then(({ url }) => url); + export const vercelBlobCopy = ( sourceUrl: string, destinationFileName: string, diff --git a/src/utility/exif-server.ts b/src/utility/exif-server.ts index 668919b5..93c06f8e 100644 --- a/src/utility/exif-server.ts +++ b/src/utility/exif-server.ts @@ -1,6 +1,6 @@ import * as PiExif from 'piexifjs'; -export const removeGpsFromFile = async ( +export const stripGpsFromFile = async ( fileBytes: ArrayBuffer ): Promise => { const base64 = Buffer.from(fileBytes).toString('base64'); diff --git a/tsconfig.json b/tsconfig.json index 72d3c462..0bc1366b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,7 +30,8 @@ }, "include": [ "**/*.ts", - "**/*.tsx" + "**/*.tsx", + ".next/types/**/*.ts" ], "exclude": [ "node_modules" From 689893d54a0f377987b8250a433f54377088626f Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 00:25:39 -0500 Subject: [PATCH 04/20] Stop logging client auth state --- src/state/AppStateProvider.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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(() => { From ad8b2280b2cb80cb0c9af8c8674d991154936645 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 00:44:01 -0500 Subject: [PATCH 05/20] Increase max duration on [uploadPath] --- src/app/admin/uploads/[uploadPath]/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/admin/uploads/[uploadPath]/page.tsx b/src/app/admin/uploads/[uploadPath]/page.tsx index fdab6e94..ab980d86 100644 --- a/src/app/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/admin/uploads/[uploadPath]/page.tsx @@ -9,6 +9,8 @@ import { BLUR_ENABLED, } from '@/site/config'; +export const maxDuration = 60; + interface Params { params: { uploadPath: string } } From d04404582bf0c18294393ca73763da400bcb14b0 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 00:45:09 -0500 Subject: [PATCH 06/20] Remove piexifjs annotation --- src/utility/exif-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utility/exif-server.ts b/src/utility/exif-server.ts index 93c06f8e..8009f068 100644 --- a/src/utility/exif-server.ts +++ b/src/utility/exif-server.ts @@ -6,7 +6,7 @@ export const stripGpsFromFile = async ( const base64 = Buffer.from(fileBytes).toString('base64'); const base64Url = `data:image/jpeg;base64,${base64}`; - const exifObject = PiExif.load(base64Url) as Record; + const exifObject = PiExif.load(base64Url); delete exifObject.GPS; const exifDataWithoutGps = PiExif.dump(exifObject); From fb452f24736228dc32ce0b9e251f0560ab3570aa Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 10:19:25 -0500 Subject: [PATCH 07/20] Switch file/blob argument types --- src/services/storage/aws-s3.ts | 4 ++-- src/services/storage/cloudflare-r2.ts | 4 ++-- src/services/storage/index.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/services/storage/aws-s3.ts b/src/services/storage/aws-s3.ts index 1c7d3218..d8b0b734 100644 --- a/src/services/storage/aws-s3.ts +++ b/src/services/storage/aws-s3.ts @@ -33,13 +33,13 @@ export const awsS3PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key, ACL: 'public-read' }); export const awsS3Put = ( - file: File | Blob, + file: Blob, fileName: string, ): Promise => awsS3Client().send(new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key: fileName, - Body: file, + Body: new File([file], fileName), ACL: 'public-read', })) .then(() => urlForKey(fileName)); diff --git a/src/services/storage/cloudflare-r2.ts b/src/services/storage/cloudflare-r2.ts index d556446c..3a3b3a2a 100644 --- a/src/services/storage/cloudflare-r2.ts +++ b/src/services/storage/cloudflare-r2.ts @@ -54,13 +54,13 @@ export const cloudflareR2PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key }); export const cloudflareR2Put = ( - file: File | Blob, + file: Blob, fileName: string, ): Promise => cloudflareR2Client().send(new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key: fileName, - Body: file, + Body: new File([file], fileName), })) .then(() => urlForKey(fileName)); diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index e8c57e30..09f5bfea 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -136,7 +136,7 @@ export const uploadPhotoFromClient = async ( : vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`); export const putFile = ( - file: File | Blob, + file: Blob, fileName: string, ) => { switch (CURRENT_STORAGE) { From ed0d485dfe20e913141e41d750673dc8108ff9e0 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 10:26:44 -0500 Subject: [PATCH 08/20] Update aws-compatible put signatures --- src/services/storage/aws-s3.ts | 4 ++-- src/services/storage/cloudflare-r2.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/storage/aws-s3.ts b/src/services/storage/aws-s3.ts index d8b0b734..aa074221 100644 --- a/src/services/storage/aws-s3.ts +++ b/src/services/storage/aws-s3.ts @@ -32,14 +32,14 @@ export const isUrlFromAwsS3 = (url?: string) => export const awsS3PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key, ACL: 'public-read' }); -export const awsS3Put = ( +export const awsS3Put = async ( file: Blob, fileName: string, ): Promise => awsS3Client().send(new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key: fileName, - Body: new File([file], fileName), + Body: Buffer.from(await file.arrayBuffer()), ACL: 'public-read', })) .then(() => urlForKey(fileName)); diff --git a/src/services/storage/cloudflare-r2.ts b/src/services/storage/cloudflare-r2.ts index 3a3b3a2a..f1ae31f5 100644 --- a/src/services/storage/cloudflare-r2.ts +++ b/src/services/storage/cloudflare-r2.ts @@ -53,14 +53,14 @@ export const isUrlFromCloudflareR2 = (url?: string) => ( export const cloudflareR2PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key }); -export const cloudflareR2Put = ( +export const cloudflareR2Put = async ( file: Blob, fileName: string, ): Promise => cloudflareR2Client().send(new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key: fileName, - Body: new File([file], fileName), + Body: Buffer.from(await file.arrayBuffer()), })) .then(() => urlForKey(fileName)); From a203972bfb3fb9b7b202544c0e32d94a63ab5f7f Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 10:28:26 -0500 Subject: [PATCH 09/20] Fix error note icon shrinking --- src/components/ErrorNote.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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}
From b492cf3ea2cd45b68af3c2c2f026b9116e6001a9 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 11:22:35 -0500 Subject: [PATCH 10/20] Remove shouldStripGpsData from formData --- src/photo/actions.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 3878db82..b6673a2e 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -52,11 +52,14 @@ import { convertUploadToPhoto } from './storage'; 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, - formData.get('shouldStripGpsData') === 'true', + shouldStripGpsData, ); if (updatedUrl) { From f675cc4feed45fd0e3e6726e4548c38d3669da50 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 11:38:05 -0500 Subject: [PATCH 11/20] Add GPS EXIF logging --- src/photo/storage.ts | 9 ++++++++- src/utility/exif-server.ts | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/photo/storage.ts b/src/photo/storage.ts index ab3c3320..908cd168 100644 --- a/src/photo/storage.ts +++ b/src/photo/storage.ts @@ -15,11 +15,18 @@ export const convertUploadToPhoto = async ( const fileExtension = getExtensionFromStorageUrl(urlOrigin); const photoPath = `${fileName}.${fileExtension || 'jpg'}`; if (stripGps) { + console.log('Fetching original file'); const fileBytes = await fetch(urlOrigin, { cache: 'no-store' }) .then(res => res.arrayBuffer()); const fileWithoutGps = await stripGpsFromFile(fileBytes); + console.log('Uploading file without GPS'); return putFile(fileWithoutGps, photoPath).then(async url => { - if (url) { await deleteFile(urlOrigin); } + if (url) { + console.log('Deleting original file'); + await deleteFile(urlOrigin); + } else { + console.log('No url found'); + } return url; }); } else { diff --git a/src/utility/exif-server.ts b/src/utility/exif-server.ts index 8009f068..9c800d62 100644 --- a/src/utility/exif-server.ts +++ b/src/utility/exif-server.ts @@ -6,10 +6,12 @@ export const stripGpsFromFile = async ( const base64 = Buffer.from(fileBytes).toString('base64'); const base64Url = `data:image/jpeg;base64,${base64}`; + console.log('Stripping GPS from file'); const exifObject = PiExif.load(base64Url); delete exifObject.GPS; const exifDataWithoutGps = PiExif.dump(exifObject); + console.log('Updating EXIF'); const data = PiExif.insert( exifDataWithoutGps, base64Url, From f74ef1918013e5640fa93d168e2e00ca2ea8b55d Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 11:51:36 -0500 Subject: [PATCH 12/20] Use custom base 64 <> blob function --- src/photo/storage.ts | 2 +- src/utility/data.ts | 14 ++++++++++++++ src/utility/exif-server.ts | 9 ++++++--- 3 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 src/utility/data.ts diff --git a/src/photo/storage.ts b/src/photo/storage.ts index 908cd168..c2ec1d8d 100644 --- a/src/photo/storage.ts +++ b/src/photo/storage.ts @@ -18,7 +18,7 @@ export const convertUploadToPhoto = async ( console.log('Fetching original file'); const fileBytes = await fetch(urlOrigin, { cache: 'no-store' }) .then(res => res.arrayBuffer()); - const fileWithoutGps = await stripGpsFromFile(fileBytes); + const fileWithoutGps = stripGpsFromFile(fileBytes); console.log('Uploading file without GPS'); return putFile(fileWithoutGps, photoPath).then(async url => { if (url) { diff --git a/src/utility/data.ts b/src/utility/data.ts new file mode 100644 index 00000000..df971602 --- /dev/null +++ b/src/utility/data.ts @@ -0,0 +1,14 @@ +export const b64toBlob = ( + data: string, + type: string = 'image/jpeg', +): Blob => { + const byteString = atob(data.split(',')[1]); + const ab = new ArrayBuffer(byteString.length); + const ia = new Uint8Array(ab); + + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + + return new Blob([ab], { type }); +}; diff --git a/src/utility/exif-server.ts b/src/utility/exif-server.ts index 9c800d62..47994651 100644 --- a/src/utility/exif-server.ts +++ b/src/utility/exif-server.ts @@ -1,8 +1,9 @@ import * as PiExif from 'piexifjs'; +import { b64toBlob } from './data'; -export const stripGpsFromFile = async ( +export const stripGpsFromFile = ( fileBytes: ArrayBuffer -): Promise => { +): Blob => { const base64 = Buffer.from(fileBytes).toString('base64'); const base64Url = `data:image/jpeg;base64,${base64}`; @@ -17,5 +18,7 @@ export const stripGpsFromFile = async ( base64Url, ); - return fetch(data, { cache: 'no-store' }).then(res => res.blob()); + console.log('EXIF updated'); + + return b64toBlob(data); }; From 45689acb2663585cd3a743dc89fafccaf4fbf8b7 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 12:32:40 -0500 Subject: [PATCH 13/20] Switch to Sharp for EXIF removal --- src/photo/server.ts | 12 ++++++++++++ src/photo/storage.ts | 2 +- src/utility/exif-server.ts | 34 ++++++++++++++++++---------------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/photo/server.ts b/src/photo/server.ts index 2b897cf5..a6056afc 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -133,3 +133,15 @@ export const blurImageFromUrl = async (url: string) => fetch(decodeURIComponent(url)) .then(res => res.arrayBuffer()) .then(buffer => blurImage(buffer)); + +export const removeGpsData = async (image: ArrayBuffer) => + generateBase64(image, sharp => sharp + .withExifMerge({ + IFD3: { + GPSLatitudeRef: '', + GPSLatitude: '', + GPSLongitudeRef: '', + GPSLongitude: '', + }, + }) + ); diff --git a/src/photo/storage.ts b/src/photo/storage.ts index c2ec1d8d..908cd168 100644 --- a/src/photo/storage.ts +++ b/src/photo/storage.ts @@ -18,7 +18,7 @@ export const convertUploadToPhoto = async ( console.log('Fetching original file'); const fileBytes = await fetch(urlOrigin, { cache: 'no-store' }) .then(res => res.arrayBuffer()); - const fileWithoutGps = stripGpsFromFile(fileBytes); + const fileWithoutGps = await stripGpsFromFile(fileBytes); console.log('Uploading file without GPS'); return putFile(fileWithoutGps, photoPath).then(async url => { if (url) { diff --git a/src/utility/exif-server.ts b/src/utility/exif-server.ts index 47994651..4931e57b 100644 --- a/src/utility/exif-server.ts +++ b/src/utility/exif-server.ts @@ -1,24 +1,26 @@ -import * as PiExif from 'piexifjs'; +// import * as PiExif from 'piexifjs'; import { b64toBlob } from './data'; +import { removeGpsData } from '@/photo/server'; -export const stripGpsFromFile = ( +export const stripGpsFromFile = async ( fileBytes: ArrayBuffer -): Blob => { - const base64 = Buffer.from(fileBytes).toString('base64'); - const base64Url = `data:image/jpeg;base64,${base64}`; +): Promise => { + // const base64 = Buffer.from(fileBytes).toString('base64'); + // const base64Url = `data:image/jpeg;base64,${base64}`; - console.log('Stripping GPS from file'); - const exifObject = PiExif.load(base64Url); - delete exifObject.GPS; - const exifDataWithoutGps = PiExif.dump(exifObject); + // console.log('Stripping GPS from file'); + // const exifObject = PiExif.load(base64Url); + // delete exifObject.GPS; + // const exifDataWithoutGps = PiExif.dump(exifObject); - console.log('Updating EXIF'); - const data = PiExif.insert( - exifDataWithoutGps, - base64Url, - ); + // console.log('Updating EXIF'); + // const data = PiExif.insert( + // exifDataWithoutGps, + // base64Url, + // ); - console.log('EXIF updated'); + // console.log('EXIF updated'); - return b64toBlob(data); + // Removing EXIF data with Sharp + return b64toBlob(await removeGpsData(fileBytes)); }; From 605b66f6400cff847cad4bbd870ca368f2690e00 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 12:40:53 -0500 Subject: [PATCH 14/20] Add GPS fields to strip --- src/photo/server.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/photo/server.ts b/src/photo/server.ts index a6056afc..bf39bf1a 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -138,10 +138,15 @@ export const removeGpsData = async (image: ArrayBuffer) => generateBase64(image, sharp => sharp .withExifMerge({ IFD3: { - GPSLatitudeRef: '', - GPSLatitude: '', - GPSLongitudeRef: '', - GPSLongitude: '', + GPSVersionID: '-', + GPSMapDatum: '-', + GPSLatitudeRef: '-', + GPSLatitude: '-', + GPSLongitudeRef: '-', + GPSLongitude: '-', + GPSTimeStamp: '-', + GPSAltitude: '-', + GPSAltitudeRef: '-', }, }) ); From 1109ce7212fb0be4e7c6ad71389bcb719ab7eb49 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 16:40:40 -0500 Subject: [PATCH 15/20] Test EXIF removal with sharp, add note about file manipulation --- README.md | 2 +- src/photo/server.ts | 1 + src/services/postgres.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ba79b9c5..cb561d56 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 (⚠️ modifies original images in subtle ways in order to remove identifying 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/photo/server.ts b/src/photo/server.ts index bf39bf1a..249afc48 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -149,4 +149,5 @@ export const removeGpsData = async (image: ArrayBuffer) => GPSAltitudeRef: '-', }, }) + .jpeg({ quality: 100 }) ); 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, From 5ff6009329f9cf5f33052c4b95bdb2268975c209 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 7 Jun 2024 17:06:56 -0500 Subject: [PATCH 16/20] Update README text --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cb561d56..a137c0e2 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 (⚠️ modifies original images in subtle ways in order to remove identifying information) +- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data (⚠️ re-compresses original images in order to remove identifying 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 From a09e3b2dbad4944279b2501c5a3bb48622a600e0 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 8 Jun 2024 11:14:45 -0500 Subject: [PATCH 17/20] Refine GPS-stripping approach --- .vscode/settings.json | 2 - package.json | 1 - pnpm-lock.yaml | 56 ++++++++++++--------------- src/photo/actions.ts | 5 +++ src/photo/form/PhotoForm.tsx | 1 + src/photo/server.ts | 35 ++++++++++------- src/photo/storage.ts | 19 ++++----- src/services/storage/aws-s3.ts | 4 +- src/services/storage/cloudflare-r2.ts | 4 +- src/services/storage/index.ts | 2 +- src/services/storage/vercel-blob.ts | 2 +- src/utility/exif-server.ts | 26 ------------- 12 files changed, 65 insertions(+), 92 deletions(-) delete mode 100644 src/utility/exif-server.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 920390c5..bb3ac392 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,8 +30,6 @@ "nanoids", "nextjs", "parameterizes", - "piexif", - "piexifjs", "presigner", "Provia", "qaub", diff --git a/package.json b/package.json index 98906589..7e48c9d3 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "next-auth": "5.0.0-beta.18", "next-themes": "^0.3.0", "pg": "^8.12.0", - "piexifjs": "^1.0.6", "postcss": "8.4.38", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f07c44e0..96d53ed6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,9 +122,6 @@ importers: pg: specifier: ^8.12.0 version: 8.12.0 - piexifjs: - specifier: ^1.0.6 - version: 1.0.6 postcss: specifier: 8.4.38 version: 8.4.38 @@ -3450,9 +3447,6 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - piexifjs@1.0.6: - resolution: {integrity: sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag==} - pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -4403,10 +4397,10 @@ snapshots: '@aws-crypto/sha1-browser': 3.0.0 '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.590.0 - '@aws-sdk/client-sts': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) + '@aws-sdk/client-sso-oidc': 3.590.0(@aws-sdk/client-sts@3.590.0) + '@aws-sdk/client-sts': 3.590.0 '@aws-sdk/core': 3.588.0 - '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) + '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0) '@aws-sdk/middleware-bucket-endpoint': 3.587.0 '@aws-sdk/middleware-expect-continue': 3.577.0 '@aws-sdk/middleware-flexible-checksums': 3.587.0 @@ -4461,13 +4455,13 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.590.0': + '@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) + '@aws-sdk/client-sts': 3.590.0 '@aws-sdk/core': 3.588.0 - '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) + '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0) '@aws-sdk/middleware-host-header': 3.577.0 '@aws-sdk/middleware-logger': 3.577.0 '@aws-sdk/middleware-recursion-detection': 3.577.0 @@ -4504,6 +4498,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.6.2 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso@3.590.0': @@ -4549,13 +4544,13 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)': + '@aws-sdk/client-sts@3.590.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.590.0 + '@aws-sdk/client-sso-oidc': 3.590.0(@aws-sdk/client-sts@3.590.0) '@aws-sdk/core': 3.588.0 - '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) + '@aws-sdk/credential-provider-node': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0) '@aws-sdk/middleware-host-header': 3.577.0 '@aws-sdk/middleware-logger': 3.577.0 '@aws-sdk/middleware-recursion-detection': 3.577.0 @@ -4592,7 +4587,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.6.2 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/core@3.588.0': @@ -4624,14 +4618,14 @@ snapshots: '@smithy/util-stream': 3.0.1 tslib: 2.6.2 - '@aws-sdk/credential-provider-ini@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0))': + '@aws-sdk/credential-provider-ini@3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0)': dependencies: - '@aws-sdk/client-sts': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) + '@aws-sdk/client-sts': 3.590.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.587.0 '@aws-sdk/credential-provider-process': 3.587.0 - '@aws-sdk/credential-provider-sso': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) - '@aws-sdk/credential-provider-web-identity': 3.587.0(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) + '@aws-sdk/credential-provider-sso': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0)) + '@aws-sdk/credential-provider-web-identity': 3.587.0(@aws-sdk/client-sts@3.590.0) '@aws-sdk/types': 3.577.0 '@smithy/credential-provider-imds': 3.1.0 '@smithy/property-provider': 3.1.0 @@ -4642,14 +4636,14 @@ snapshots: - '@aws-sdk/client-sso-oidc' - aws-crt - '@aws-sdk/credential-provider-node@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0))': + '@aws-sdk/credential-provider-node@3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0)': dependencies: '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.587.0 - '@aws-sdk/credential-provider-ini': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0)(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) + '@aws-sdk/credential-provider-ini': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))(@aws-sdk/client-sts@3.590.0) '@aws-sdk/credential-provider-process': 3.587.0 - '@aws-sdk/credential-provider-sso': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) - '@aws-sdk/credential-provider-web-identity': 3.587.0(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)) + '@aws-sdk/credential-provider-sso': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0)) + '@aws-sdk/credential-provider-web-identity': 3.587.0(@aws-sdk/client-sts@3.590.0) '@aws-sdk/types': 3.577.0 '@smithy/credential-provider-imds': 3.1.0 '@smithy/property-provider': 3.1.0 @@ -4669,10 +4663,10 @@ snapshots: '@smithy/types': 3.0.0 tslib: 2.6.2 - '@aws-sdk/credential-provider-sso@3.590.0(@aws-sdk/client-sso-oidc@3.590.0)': + '@aws-sdk/credential-provider-sso@3.590.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))': dependencies: '@aws-sdk/client-sso': 3.590.0 - '@aws-sdk/token-providers': 3.587.0(@aws-sdk/client-sso-oidc@3.590.0) + '@aws-sdk/token-providers': 3.587.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0)) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.0 '@smithy/shared-ini-file-loader': 3.1.0 @@ -4682,9 +4676,9 @@ snapshots: - '@aws-sdk/client-sso-oidc' - aws-crt - '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.590.0(@aws-sdk/client-sso-oidc@3.590.0))': + '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.590.0)': dependencies: - '@aws-sdk/client-sts': 3.590.0(@aws-sdk/client-sso-oidc@3.590.0) + '@aws-sdk/client-sts': 3.590.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.0 '@smithy/types': 3.0.0 @@ -4809,9 +4803,9 @@ snapshots: '@smithy/types': 3.0.0 tslib: 2.6.2 - '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.590.0)': + '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.590.0(@aws-sdk/client-sts@3.590.0))': dependencies: - '@aws-sdk/client-sso-oidc': 3.590.0 + '@aws-sdk/client-sso-oidc': 3.590.0(@aws-sdk/client-sts@3.590.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.0 '@smithy/shared-ini-file-loader': 3.1.0 @@ -8550,8 +8544,6 @@ snapshots: picomatch@2.3.1: {} - piexifjs@1.0.6: {} - pify@2.3.0: {} pirates@4.0.6: {} diff --git a/src/photo/actions.ts b/src/photo/actions.ts index b6673a2e..3311aa61 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -108,6 +108,7 @@ export const addAllUploadsAction = async ({ photoFormExif, imageResizedBase64, shouldStripGpsData, + fileBytes, } = await extractImageDataFromBlobPath(url, { includeInitialPhotoFields: true, generateBlurData: BLUR_ENABLED, @@ -152,6 +153,7 @@ export const addAllUploadsAction = async ({ const updatedUrl = await convertUploadToPhoto( url, shouldStripGpsData, + fileBytes, ); if (updatedUrl) { stream.update({ @@ -184,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); @@ -303,6 +306,7 @@ export const syncPhotoAction = async (formData: FormData) => photoFormExif, imageResizedBase64, shouldStripGpsData, + fileBytes, } = await extractImageDataFromBlobPath(photo.url, { includeInitialPhotoFields: false, generateBlurData: BLUR_ENABLED, @@ -316,6 +320,7 @@ export const syncPhotoAction = async (formData: FormData) => 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 867e9dfd..26333b01 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -359,6 +359,7 @@ export default function PhotoForm({ type="hidden" name="shouldStripGpsData" value={shouldStripGpsData ? 'true' : 'false'} + readOnly /> {/* Actions */} diff --git a/src/photo/server.ts b/src/photo/server.ts index 249afc48..946374e7 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -11,7 +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 } from '@/site/config'; +import { GEO_PRIVACY_ENABLED, PRO_MODE_ENABLED } from '@/site/config'; const IMAGE_WIDTH_RESIZE = 200; const IMAGE_WIDTH_BLUR = 200; @@ -28,6 +28,7 @@ export const extractImageDataFromBlobPath = async ( photoFormExif?: Partial imageResizedBase64?: string shouldStripGpsData?: boolean + fileBytes?: ArrayBuffer }> => { const { includeInitialPhotoFields, @@ -100,6 +101,7 @@ export const extractImageDataFromBlobPath = async ( }, imageResizedBase64, shouldStripGpsData, + fileBytes, }; }; @@ -134,20 +136,27 @@ export const blurImageFromUrl = async (url: string) => .then(res => res.arrayBuffer()) .then(buffer => blurImage(buffer)); +const GPS_NULL_STRING = '-'; + export const removeGpsData = async (image: ArrayBuffer) => - generateBase64(image, sharp => sharp + sharp(image) .withExifMerge({ IFD3: { - GPSVersionID: '-', - GPSMapDatum: '-', - GPSLatitudeRef: '-', - GPSLatitude: '-', - GPSLongitudeRef: '-', - GPSLongitude: '-', - GPSTimeStamp: '-', - GPSAltitude: '-', - GPSAltitudeRef: '-', + GPSMapDatum: GPS_NULL_STRING, + GPSLatitudeRef: GPS_NULL_STRING, + GPSLatitude: GPS_NULL_STRING, + GPSLongitudeRef: GPS_NULL_STRING, + GPSLongitude: GPS_NULL_STRING, + GPSTimeStamp: GPS_NULL_STRING, + GPSAltitude: GPS_NULL_STRING, + GPSAltitudeRef: GPS_NULL_STRING, + GPSSatellites: GPS_NULL_STRING, + GPSDestLatitude: GPS_NULL_STRING, + GPSDestLongitudeRef: GPS_NULL_STRING, + GPSDestDistance: GPS_NULL_STRING, + GPSDestDistanceRef: GPS_NULL_STRING, + GPSAreaInformation: GPS_NULL_STRING, }, }) - .jpeg({ quality: 100 }) - ); + .toFormat('jpeg', { quality: PRO_MODE_ENABLED ? 100 : 80 }) + .toBuffer(); diff --git a/src/photo/storage.ts b/src/photo/storage.ts index 908cd168..b5396615 100644 --- a/src/photo/storage.ts +++ b/src/photo/storage.ts @@ -5,28 +5,23 @@ import { moveFile, putFile, } from '@/services/storage'; -import { stripGpsFromFile } from '@/utility/exif-server'; +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) { - console.log('Fetching original file'); - const fileBytes = await fetch(urlOrigin, { cache: 'no-store' }) - .then(res => res.arrayBuffer()); - const fileWithoutGps = await stripGpsFromFile(fileBytes); - console.log('Uploading file without GPS'); + const fileWithoutGps = await removeGpsData( + fileBytes ?? await fetch(urlOrigin, { cache: 'no-store' }) + .then(res => res.arrayBuffer()) + ); return putFile(fileWithoutGps, photoPath).then(async url => { - if (url) { - console.log('Deleting original file'); - await deleteFile(urlOrigin); - } else { - console.log('No url found'); - } + if (url) { await deleteFile(urlOrigin); } return url; }); } else { diff --git a/src/services/storage/aws-s3.ts b/src/services/storage/aws-s3.ts index aa074221..9e7e2fa1 100644 --- a/src/services/storage/aws-s3.ts +++ b/src/services/storage/aws-s3.ts @@ -33,13 +33,13 @@ export const awsS3PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key, ACL: 'public-read' }); export const awsS3Put = async ( - file: Blob, + file: Buffer, fileName: string, ): Promise => awsS3Client().send(new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key: fileName, - Body: Buffer.from(await file.arrayBuffer()), + Body: file, ACL: 'public-read', })) .then(() => urlForKey(fileName)); diff --git a/src/services/storage/cloudflare-r2.ts b/src/services/storage/cloudflare-r2.ts index f1ae31f5..12a45572 100644 --- a/src/services/storage/cloudflare-r2.ts +++ b/src/services/storage/cloudflare-r2.ts @@ -54,13 +54,13 @@ export const cloudflareR2PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key }); export const cloudflareR2Put = async ( - file: Blob, + file: Buffer, fileName: string, ): Promise => cloudflareR2Client().send(new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key: fileName, - Body: Buffer.from(await file.arrayBuffer()), + Body: file, })) .then(() => urlForKey(fileName)); diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 09f5bfea..5303f603 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -136,7 +136,7 @@ export const uploadPhotoFromClient = async ( : vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`); export const putFile = ( - file: Blob, + file: Buffer, fileName: string, ) => { switch (CURRENT_STORAGE) { diff --git a/src/services/storage/vercel-blob.ts b/src/services/storage/vercel-blob.ts index 8d6cc26c..60cfdd22 100644 --- a/src/services/storage/vercel-blob.ts +++ b/src/services/storage/vercel-blob.ts @@ -29,7 +29,7 @@ export const vercelBlobUploadFromClient = async ( .then(({ url }) => url); export const vercelBlobPut = ( - file: File | Blob, + file: Buffer, fileName: string, ): Promise => put(fileName, file, { diff --git a/src/utility/exif-server.ts b/src/utility/exif-server.ts deleted file mode 100644 index 4931e57b..00000000 --- a/src/utility/exif-server.ts +++ /dev/null @@ -1,26 +0,0 @@ -// import * as PiExif from 'piexifjs'; -import { b64toBlob } from './data'; -import { removeGpsData } from '@/photo/server'; - -export const stripGpsFromFile = async ( - fileBytes: ArrayBuffer -): Promise => { - // const base64 = Buffer.from(fileBytes).toString('base64'); - // const base64Url = `data:image/jpeg;base64,${base64}`; - - // console.log('Stripping GPS from file'); - // const exifObject = PiExif.load(base64Url); - // delete exifObject.GPS; - // const exifDataWithoutGps = PiExif.dump(exifObject); - - // console.log('Updating EXIF'); - // const data = PiExif.insert( - // exifDataWithoutGps, - // base64Url, - // ); - - // console.log('EXIF updated'); - - // Removing EXIF data with Sharp - return b64toBlob(await removeGpsData(fileBytes)); -}; From cdf70fa5c734aae6db71f6a6d3ce7f885119ca07 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 8 Jun 2024 11:27:20 -0500 Subject: [PATCH 18/20] Add download to admin menu --- src/admin/AdminPhotoMenuClient.tsx | 14 ++++++++++++-- src/components/more/MoreMenu.tsx | 4 +++- src/components/more/MoreMenuItem.tsx | 8 +++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/admin/AdminPhotoMenuClient.tsx b/src/admin/AdminPhotoMenuClient.tsx index 99154499..892ae04e 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, + hrefTargetBlank: true, + }); items.push({ label: 'Delete', icon: , action: () => { if (confirm(deleteConfirmationTextForPhoto(photo))) { diff --git a/src/components/more/MoreMenu.tsx b/src/components/more/MoreMenu.tsx index 39188b3e..7b8e493d 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 + hrefTargetBlank?: boolean 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, hrefTargetBlank, action }) => )} diff --git a/src/components/more/MoreMenuItem.tsx b/src/components/more/MoreMenuItem.tsx index 6fd4cf87..8b7ccc2d 100644 --- a/src/components/more/MoreMenuItem.tsx +++ b/src/components/more/MoreMenuItem.tsx @@ -10,11 +10,13 @@ export default function MoreMenuItem({ label, icon, href, + hrefTargetBlank, action, }: { label: ReactNode icon?: ReactNode href?: string + hrefTargetBlank?: boolean 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 (hrefTargetBlank) { + window.open(href, '_blank'); + } else { + startTransition(() => router.push(href)); + } } else { const result = action?.(); if (result instanceof Promise) { From b8e1d22b64c70a4e3f1d17c2413873011c3801b9 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 8 Jun 2024 17:39:27 -0500 Subject: [PATCH 19/20] Tweak GPS overwrite schema --- .vscode/settings.json | 1 + src/admin/AdminPhotoMenuClient.tsx | 2 +- src/components/more/MoreMenu.tsx | 6 +++--- src/components/more/MoreMenuItem.tsx | 6 +++--- src/photo/server.ts | 18 ++++++++++-------- 5 files changed, 18 insertions(+), 15 deletions(-) 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/src/admin/AdminPhotoMenuClient.tsx b/src/admin/AdminPhotoMenuClient.tsx index 892ae04e..9665a8e8 100644 --- a/src/admin/AdminPhotoMenuClient.tsx +++ b/src/admin/AdminPhotoMenuClient.tsx @@ -63,7 +63,7 @@ export default function AdminPhotoMenuClient({ className="translate-x-[-1.5px] translate-y-[-0.5px]" />, href: photo.url, - hrefTargetBlank: true, + hrefDownloadName: photo.url.split('/').pop(), }); items.push({ label: 'Delete', diff --git a/src/components/more/MoreMenu.tsx b/src/components/more/MoreMenu.tsx index 7b8e493d..4c646b43 100644 --- a/src/components/more/MoreMenu.tsx +++ b/src/components/more/MoreMenu.tsx @@ -8,7 +8,7 @@ export interface MoreMenuItem { label: ReactNode icon?: ReactNode href?: string - hrefTargetBlank?: boolean + hrefDownloadName?: string action?: () => Promise | void } @@ -52,13 +52,13 @@ export default function MoreMenu({ 'shadow-lg dark:shadow-xl', )} > - {items.map(({ label, icon, href, hrefTargetBlank, action }) => + {items.map(({ label, icon, href, hrefDownloadName, action }) => )} diff --git a/src/components/more/MoreMenuItem.tsx b/src/components/more/MoreMenuItem.tsx index 8b7ccc2d..213f1601 100644 --- a/src/components/more/MoreMenuItem.tsx +++ b/src/components/more/MoreMenuItem.tsx @@ -10,13 +10,13 @@ export default function MoreMenuItem({ label, icon, href, - hrefTargetBlank, + hrefDownloadName, action, }: { label: ReactNode icon?: ReactNode href?: string - hrefTargetBlank?: boolean + hrefDownloadName?: string action?: () => Promise | void }) { const router = useRouter(); @@ -41,7 +41,7 @@ export default function MoreMenuItem({ onClick={e => { e.preventDefault(); if (href) { - if (hrefTargetBlank) { + if (Boolean(hrefDownloadName)) { window.open(href, '_blank'); } else { startTransition(() => router.push(href)); diff --git a/src/photo/server.ts b/src/photo/server.ts index 946374e7..b01a70e2 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -143,20 +143,22 @@ export const removeGpsData = async (image: ArrayBuffer) => .withExifMerge({ IFD3: { GPSMapDatum: GPS_NULL_STRING, - GPSLatitudeRef: GPS_NULL_STRING, GPSLatitude: GPS_NULL_STRING, - GPSLongitudeRef: GPS_NULL_STRING, GPSLongitude: GPS_NULL_STRING, + GPSDateStamp: GPS_NULL_STRING, + GPSDateTime: GPS_NULL_STRING, GPSTimeStamp: GPS_NULL_STRING, GPSAltitude: GPS_NULL_STRING, - GPSAltitudeRef: GPS_NULL_STRING, GPSSatellites: GPS_NULL_STRING, - GPSDestLatitude: GPS_NULL_STRING, - GPSDestLongitudeRef: GPS_NULL_STRING, - GPSDestDistance: GPS_NULL_STRING, - GPSDestDistanceRef: 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 ? 100 : 80 }) + .toFormat('jpeg', { quality: PRO_MODE_ENABLED ? 95 : 80 }) .toBuffer(); From 6b65adb1e5c8b0b79fef426d3d57fd9aff048079 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 8 Jun 2024 18:22:33 -0500 Subject: [PATCH 20/20] Streamline GPS code --- README.md | 2 +- src/types/piexif.d.ts | 6 ------ src/utility/data.ts | 14 -------------- 3 files changed, 1 insertion(+), 21 deletions(-) delete mode 100644 src/types/piexif.d.ts delete mode 100644 src/utility/data.ts diff --git a/README.md b/README.md index a137c0e2..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 (⚠️ re-compresses original images in order to remove identifying information) +- `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/types/piexif.d.ts b/src/types/piexif.d.ts deleted file mode 100644 index 10ad2c63..00000000 --- a/src/types/piexif.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module 'piexifjs' { - export function load(base64Url: string): Record - export function dump(exifObject: Record): string - export function insert(exifDataWithoutGps: string, base64Url: string): string - export function remove(exifData: string): string -} diff --git a/src/utility/data.ts b/src/utility/data.ts deleted file mode 100644 index df971602..00000000 --- a/src/utility/data.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const b64toBlob = ( - data: string, - type: string = 'image/jpeg', -): Blob => { - const byteString = atob(data.split(',')[1]); - const ab = new ArrayBuffer(byteString.length); - const ia = new Uint8Array(ab); - - for (let i = 0; i < byteString.length; i++) { - ia[i] = byteString.charCodeAt(i); - } - - return new Blob([ab], { type }); -};