diff --git a/README.md b/README.md index 8ebd954d..939fcc06 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Installation - `NEXT_PUBLIC_PUBLIC_API = 1` enables public API available at `/api` - `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo - `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar +- `NEXT_PUBLIC_HIDE_EXIF_DATA = 1` hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography) - `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint) - `NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM` keeps OG image text bottom aligned (default is top) @@ -82,24 +83,27 @@ Only one storage adapter—Vercel Blob, Cloudflare R2, or AWS S3—can be used a - Setup CORS under bucket settings: ```json [{ - "AllowedHeaders": ["*"] - "AllowedOrigins": [ - "http://localhost:3000", - "https://{VERCEL_PROJECT_NAME}*.vercel.app", - "{PRODUCTION_DOMAIN}" - ], + "AllowedHeaders": ["*"], "AllowedMethods": [ "GET", "PUT" ], + "AllowedOrigins": [ + "http://localhost:3000", + "https://{VERCEL_PROJECT_NAME}*.vercel.app", + "{PRODUCTION_DOMAIN}" + ] }] ``` - - Enable R2.dev subdomain (necessary in order to serve files publicly without a custom domain) - - Store configuration: + - Enable public hosting by doing one of the following: + - Select "Connect Custom Domain" and choose a Cloudflare domain + - OR + - Select "Allow Access" from R2.dev subdomain + - Store public configuration: - `NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET`: bucket name - `NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID`: account id (found on R2 overview page) - - `NEXT_PUBLIC_CLOUDFLARE_R2_DEV_SUBDOMAIN`: r2.dev subdomain, e.g., "pub-jf90908..." -2. Setup credentials + - `NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN`: e.g., either "pub-jf90908...r2.dev" or "custom-domain.com" +2. Setup private credentials - Create API token by selecting "Manage R2 API Tokens," and clicking "Create API Token" - Select "Object Read & Write," choose "Apply to specific buckets only," and select the bucket created in Step 1. - Store credentials (⚠️ _Ensure access keys are not prefixed with `NEXT_PUBLIC`_): @@ -126,10 +130,10 @@ Only one storage adapter—Vercel Blob, Cloudflare R2, or AWS S3—can be used a "ExposeHeaders": [] }] ``` - - Store configuration + - Store public configuration - `NEXT_PUBLIC_AWS_S3_BUCKET`: bucket name - `NEXT_PUBLIC_AWS_S3_REGION`: bucket region, e.g., "us-east-1" -2. Setup credentials +2. Setup private credentials - [Create IAM policy](https://console.aws.amazon.com/iam/home#/policies) using JSON editor: ```json { diff --git a/next.config.js b/next.config.js index b8999f9a..c6ea678c 100644 --- a/next.config.js +++ b/next.config.js @@ -7,9 +7,7 @@ const VERCEL_BLOB_HOSTNAME = VERCEL_BLOB_STORE_ID : undefined; const CLOUDFLARE_R2_HOSTNAME = - process.env.NEXT_PUBLIC_CLOUDFLARE_R2_DEV_SUBDOMAIN - ? `${process.env.NEXT_PUBLIC_CLOUDFLARE_R2_DEV_SUBDOMAIN}.r2.dev` - : undefined; + process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN; const AWS_S3_HOSTNAME = process.env.NEXT_PUBLIC_AWS_S3_BUCKET && diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 935a3b55..466caf34 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -1,4 +1,9 @@ -import { Photo, photoHasCameraData, photoHasExifData, titleForPhoto } from '.'; +import { + Photo, + shouldShowCameraDataForPhoto, + shouldShowExifDataForPhoto, + titleForPhoto, +} from '.'; import SiteGrid from '@/components/SiteGrid'; import ImageLarge from '@/components/ImageLarge'; import { clsx } from 'clsx/lite'; @@ -92,7 +97,7 @@ export default function PhotoLarge({ {tags.length > 0 && } - {showCamera && photoHasCameraData(photo) && + {showCamera && shouldShowCameraDataForPhoto(photo) &&
} )} {renderMiniGrid(<> - {photoHasExifData(photo) && + {shouldShowExifDataForPhoto(photo) &&
  • {photo.focalLengthFormatted} diff --git a/src/photo/image-response/PhotoImageResponse.tsx b/src/photo/image-response/PhotoImageResponse.tsx index 499ca283..fcc28897 100644 --- a/src/photo/image-response/PhotoImageResponse.tsx +++ b/src/photo/image-response/PhotoImageResponse.tsx @@ -1,4 +1,4 @@ -import { Photo } from '..'; +import { Photo, shouldShowExifDataForPhoto } from '..'; import { AiFillApple } from 'react-icons/ai'; import ImageCaption from './components/ImageCaption'; import ImagePhotoGrid from './components/ImagePhotoGrid'; @@ -30,25 +30,26 @@ export default function PhotoImageResponse({ height, ...OG_TEXT_BOTTOM_ALIGNMENT && { imagePosition: 'top' }, }} /> - - {photo.make === 'Apple' && + {shouldShowExifDataForPhoto(photo) && + + {photo.make === 'Apple' && +
    + +
    } + {model && +
    + {model} +
    }
    - -
    } - {model && + {photo.focalLengthFormatted} +
- {model} -
} -
- {photo.focalLengthFormatted} -
-
- {photo.fNumberFormatted} -
-
- {photo.isoFormatted} -
- + {photo.fNumberFormatted} + +
+ {photo.isoFormatted} +
+ } ); }; diff --git a/src/photo/index.ts b/src/photo/index.ts index 664d4aba..bef1ae1b 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -1,4 +1,5 @@ import { FilmSimulation } from '@/simulation'; +import { SHOW_EXIF_DATA } from '@/site/config'; import { ABSOLUTE_PATH_FOR_HOME_IMAGE } from '@/site/paths'; import { formatDateFromPostgresString } from '@/utility/date'; import { @@ -228,14 +229,20 @@ export const dateRangeForPhotos = ( return { start, end, description }; }; -export const photoHasCameraData = (photo: Photo) => +const photoHasCameraData = (photo: Photo) => photo.make && photo.model; -export const photoHasExifData = (photo: Photo) => +const photoHasExifData = (photo: Photo) => photo.focalLength || photo.focalLengthIn35MmFormat || photo.fNumberFormatted || photo.isoFormatted || photo.exposureTimeFormatted || photo.exposureCompensationFormatted; + +export const shouldShowCameraDataForPhoto = (photo: Photo) => + SHOW_EXIF_DATA && photoHasCameraData(photo); + +export const shouldShowExifDataForPhoto = (photo: Photo) => + SHOW_EXIF_DATA && photoHasExifData(photo); diff --git a/src/services/storage/aws-s3.ts b/src/services/storage/aws-s3.ts index 2ec518d5..07bc3dc9 100644 --- a/src/services/storage/aws-s3.ts +++ b/src/services/storage/aws-s3.ts @@ -11,8 +11,10 @@ const AWS_S3_BUCKET = process.env.NEXT_PUBLIC_AWS_S3_BUCKET ?? ''; const AWS_S3_REGION = process.env.NEXT_PUBLIC_AWS_S3_REGION ?? ''; const AWS_S3_ACCESS_KEY = process.env.AWS_S3_ACCESS_KEY ?? ''; const AWS_S3_SECRET_ACCESS_KEY = process.env.AWS_S3_SECRET_ACCESS_KEY ?? ''; -export const AWS_S3_BASE_URL = - `https://${AWS_S3_BUCKET}.s3.${AWS_S3_REGION}.amazonaws.com`; + +export const AWS_S3_BASE_URL = AWS_S3_BUCKET && AWS_S3_REGION + ? `https://${AWS_S3_BUCKET}.s3.${AWS_S3_REGION}.amazonaws.com` + : undefined; export const awsS3Client = () => new S3Client({ region: AWS_S3_REGION, @@ -25,7 +27,7 @@ export const awsS3Client = () => new S3Client({ const urlForKey = (key?: string) => `${AWS_S3_BASE_URL}/${key}`; export const isUrlFromAwsS3 = (url: string) => - url.startsWith(AWS_S3_BASE_URL); + AWS_S3_BASE_URL && url.startsWith(AWS_S3_BASE_URL); export const awsS3PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: AWS_S3_BUCKET, Key, ACL: 'public-read' }); diff --git a/src/services/storage/cloudflare-r2.ts b/src/services/storage/cloudflare-r2.ts index 7866005a..cf4ac5d2 100644 --- a/src/services/storage/cloudflare-r2.ts +++ b/src/services/storage/cloudflare-r2.ts @@ -11,20 +11,23 @@ const CLOUDFLARE_R2_BUCKET = process.env.NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET ?? ''; const CLOUDFLARE_R2_ACCOUNT_ID = process.env.NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID ?? ''; -const CLOUDFLARE_R2_DEV_SUBDOMAIN = - process.env.NEXT_PUBLIC_CLOUDFLARE_R2_DEV_SUBDOMAIN ?? ''; +const CLOUDFLARE_R2_PUBLIC_DOMAIN = + process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN ?? ''; const CLOUDFLARE_R2_ACCESS_KEY = process.env.CLOUDFLARE_R2_ACCESS_KEY ?? ''; const CLOUDFLARE_R2_SECRET_ACCESS_KEY = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY ?? ''; -const CLOUDFLARE_R2_ENDPOINT = - `https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com`; +const CLOUDFLARE_R2_ENDPOINT = CLOUDFLARE_R2_ACCOUNT_ID + ? `https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com` + : undefined; +export const CLOUDFLARE_R2_BASE_URL_PUBLIC = CLOUDFLARE_R2_PUBLIC_DOMAIN + ? `https://${CLOUDFLARE_R2_PUBLIC_DOMAIN}` + : undefined; export const CLOUDFLARE_R2_BASE_URL_PRIVATE = - `${CLOUDFLARE_R2_ENDPOINT}/${CLOUDFLARE_R2_BUCKET}`; - -export const CLOUDFLARE_R2_BASE_URL_PUBLIC = - `https://${CLOUDFLARE_R2_DEV_SUBDOMAIN}.r2.dev`; + CLOUDFLARE_R2_ENDPOINT && CLOUDFLARE_R2_BUCKET + ? `${CLOUDFLARE_R2_ENDPOINT}/${CLOUDFLARE_R2_BUCKET}` + : undefined; export const cloudflareR2Client = () => new S3Client({ region: 'auto', @@ -39,9 +42,13 @@ const urlForKey = (key?: string, isPublic = true) => isPublic ? `${CLOUDFLARE_R2_BASE_URL_PUBLIC}/${key}` : `${CLOUDFLARE_R2_BASE_URL_PRIVATE}/${key}`; -export const isUrlFromCloudflareR2 = (url: string) => - url.startsWith(CLOUDFLARE_R2_BASE_URL_PRIVATE) || - url.startsWith(CLOUDFLARE_R2_BASE_URL_PUBLIC); +export const isUrlFromCloudflareR2 = (url: string) => ( + CLOUDFLARE_R2_BASE_URL_PRIVATE && + url.startsWith(CLOUDFLARE_R2_BASE_URL_PRIVATE) +) || ( + CLOUDFLARE_R2_BASE_URL_PUBLIC && + url.startsWith(CLOUDFLARE_R2_BASE_URL_PUBLIC) +); export const cloudflareR2PutObjectCommandForKey = (Key: string) => new PutObjectCommand({ Bucket: CLOUDFLARE_R2_BUCKET, Key }); diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 2b58ebe3..9e7a1e1f 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -130,7 +130,7 @@ export const convertUploadToPhoto = async ( ): Promise => { const fileName = photoId ? `${PREFIX_PHOTO}-${photoId}` : `${PREFIX_PHOTO}`; const fileExtension = getExtensionFromStorageUrl(uploadUrl); - const photoUrl = `${fileName}.${fileExtension ?? 'jpg'}`; + const photoPath = `${fileName}.${fileExtension ?? 'jpg'}`; const storageType = storageTypeFromUrl(uploadUrl); @@ -139,17 +139,17 @@ export const convertUploadToPhoto = async ( // Copy file switch (storageType) { case 'vercel-blob': - url = await vercelBlobCopy(uploadUrl, photoUrl, photoId === undefined); + url = await vercelBlobCopy(uploadUrl, photoPath, photoId === undefined); break; case 'cloudflare-r2': url = await cloudflareR2Copy( getFileNameFromStorageUrl(uploadUrl), - photoUrl, + photoPath, photoId === undefined, ); break; case 'aws-s3': - url = await awsS3Copy(uploadUrl, photoUrl, photoId === undefined); + url = await awsS3Copy(uploadUrl, photoPath, photoId === undefined); break; } diff --git a/src/services/storage/vercel-blob.ts b/src/services/storage/vercel-blob.ts index a8b5094f..fb6f013c 100644 --- a/src/services/storage/vercel-blob.ts +++ b/src/services/storage/vercel-blob.ts @@ -6,10 +6,12 @@ const VERCEL_BLOB_STORE_ID = process.env.BLOB_READ_WRITE_TOKEN?.match( /^vercel_blob_rw_([a-z0-9]+)_[a-z0-9]+$/i, )?.[1].toLowerCase(); -export const VERCEL_BLOB_BASE_URL = - `https://${VERCEL_BLOB_STORE_ID}.public.blob.vercel-storage.com`; +export const VERCEL_BLOB_BASE_URL = VERCEL_BLOB_STORE_ID + ? `https://${VERCEL_BLOB_STORE_ID}.public.blob.vercel-storage.com` + : undefined; export const isUrlFromVercelBlob = (url: string) => + VERCEL_BLOB_BASE_URL && url.startsWith(VERCEL_BLOB_BASE_URL); export const vercelBlobUploadFromClient = async ( diff --git a/src/site/NavClient.tsx b/src/site/NavClient.tsx index 066ba107..e15e3590 100644 --- a/src/site/NavClient.tsx +++ b/src/site/NavClient.tsx @@ -68,7 +68,7 @@ export default function NavClient({ showAdmin={showAdmin} /> -
+
{renderLink(SITE_DOMAIN_OR_TITLE, PATH_ROOT)}
] diff --git a/src/site/SiteChecklistClient.tsx b/src/site/SiteChecklistClient.tsx index 6ea9004e..fb940901 100644 --- a/src/site/SiteChecklistClient.tsx +++ b/src/site/SiteChecklistClient.tsx @@ -35,6 +35,7 @@ export default function SiteChecklistClient({ hasDomain, showRepoLink, showFilmSimulations, + showExifInfo, isProModeEnabled, isGeoPrivacyEnabled, isPriorityOrderEnabled, @@ -146,7 +147,8 @@ export default function SiteChecklistClient({ title={!hasStorage ? 'Setup storage (one of the following)' : hasMultipleStorageProviders - ? `Setup storage (current: ${labelForStorage(currentStorage)})` + // eslint-disable-next-line max-len + ? `Setup storage (new uploads go to: ${labelForStorage(currentStorage)})` : 'Setup storage'} status={hasStorage} isPending={isPendingPage} @@ -317,6 +319,15 @@ export default function SiteChecklistClient({ simulations showing up in /grid sidebar: {renderEnvVars(['NEXT_PUBLIC_HIDE_FILM_SIMULATIONS'])} + + Set environment variable to {'"1"'} to hide EXIF data: + {renderEnvVars(['NEXT_PUBLIC_HIDE_EXIF_DATA'])} + 0 && (process.env.NEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID ?? '').length > 0 && - (process.env.NEXT_PUBLIC_CLOUDFLARE_R2_DEV_SUBDOMAIN ?? '').length > 0; + (process.env.NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN ?? '').length > 0; export const HAS_CLOUDFLARE_R2_STORAGE = HAS_CLOUDFLARE_R2_STORAGE_CLIENT && (process.env.CLOUDFLARE_R2_ACCESS_KEY ?? '').length > 0 && @@ -83,6 +83,7 @@ export const PUBLIC_API_ENABLED = process.env.NEXT_PUBLIC_PUBLIC_API === '1'; export const SHOW_REPO_LINK = process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1'; export const SHOW_FILM_SIMULATIONS = process.env.NEXT_PUBLIC_HIDE_FILM_SIMULATIONS !== '1'; +export const SHOW_EXIF_DATA = process.env.NEXT_PUBLIC_HIDE_EXIF_DATA !== '1'; export const GRID_ASPECT_RATIO = process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO ? parseFloat(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO) : 1; @@ -111,6 +112,7 @@ export const CONFIG_CHECKLIST_STATUS = { hasDomain: (process.env.NEXT_PUBLIC_SITE_DOMAIN ?? '').length > 0, showRepoLink: SHOW_REPO_LINK, showFilmSimulations: SHOW_FILM_SIMULATIONS, + showExifInfo: SHOW_EXIF_DATA, isProModeEnabled: PRO_MODE_ENABLED, isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED, isPriorityOrderEnabled: PRIORITY_ORDER_ENABLED,