diff --git a/README.md b/README.md index 44d4023a..7add19c6 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,9 @@ FAQ #### Why do my vertical images take up so much space? > By default, all photos are shown full-width, regardless of orientation. Enable matting to showcase horizontal and vertical photos at a similar scale by setting `NEXT_PUBLIC_MATTE_PHOTOS = 1`. +#### How secure are photos marked “hidden?” +> While all hidden paths (`/tag/hidden/*`) require authentication, raw links to individual photo files remain publicly accessible. Their randomly generated file names are only secure via obscurity. + #### My images/content have fallen out of sync with my database and/or my production site no longer matches local development. What do I do? > Navigate to `/admin/configuration` and click "Clear Cache." diff --git a/__tests__/path.test.ts b/__tests__/path.test.ts index 858dfede..0552a40f 100644 --- a/__tests__/path.test.ts +++ b/__tests__/path.test.ts @@ -1,5 +1,4 @@ /* eslint-disable max-len */ -import '@testing-library/jest-dom'; import { getEscapePath, getPathComponents, @@ -13,11 +12,13 @@ import { isPathFilmSimulationShare, isPathPhoto, isPathPhotoShare, + isPathProtected, isPathTag, isPathTagPhoto, isPathTagPhotoShare, isPathTagShare, } from '@/site/paths'; +import { TAG_HIDDEN } from '@/tag'; const PHOTO_ID = 'UsKSGcbt'; const TAG = 'tag-name'; @@ -39,6 +40,11 @@ const PATH_TAG_SHARE = `${PATH_TAG}/${SHARE}`; const PATH_TAG_PHOTO = `${PATH_TAG}/${PHOTO_ID}`; const PATH_TAG_PHOTO_SHARE = `${PATH_TAG_PHOTO}/${SHARE}`; +const PATH_TAG_HIDDEN = `/tag/${TAG_HIDDEN}`; +const PATH_TAG_HIDDEN_SHARE = `${PATH_TAG_HIDDEN}/${SHARE}`; +const PATH_TAG_HIDDEN_PHOTO = `${PATH_TAG_HIDDEN}/${PHOTO_ID}`; +const PATH_TAG_HIDDEN_PHOTO_SHARE = `${PATH_TAG_HIDDEN_PHOTO}/${SHARE}`; + const PATH_CAMERA = `/shot-on/${CAMERA_MAKE}/${CAMERA_MODEL}`; const PATH_CAMERA_SHARE = `${PATH_CAMERA}/${SHARE}`; const PATH_CAMERA_PHOTO = `${PATH_CAMERA}/${PHOTO_ID}`; @@ -50,6 +56,21 @@ const PATH_FILM_SIMULATION_PHOTO = `${PATH_FILM_SIMULATION}/${PHOTO_ID}`; const PATH_FILM_SIMULATION_PHOTO_SHARE = `${PATH_FILM_SIMULATION_PHOTO}/${SHARE}`; describe('Paths', () => { + it('can be protected', () => { + // Public + expect(isPathProtected(PATH_ROOT)).toBe(false); + expect(isPathProtected(PATH_PHOTO)).toBe(false); + expect(isPathProtected(PATH_TAG)).toBe(false); + expect(isPathProtected(PATH_TAG_PHOTO)).toBe(false); + expect(isPathProtected(PATH_CAMERA)).toBe(false); + expect(isPathProtected(PATH_FILM_SIMULATION)).toBe(false); + // Private + expect(isPathProtected(PATH_ADMIN)).toBe(true); + expect(isPathProtected(PATH_TAG_HIDDEN)).toBe(true); + expect(isPathProtected(PATH_TAG_HIDDEN_SHARE)).toBe(true); + expect(isPathProtected(PATH_TAG_HIDDEN_PHOTO)).toBe(true); + expect(isPathProtected(PATH_TAG_HIDDEN_PHOTO_SHARE)).toBe(true); + }); it('can be classified', () => { // Positive expect(isPathPhoto(PATH_PHOTO)).toBe(true); diff --git a/package.json b/package.json index 343886e5..f1b0846d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "jest --watch", + "test": "jest --watch --transformIgnorePatterns 'node_modules/(?!my-library-dir)/'", "analyze": "ANALYZE=true next build" }, "dependencies": { diff --git a/src/admin/AdminNavClient.tsx b/src/admin/AdminNavClient.tsx index 6c30609b..b9bd1df8 100644 --- a/src/admin/AdminNavClient.tsx +++ b/src/admin/AdminNavClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import InfoBlock from '@/components/InfoBlock'; +import Banner from '@/components/Banner'; import SiteGrid from '@/components/SiteGrid'; import { PATH_ADMIN_CONFIGURATION, @@ -96,13 +96,10 @@ export default function AdminNavClient({ {shouldShowBanner && - -
- - Photo updates detected—they may take several minutes to show up - for visitors -
-
} + }> + Photo updates detected—they may take several minutes to show upe + for visitors + } } /> diff --git a/src/admin/AdminPhotosTable.tsx b/src/admin/AdminPhotosTable.tsx index f4effb3f..5073ddbe 100644 --- a/src/admin/AdminPhotosTable.tsx +++ b/src/admin/AdminPhotosTable.tsx @@ -50,15 +50,16 @@ export default function AdminPhotosTable({ prefetch={false} > - {titleForPhoto(photo)} - {photo.hidden && + {titleForPhoto(photo)} + {photo.hidden && + {' '} } + /> + } {photo.priorityOrder !== null && []), diff --git a/src/app/tag/[tag]/page.tsx b/src/app/tag/[tag]/page.tsx index 95a33ff4..ea436410 100644 --- a/src/app/tag/[tag]/page.tsx +++ b/src/app/tag/[tag]/page.tsx @@ -1,5 +1,6 @@ import { GRID_THUMBNAILS_TO_SHOW_MAX } from '@/photo'; import { PaginationParams } from '@/site/pagination'; +import { PATH_ROOT } from '@/site/paths'; import { generateMetaForTag } from '@/tag'; import TagOverview from '@/tag/TagOverview'; import { @@ -7,6 +8,7 @@ import { getPhotosTagDataCachedWithPagination, } from '@/tag/data'; import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; interface TagProps { params: { tag: string } @@ -25,6 +27,8 @@ export async function generateMetadata({ limit: GRID_THUMBNAILS_TO_SHOW_MAX, }); + if (photos.length === 0) { return {}; } + const { url, title, @@ -65,6 +69,8 @@ export default async function TagPage({ searchParams, }); + if (photos.length === 0) { redirect(PATH_ROOT); } + return ( ); diff --git a/src/app/tag/hidden/[photoId]/page.tsx b/src/app/tag/hidden/[photoId]/page.tsx new file mode 100644 index 00000000..5f7e4e91 --- /dev/null +++ b/src/app/tag/hidden/[photoId]/page.tsx @@ -0,0 +1,56 @@ +import { descriptionForPhoto, titleForPhoto } from '@/photo'; +import PhotoDetailPage from '@/photo/PhotoDetailPage'; +import { getPhotoCached, getPhotosCached } from '@/photo/cache'; +import { PATH_ROOT, absolutePathForPhoto } from '@/site/paths'; +import { TAG_HIDDEN } from '@/tag'; +import { Metadata } from 'next'; +import { redirect } from 'next/navigation'; +import { cache } from 'react'; + +const getPhotoCachedCached = cache(getPhotoCached); + +interface PhotoTagProps { + params: { photoId: string } +} + +export async function generateMetadata({ + params: { photoId }, +}: PhotoTagProps): Promise { + const photo = await getPhotoCachedCached(photoId, true); + + if (!photo) { return {}; } + + const title = titleForPhoto(photo); + const description = descriptionForPhoto(photo); + const url = absolutePathForPhoto(photo, TAG_HIDDEN); + + return { + title, + description, + openGraph: { + title, + description, + url, + }, + twitter: { + title, + description, + card: 'summary_large_image', + }, + }; +} + +export default async function PhotoTagHiddenPage({ + params: { photoId }, +}: PhotoTagProps) { + const photo = await getPhotoCachedCached(photoId, true); + + if (!photo) { redirect(PATH_ROOT); } + + const photos = await getPhotosCached({ hidden: 'only' }); + const count = photos.length; + + return ( + + ); +} diff --git a/src/app/tag/hidden/page.tsx b/src/app/tag/hidden/page.tsx new file mode 100644 index 00000000..796ff9e2 --- /dev/null +++ b/src/app/tag/hidden/page.tsx @@ -0,0 +1,71 @@ +import AnimateItems from '@/components/AnimateItems'; +import Banner from '@/components/Banner'; +import SiteGrid from '@/components/SiteGrid'; +import PhotoGrid from '@/photo/PhotoGrid'; +import { getPhotosCached, getPhotosTagHiddenMetaCached } from '@/photo/cache'; +import { absolutePathForTag } from '@/site/paths'; +import { TAG_HIDDEN, descriptionForTaggedPhotos, titleForTag } from '@/tag'; +import HiddenHeader from '@/tag/HiddenHeader'; +import { Metadata } from 'next'; +import { cache } from 'react'; + +const getPhotosTagHiddenMetaCachedCached = cache(getPhotosTagHiddenMetaCached); + +export async function generateMetadata(): Promise { + const { count, dateRange } = await getPhotosTagHiddenMetaCachedCached(); + + if (count === 0) { return {}; } + + const title = titleForTag(TAG_HIDDEN, undefined, count); + const description = descriptionForTaggedPhotos( + undefined, + undefined, + count, + dateRange, + ); + const url = absolutePathForTag(TAG_HIDDEN); + + return { + title, + openGraph: { + title, + description, + url, + }, + twitter: { + description, + card: 'summary_large_image', + }, + description, + }; +} + +export default async function HiddenTagPage() { + const [ + photos, + { count, dateRange }, + ] = await Promise.all([ + getPhotosCached({ hidden: 'only' }), + getPhotosTagHiddenMetaCached(), + ]); + return ( + + ]} + animateOnFirstLoadOnly + /> +
+ + Only visible to authenticated admins + + +
+ } + /> + ); +} diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx new file mode 100644 index 00000000..db738a84 --- /dev/null +++ b/src/components/Banner.tsx @@ -0,0 +1,40 @@ +import { ReactNode } from 'react'; +import InfoBlock from './InfoBlock'; +import AnimateItems from './AnimateItems'; +import { IoInformationCircleOutline } from 'react-icons/io5'; + +export default function Banner({ + icon, + children, + animate, + className, +}: { + icon?: ReactNode + children: ReactNode + animate?: boolean + className?: string +}) { + return ( + +
+ {icon ?? } + {children} +
+ , + ]} + animateOnFirstLoadOnly + /> + ); +} diff --git a/src/components/CommandKClient.tsx b/src/components/CommandKClient.tsx index c57696d5..c2d570e2 100644 --- a/src/components/CommandKClient.tsx +++ b/src/components/CommandKClient.tsx @@ -17,6 +17,7 @@ import { PATH_ADMIN_UPLOADS, PATH_SIGN_IN, pathForPhoto, + pathForTag, } from '../site/paths'; import Modal from './Modal'; import { clsx } from 'clsx/lite'; @@ -37,6 +38,9 @@ import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import PhotoDate from '@/photo/PhotoDate'; import PhotoTiny from '@/photo/PhotoTiny'; import { FaCheck } from 'react-icons/fa6'; +import { TagsWithMeta, addHiddenToTags } from '@/tag'; +import { FaTag } from 'react-icons/fa'; +import { formatCount, formatCountDescriptive } from '@/utility/string'; const LISTENER_KEYDOWN = 'keydown'; const MINIMUM_QUERY_LENGTH = 2; @@ -44,9 +48,9 @@ const MINIMUM_QUERY_LENGTH = 2; type CommandKItem = { label: string keywords?: string[] + accessory?: ReactNode annotation?: ReactNode annotationAria?: string - accessory?: ReactNode path?: string action?: () => void | Promise } @@ -58,10 +62,12 @@ export type CommandKSection = { } export default function CommandKClient({ + tags, serverSections = [], showDebugTools, footer, }: { + tags: TagsWithMeta serverSections?: CommandKSection[] showDebugTools?: boolean footer?: string @@ -70,6 +76,7 @@ export default function CommandKClient({ isUserSignedIn, setUserEmail, isCommandKOpen: isOpen, + hiddenPhotosCount, arePhotosMatted, shouldShowBaselineGrid, shouldDebugBlur, @@ -173,6 +180,24 @@ export default function CommandKClient({ } }, [isOpen, setShouldRespondToKeyboardCommands]); + const tagsIncludingHidden = useMemo(() => + addHiddenToTags(tags, hiddenPhotosCount) + , [tags, hiddenPhotosCount]); + + const SECTION_TAGS: CommandKSection = { + heading: 'Tags', + accessory: , + items: tagsIncludingHidden.map(({ tag, count }) => ({ + label: tag, + annotation: formatCount(count), + annotationAria: formatCountDescriptive(count), + path: pathForTag(tag), + })), + }; + const clientSections: CommandKSection[] = [{ heading: 'Theme', accessory: {queriedSections + .concat(SECTION_TAGS) .concat(serverSections) .concat(sectionPages) .concat(adminSection) diff --git a/src/components/FieldSetWithStatus.tsx b/src/components/FieldSetWithStatus.tsx index bf8de66d..2c0895b3 100644 --- a/src/components/FieldSetWithStatus.tsx +++ b/src/components/FieldSetWithStatus.tsx @@ -59,7 +59,7 @@ export default function FieldSetWithStatus({ ({note}) } - {isModified && + {isModified && !error && diff --git a/src/photo/InfinitePhotoScroll.tsx b/src/photo/InfinitePhotoScroll.tsx index 64cf4df8..4c221e18 100644 --- a/src/photo/InfinitePhotoScroll.tsx +++ b/src/photo/InfinitePhotoScroll.tsx @@ -58,12 +58,12 @@ export default function InfinitePhotoScroll({ ? getPhotosCachedAction( initialOffset + size * itemsPerPage, itemsPerPage, - includeHiddenPhotos, + includeHiddenPhotos ? 'include' : 'exclude', ) : getPhotosAction( initialOffset + size * itemsPerPage, itemsPerPage, - includeHiddenPhotos, + includeHiddenPhotos ? 'include' : 'exclude', ) , [useCachedPhotos, initialOffset, itemsPerPage, includeHiddenPhotos]); diff --git a/src/photo/PhotoDetailPage.tsx b/src/photo/PhotoDetailPage.tsx index d0f0eb4b..23785659 100644 --- a/src/photo/PhotoDetailPage.tsx +++ b/src/photo/PhotoDetailPage.tsx @@ -10,6 +10,8 @@ import { Camera } from '@/camera'; import CameraHeader from '@/camera/CameraHeader'; import { FilmSimulation } from '@/simulation'; import FilmSimulationHeader from '@/simulation/FilmSimulationHeader'; +import { TAG_HIDDEN } from '@/tag'; +import HiddenHeader from '@/tag/HiddenHeader'; export default function PhotoDetailPage({ photo, @@ -35,8 +37,13 @@ export default function PhotoDetailPage({ {tag && + : + addHiddenToTags(tags, hiddenPhotosCount) + , [tags, hiddenPhotosCount]); + return ( <> {tags.length > 0 && } - items={tags.map(({ tag, count }) => tag === TAG_FAVS - ? - : )} + items={tagsIncludingHidden.map(({ tag, count }) => { + switch (tag) { + case TAG_FAVS: + return ; + case TAG_HIDDEN: + return ; + default: + return ; + } + })} />} {cameras.length > 0 && {entity} @@ -59,9 +59,9 @@ export default function PhotoSetHeader({ )}> {selectedPhotoIndex !== undefined // eslint-disable-next-line max-len - ? `${entityVerb} ${selectedPhotoIndex + 1} of ${count ?? photos.length}` + ? `${entityVerb ? `${entityVerb} ` : ''}${selectedPhotoIndex + 1} of ${count ?? photos.length}` : entityDescription} - {selectedPhotoIndex === undefined && + {selectedPhotoIndex === undefined && sharePath && safelyRunAdminServerAction(async () => { const photo = convertFormDataToPhotoDbInsert(formData, true); - const updatedUrl = await convertUploadToPhoto(photo.url, photo.id); + const updatedUrl = await convertUploadToPhoto(photo.url); if (updatedUrl) { photo.url = updatedUrl; } @@ -62,6 +64,14 @@ export const updatePhotoAction = async (formData: FormData) => safelyRunAdminServerAction(async () => { const photo = convertFormDataToPhotoDbInsert(formData); + let url: string | undefined; + if (photo.hidden && photo.url.includes(photo.id)) { + // Anonymize storage url on update if necessary by + // re-running image upload transfer logic + url = await convertUploadToPhoto(photo.url); + if (url) { photo.url = url; } + } + await sqlUpdatePhoto(photo); revalidatePhoto(photo.id); @@ -196,21 +206,30 @@ export const streamAiImageQueryAction = async ( export const getImageBlurAction = async (url: string) => safelyRunAdminServerAction(() => blurImageFromUrl(url)); -// Public actions +export const getPhotosTagHiddenMetaCachedAction = async () => + safelyRunAdminServerAction(getPhotosTagHiddenMetaCached); + +// Public/Private actions export const getPhotosAction = async ( offset: number, limit: number, - includeHidden?: boolean, -) => - getPhotos({ offset, includeHidden, limit }); + hidden?: GetPhotosOptions['hidden'], +) => (hidden === 'include' || hidden === 'only') + ? safelyRunAdminServerAction(() => + getPhotos({ offset, hidden, limit })) + : getPhotos({ offset, hidden, limit }); export const getPhotosCachedAction = async ( offset: number, limit: number, - includeHidden?: boolean, -) => - getPhotosCachedCached({ offset, includeHidden, limit }); + hidden?: GetPhotosOptions['hidden'], +) => (hidden === 'include' || hidden === 'only') + ? safelyRunAdminServerAction(() => + getPhotosCachedCached({ offset, hidden, limit })) + : getPhotosCachedCached({ offset, hidden, limit }); + +// Public actions export const queryPhotosByTitleAction = async (query: string) => (await getPhotos({ query, limit: 10 })) diff --git a/src/photo/cache.ts b/src/photo/cache.ts index 5305b8c5..2d493c4c 100644 --- a/src/photo/cache.ts +++ b/src/photo/cache.ts @@ -20,6 +20,7 @@ import { getPhotosDateRange, getPhotosNearId, getPhotosMostRecentUpdate, + getPhotosTagHiddenMeta, } from '@/photo/db'; import { parseCachedPhotoDates, parseCachedPhotosDates } from '@/photo'; import { createCameraKey } from '@/camera'; @@ -179,6 +180,12 @@ export const getPhotosTagMetaCached = [KEY_PHOTOS, KEY_TAGS, KEY_DATE_RANGE], ); +export const getPhotosTagHiddenMetaCached = + unstable_cache( + getPhotosTagHiddenMeta, + [KEY_PHOTOS, KEY_TAGS, KEY_HIDDEN, KEY_DATE_RANGE], + ); + export const getPhotosCameraMetaCached = unstable_cache( getPhotosCameraMeta, diff --git a/src/photo/db.ts b/src/photo/db.ts index 7bca89f7..bccc0ae7 100644 --- a/src/photo/db.ts +++ b/src/photo/db.ts @@ -173,11 +173,10 @@ export const sqlDeletePhoto = (id: string) => 'sqlDeletePhoto', ); -const sqlGetPhoto = (id: string) => - safelyQueryPhotos( - () => sql`SELECT * FROM photos WHERE id=${id} LIMIT 1`, - 'sqlGetPhoto', - ); +const sqlGetPhoto = (id: string, includeHidden?: boolean) => includeHidden + ? sql`SELECT * FROM photos WHERE id=${id} LIMIT 1` + // eslint-disable-next-line max-len + : sql`SELECT * FROM photos WHERE id=${id} AND hidden IS NOT TRUE LIMIT 1`; const sqlGetPhotosCount = async () => sql` SELECT COUNT(*) FROM photos @@ -212,6 +211,17 @@ const sqlGetPhotosTagMeta = async (tag: string) => sql` : undefined, })); +const sqlGetPhotosTagHiddenMeta = async () => sql` + SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end + FROM photos + WHERE hidden IS TRUE + `.then(({ rows }) => ({ + count: parseInt(rows[0].count, 10), + ...rows[0]?.start && rows[0]?.end + ? { dateRange: rows[0] as PhotoDateRange } + : undefined, + })); + const sqlGetPhotosCameraMeta = async (camera: Camera) => sql` SELECT COUNT(*), MIN(taken_at_naive) as start, MAX(taken_at_naive) as end FROM photos @@ -297,7 +307,7 @@ export type GetPhotosOptions = { simulation?: FilmSimulation takenBefore?: Date takenAfterInclusive?: Date - includeHidden?: boolean + hidden?: 'exclude' | 'include' | 'only' } const safelyQueryPhotos = async ( @@ -359,7 +369,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { simulation, takenBefore, takenAfterInclusive, - includeHidden, + hidden = 'exclude', } = options; let sql = ['SELECT * FROM photos']; @@ -368,8 +378,14 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { // WHERE let wheres = [] as string[]; - if (!includeHidden) { + + switch (hidden) { + case 'exclude': wheres.push('hidden IS NOT TRUE'); + break; + case 'only': + wheres.push('hidden IS TRUE'); + break; } if (takenBefore) { wheres.push(`taken_at > $${valueIndex++}`); @@ -428,6 +444,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => { export const getPhotosNearId = async ( id: string, limit: number, + onlyHidden?: boolean, ) => { const orderBy = PRIORITY_ORDER_ENABLED ? 'ORDER BY priority_order ASC, taken_at DESC' @@ -440,7 +457,7 @@ export const getPhotosNearId = async ( SELECT *, row_number() OVER (${orderBy}) as row_number FROM photos - WHERE hidden IS NOT TRUE + WHERE hidden is ${onlyHidden ? 'TRUE' : 'NOT TRUE'} ), current AS (SELECT row_number FROM twi WHERE id = $1) SELECT twi.* @@ -468,11 +485,15 @@ export const getPhotoIds = async ({ limit }: { limit?: number }) => { .then(({ rows }) => rows.map(({ id }) => id as string)); }; -export const getPhoto = async (id: string): Promise => { +export const getPhoto = async ( + id: string, + includeHidden?: boolean, +): Promise => { // Check for photo id forwarding // and convert short ids to uuids const photoId = translatePhotoId(id); - return safelyQueryPhotos(() => sqlGetPhoto(photoId), 'getPhoto') + return safelyQueryPhotos(() => + sqlGetPhoto(photoId, includeHidden), 'sqlGetPhoto') .then(({ rows }) => rows.map(parsePhotoFromDb)) .then(photos => photos.length > 0 ? photos[0] : undefined); }; @@ -497,10 +518,9 @@ export const getUniqueTags = () => export const getUniqueTagsHidden = () => safelyQueryPhotos(sqlGetUniqueTagsHidden, 'getUniqueTagsHidden'); export const getPhotosTagMeta = (tag: string) => - safelyQueryPhotos( - () => sqlGetPhotosTagMeta(tag), - 'getPhotosTagMeta', - ); + safelyQueryPhotos(() => sqlGetPhotosTagMeta(tag), 'getPhotosTagMeta'); +export const getPhotosTagHiddenMeta = () => + safelyQueryPhotos(sqlGetPhotosTagHiddenMeta, 'sqlGetPhotosTagHiddenMeta'); // CAMERAS export const getUniqueCameras = () => diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 9b9cf7ab..e6df9f75 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -16,7 +16,7 @@ import { } from '@/vendors/fujifilm'; import { FilmSimulation } from '@/simulation'; import { GEO_PRIVACY_ENABLED } from '@/site/config'; -import { TAG_FAVS, doesTagsStringIncludeFavs } from '@/tag'; +import { TAG_FAVS, TAG_HIDDEN, doesStringContainReservedTags } from '@/tag'; type VirtualFields = 'favorite'; @@ -76,8 +76,8 @@ const FORM_METADATA = ( tags: { label: 'tags', tagOptions, - validate: tags => doesTagsStringIncludeFavs(tags) - ? `'${TAG_FAVS}' is a reserved tag` + validate: tags => doesStringContainReservedTags(tags) + ? `Reserved tags (${TAG_FAVS}, ${TAG_HIDDEN})` : undefined, }, semanticDescription: { @@ -141,10 +141,9 @@ export const isFormValid = (formData: Partial) => FORM_METADATA_ENTRIES().every( ([key, { required, validate, validateStringMaxLength }]) => (!required || Boolean(formData[key])) && - (validate?.(formData[key]) === undefined) && + (!validate?.(formData[key])) && // eslint-disable-next-line max-len - (!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength) && - (key !== 'tags' || !doesTagsStringIncludeFavs(formData.tags ?? '')) + (!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength) ); export const formHasTextContent = ({ diff --git a/src/photo/index.ts b/src/photo/index.ts index e31687ef..264876cf 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -201,7 +201,7 @@ export const deleteConfirmationTextForPhoto = (photo: Photo) => export type PhotoDateRange = { start: string, end: string }; export const descriptionForPhotoSet = ( - photos:Photo[], + photos:Photo[] = [], descriptor?: string, dateBased?: boolean, explicitCount?: number, diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index 0f3e008e..481cff36 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -129,53 +129,66 @@ export const uploadPhotoFromClient = async ( ? uploadFromClientViaPresignedUrl(file, PREFIX_UPLOAD, extension, true) : vercelBlobUploadFromClient(file, `${PREFIX_UPLOAD}.${extension}`); -export const convertUploadToPhoto = async ( - uploadUrl: string, - photoId?: string, -): Promise => { - const fileName = photoId ? `${PREFIX_PHOTO}-${photoId}` : `${PREFIX_PHOTO}`; - const fileExtension = getExtensionFromStorageUrl(uploadUrl); - const photoPath = `${fileName}.${fileExtension ?? 'jpg'}`; - - const storageType = storageTypeFromUrl(uploadUrl); +const moveFile = async ( + originUrl: string, + destinationFileName: string, +) => { + const storageType = storageTypeFromUrl(originUrl); let url: string | undefined; // Copy file switch (storageType) { case 'vercel-blob': - url = await vercelBlobCopy(uploadUrl, photoPath, photoId === undefined); + url = await vercelBlobCopy( + originUrl, + destinationFileName, + false, + ); break; case 'cloudflare-r2': url = await cloudflareR2Copy( - getFileNameFromStorageUrl(uploadUrl), - photoPath, - photoId === undefined, + getFileNameFromStorageUrl(originUrl), + destinationFileName, + false, ); break; case 'aws-s3': - url = await awsS3Copy(uploadUrl, photoPath, photoId === undefined); + url = await awsS3Copy( + originUrl, + destinationFileName, + false, + ); break; } - + // If successful, delete original file if (url) { switch (storageType) { case 'vercel-blob': - await vercelBlobDelete(uploadUrl); + await vercelBlobDelete(originUrl); break; case 'cloudflare-r2': - await cloudflareR2Delete(getFileNameFromStorageUrl(uploadUrl)); + await cloudflareR2Delete(getFileNameFromStorageUrl(originUrl)); break; case 'aws-s3': - await awsS3Delete(getFileNameFromStorageUrl(uploadUrl)); + await awsS3Delete(getFileNameFromStorageUrl(originUrl)); break; } } - + return url; }; +export const convertUploadToPhoto = async ( + urlOrigin: string, +): Promise => { + const fileName = `${PREFIX_PHOTO}-${generateStorageId()}`; + const fileExtension = getExtensionFromStorageUrl(urlOrigin); + const photoPath = `${fileName}.${fileExtension || 'jpg'}`; + return moveFile(urlOrigin, photoPath); +}; + export const deleteStorageUrl = (url: string) => { switch (storageTypeFromUrl(url)) { case 'vercel-blob': diff --git a/src/site/CommandK.tsx b/src/site/CommandK.tsx index 77c7f39e..e62f87c8 100644 --- a/src/site/CommandK.tsx +++ b/src/site/CommandK.tsx @@ -8,14 +8,12 @@ import { import { pathForCamera, pathForFilmSimulation, - pathForTag, } from './paths'; import { formatCameraText } from '@/camera'; import { photoQuantityText } from '@/photo'; import { formatCount, formatCountDescriptive } from '@/utility/string'; -import { sortTagsObject } from '@/tag'; +import { TagsWithMeta } from '@/tag'; import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon'; -import { FaTag } from 'react-icons/fa'; import { IoMdCamera } from 'react-icons/io'; import { ADMIN_DEBUG_TOOLS_ENABLED } from './config'; @@ -27,25 +25,11 @@ export default async function CommandK() { filmSimulations, ] = await Promise.all([ getPhotosCountCached().catch(() => 0), - getUniqueTagsCached().catch(() => []), + getUniqueTagsCached().catch(() => [] as TagsWithMeta), getUniqueCamerasCached().catch(() => []), getUniqueFilmSimulationsCached().catch(() => []), ]); - const SECTION_TAGS: CommandKSection = { - heading: 'Tags', - accessory: , - items: sortTagsObject(tags).map(({ tag, count }) => ({ - label: tag, - annotation: formatCount(count), - annotationAria: formatCountDescriptive(count), - path: pathForTag(tag), - })), - }; - const SECTION_CAMERAS: CommandKSection = { heading: 'Cameras', accessory: , @@ -71,8 +55,8 @@ export default async function CommandK() { }; return - tag - ? `${pathForTag(tag)}/${getPhotoId(photo)}` - : camera - ? `${pathForCamera(camera)}/${getPhotoId(photo)}` - : simulation - ? `${pathForFilmSimulation(simulation)}/${getPhotoId(photo)}` - : `${PREFIX_PHOTO}/${getPhotoId(photo)}`; + typeof photo !== 'string' && photo.hidden + ? `${pathForTag(TAG_HIDDEN)}/${getPhotoId(photo)}` + : tag + ? `${pathForTag(tag)}/${getPhotoId(photo)}` + : camera + ? `${pathForCamera(camera)}/${getPhotoId(photo)}` + : simulation + ? `${pathForFilmSimulation(simulation)}/${getPhotoId(photo)}` + : `${PREFIX_PHOTO}/${getPhotoId(photo)}`; export const pathForPhotoShare = ( photo: PhotoOrPhotoId, @@ -248,7 +251,8 @@ export const isPathAdminConfiguration = (pathname?: string) => checkPathPrefix(pathname, PATH_ADMIN_CONFIGURATION); export const isPathProtected = (pathname?: string) => - checkPathPrefix(pathname, PATH_ADMIN); + checkPathPrefix(pathname, PATH_ADMIN) || + checkPathPrefix(pathname, pathForTag(TAG_HIDDEN)); export const getPathComponents = (pathname = ''): { photoId?: string diff --git a/src/state/AppState.ts b/src/state/AppState.ts index 47af4712..1ceb2791 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -2,29 +2,33 @@ import { Dispatch, SetStateAction, createContext, useContext } from 'react'; import { AnimationConfig } from '@/components/AnimateItems'; export interface AppStateContext { + // CORE previousPathname?: string hasLoaded?: boolean - arePhotosMatted?: boolean - setArePhotosMatted?: Dispatch> + setHasLoaded?: Dispatch> swrTimestamp?: number invalidateSwr?: () => void - userEmail?: string - setUserEmail?: Dispatch> - isUserSignedIn?: boolean - setHasLoaded?: Dispatch> nextPhotoAnimation?: AnimationConfig setNextPhotoAnimation?: Dispatch> + clearNextPhotoAnimation?: () => void shouldRespondToKeyboardCommands?: boolean setShouldRespondToKeyboardCommands?: Dispatch> isCommandKOpen?: boolean setIsCommandKOpen?: Dispatch> + // ADMIN + userEmail?: string + setUserEmail?: Dispatch> + isUserSignedIn?: boolean adminUpdateTimes?: Date[] registerAdminUpdate?: () => void - shouldShowBaselineGrid?: boolean - setShouldShowBaselineGrid?: Dispatch> + hiddenPhotosCount?: number + // DEBUG + arePhotosMatted?: boolean + setArePhotosMatted?: Dispatch> shouldDebugBlur?: boolean setShouldDebugBlur?: Dispatch> - clearNextPhotoAnimation?: () => void + shouldShowBaselineGrid?: boolean + setShouldShowBaselineGrid?: Dispatch> } export const AppStateContext = createContext({}); diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index d9967814..e6bafb2e 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -7,6 +7,7 @@ import usePathnames from '@/utility/usePathnames'; import { getAuthAction, logClientAuthUpdate } from '@/auth/actions'; import useSWR from 'swr'; import { MATTE_PHOTOS } from '@/site/config'; +import { getPhotosTagHiddenMetaCachedAction } from '@/photo/actions'; export default function AppStateProvider({ children, @@ -15,25 +16,31 @@ export default function AppStateProvider({ }) { const { previousPathname } = usePathnames(); + // CORE const [hasLoaded, setHasLoaded] = useState(false); - const [arePhotosMatted, setArePhotosMatted] = - useState(MATTE_PHOTOS); const [swrTimestamp, setSwrTimestamp] = useState(Date.now()); - const [userEmail, setUserEmail] = - useState(); const [nextPhotoAnimation, setNextPhotoAnimation] = useState(); const [shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands] = useState(true); const [isCommandKOpen, setIsCommandKOpen] = useState(false); - const [adminUpdateTimes, setAdminUpdateTimes] = useState([]); - const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = - useState(false); + // ADMIN + const [userEmail, setUserEmail] = + useState(); + const [adminUpdateTimes, setAdminUpdateTimes] = + useState([]); + const [hiddenPhotosCount, setHiddenPhotosCount] = + useState(0); + // DEBUG + const [arePhotosMatted, setArePhotosMatted] = + useState(MATTE_PHOTOS); const [shouldDebugBlur, setShouldDebugBlur] = useState(false); + const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = + useState(false); const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); @@ -42,6 +49,18 @@ export default function AppStateProvider({ setUserEmail(data?.user?.email ?? undefined); logClientAuthUpdate(data); }, [data]); + const isUserSignedIn = Boolean(userEmail); + useEffect(() => { + if (isUserSignedIn) { + const timeout = setTimeout(() => + getPhotosTagHiddenMetaCachedAction().then(({ count }) => + setHiddenPhotosCount(count)) + , 100); + return () => clearTimeout(timeout); + } else { + setHiddenPhotosCount(0); + } + }, [isUserSignedIn]); const registerAdminUpdate = useCallback(() => setAdminUpdateTimes(updates => [...updates, new Date()]) @@ -54,29 +73,33 @@ export default function AppStateProvider({ return ( setNextPhotoAnimation?.(undefined), shouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands, isCommandKOpen, setIsCommandKOpen, + // ADMIN + userEmail, + setUserEmail, + isUserSignedIn, adminUpdateTimes, registerAdminUpdate, - shouldShowBaselineGrid, - shouldDebugBlur, + hiddenPhotosCount, + // DEBUG + arePhotosMatted, + setArePhotosMatted, setShouldDebugBlur, setShouldShowBaselineGrid, - clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined), + shouldShowBaselineGrid, + shouldDebugBlur, }} > {children} diff --git a/src/tag/FavsTag.tsx b/src/tag/FavsTag.tsx index 7bc211da..f2e82c3d 100644 --- a/src/tag/FavsTag.tsx +++ b/src/tag/FavsTag.tsx @@ -17,16 +17,15 @@ export default function FavsTag({ } & EntityLinkExternalProps) { return ( - {TAG_FAVS} - - - : TAG_FAVS} + label={badged + ? + {TAG_FAVS} + + + : TAG_FAVS} href={pathForTag(TAG_FAVS)} icon={!badged && } + entityDescription={photoQuantityText(count, false)} + photos={photos} + selectedPhoto={selectedPhoto} + /> + ); +} diff --git a/src/tag/HiddenTag.tsx b/src/tag/HiddenTag.tsx new file mode 100644 index 00000000..e9b176f2 --- /dev/null +++ b/src/tag/HiddenTag.tsx @@ -0,0 +1,37 @@ +import { TAG_HIDDEN } from '.'; +import { pathForTag } from '@/site/paths'; +import EntityLink, { + EntityLinkExternalProps, +} from '@/components/primitives/EntityLink'; +import { AiOutlineEyeInvisible } from 'react-icons/ai'; + +export default function HiddenTag({ + type, + badged, + contrast, + prefetch, + countOnHover, +}: { + countOnHover?: number +} & EntityLinkExternalProps) { + return ( + + {TAG_HIDDEN} + + + : TAG_HIDDEN} + href={pathForTag(TAG_HIDDEN)} + icon={!badged && } + type={type} + hoverEntity={countOnHover} + badged={badged} + contrast={contrast} + prefetch={prefetch} + /> + ); +} diff --git a/src/tag/TagHeader.tsx b/src/tag/TagHeader.tsx index fe97f856..eeb812c0 100644 --- a/src/tag/TagHeader.tsx +++ b/src/tag/TagHeader.tsx @@ -21,7 +21,7 @@ export default function TagHeader({ return ( + ? : } entityVerb="Tagged" entityDescription={descriptionForTaggedPhotos(photos, undefined, count)} diff --git a/src/tag/index.ts b/src/tag/index.ts index ec42f22c..fc85ee32 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -11,7 +11,9 @@ import { } from '@/site/paths'; import { capitalizeWords, convertStringToArray } from '@/utility/string'; -export const TAG_FAVS = 'favs'; +// Reserved/virtual tags +export const TAG_FAVS = 'favs'; // Reserved +export const TAG_HIDDEN = 'hidden'; // Virtual export type TagsWithMeta = { tag: string @@ -21,12 +23,15 @@ export type TagsWithMeta = { export const formatTag = (tag?: string) => capitalizeWords(tag?.replaceAll('-', ' ')); -export const doesTagsStringIncludeFavs = (tags?: string) => - convertStringToArray(tags)?.some(tag => isTagFavs(tag)); +export const doesStringContainReservedTags = (tags?: string) => + convertStringToArray(tags)?.some(tag => ( + isTagFavs(tag) || + tag.toLowerCase() === TAG_HIDDEN + )); export const titleForTag = ( tag: string, - photos:Photo[], + photos:Photo[] = [], explicitCount?: number, ) => [ formatTag(tag), @@ -54,7 +59,7 @@ export const sortTagsObjectWithoutFavs = (tags: TagsWithMeta) => sortTagsObject(tags, TAG_FAVS); export const descriptionForTaggedPhotos = ( - photos: Photo[], + photos: Photo[] = [], dateBased?: boolean, explicitCount?: number, explicitDateRange?: PhotoDateRange, @@ -86,3 +91,14 @@ export const isPhotoFav = ({ tags }: Photo) => tags.some(isTagFavs); export const isPathFavs = (pathname?: string) => getPathComponents(pathname).tag === TAG_FAVS; + +export const addHiddenToTags = (tags: TagsWithMeta, hiddenPhotosCount = 0) => { + if (hiddenPhotosCount > 0) { + return tags + .filter(({ tag }) => tag === TAG_FAVS) + .concat({ tag: TAG_HIDDEN, count: hiddenPhotosCount }) + .concat(tags.filter(({ tag }) => tag !== TAG_FAVS)); + } else { + return tags; + } +};