diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 0d5033a..3d92540 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -162,8 +162,17 @@ async def list_photos( if filters: query = query.where(and_(*filters)) - # Apply sorting - sort_column = getattr(Photo, sort, Photo.taken_at) + # Apply sorting. The sort field is whitelisted so a malicious client + # can't pass an arbitrary column name (e.g. "filepath" leaks paths or + # any other Photo attribute the model exposes). + SORT_WHITELIST = { + "taken_at": Photo.taken_at, + "added_at": Photo.added_at, + "filename": Photo.filename, + "file_size": Photo.file_size, + "rating": Photo.rating, + } + sort_column = SORT_WHITELIST.get(sort, Photo.taken_at) if order == "desc": query = query.order_by(sort_column.desc()) else: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c6d15d6..5c9b54a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Timeline } from './components/timeline/Timeline' import { LeftSidebar } from './components/layout/LeftSidebar' import { RightSidebar } from './components/layout/RightSidebar' @@ -37,13 +37,16 @@ function App() { // Auto-show right sidebar when photos are selected — but only in grid mode, // so leaving the preview doesn't fight the user's prior sidebar state. - if (viewMode === 'grid') { + // Lives in an effect (not the render body) to avoid setState-during-render + // and the cascading re-renders the audit caught. + useEffect(() => { + if (viewMode !== 'grid') return if (selectedPhotos.length > 0 && !rightSidebarOpen) { setRightSidebarOpen(true) } else if (selectedPhotos.length === 0 && rightSidebarOpen) { setRightSidebarOpen(false) } - } + }, [viewMode, selectedPhotos.length, rightSidebarOpen]) const showRightSidebar = rightSidebarOpen && viewMode === 'grid' diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx index cafb23f..ce6c4c1 100644 --- a/frontend/src/components/filter/FilterBar.tsx +++ b/frontend/src/components/filter/FilterBar.tsx @@ -4,11 +4,11 @@ import { useFilterStore, hasActiveFilters, type MediaType, - type ColorLabel, type SortField, } from '../../store/filterStore' import { useTagsQuery } from '../../hooks/useTagsQuery' import { FilterPill } from './FilterPill' +import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' const MEDIA_TYPES: { value: MediaType; label: string }[] = [ { value: 'photo', label: 'Photo' }, @@ -17,15 +17,6 @@ const MEDIA_TYPES: { value: MediaType; label: string }[] = [ { value: 'heic', label: 'HEIC' }, ] -const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [ - { value: 'red', className: 'bg-red-500' }, - { value: 'orange', className: 'bg-orange-500' }, - { value: 'yellow', className: 'bg-yellow-400' }, - { value: 'green', className: 'bg-green-500' }, - { value: 'blue', className: 'bg-blue-500' }, - { value: 'purple', className: 'bg-purple-500' }, -] - const SORT_OPTIONS: { value: SortField; label: string }[] = [ { value: 'taken_at', label: 'Date taken' }, { value: 'added_at', label: 'Date added' }, diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index f370e94..7107737 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -7,17 +7,7 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { toast } from '../ToastContainer' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' - -type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' - -const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [ - { value: 'red', className: 'bg-red-500' }, - { value: 'orange', className: 'bg-orange-500' }, - { value: 'yellow', className: 'bg-yellow-400' }, - { value: 'green', className: 'bg-green-500' }, - { value: 'blue', className: 'bg-blue-500' }, - { value: 'purple', className: 'bg-purple-500' }, -] +import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' /** * Right-hand details panel. diff --git a/frontend/src/components/sidebar/PhotoInfoPanel.tsx b/frontend/src/components/sidebar/PhotoInfoPanel.tsx index 32529a7..d45fbef 100644 --- a/frontend/src/components/sidebar/PhotoInfoPanel.tsx +++ b/frontend/src/components/sidebar/PhotoInfoPanel.tsx @@ -23,6 +23,10 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery' import { toast } from '../ToastContainer' +import { + COLOR_LABEL_OPTIONS, + type ColorLabel, +} from '../../constants/colorLabels' interface PhotoTagSummary { id: string @@ -47,17 +51,6 @@ interface PhotoDetails { tags?: PhotoTagSummary[] } -type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' - -const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [ - { value: 'red', className: 'bg-red-500' }, - { value: 'orange', className: 'bg-orange-500' }, - { value: 'yellow', className: 'bg-yellow-400' }, - { value: 'green', className: 'bg-green-500' }, - { value: 'blue', className: 'bg-blue-500' }, - { value: 'purple', className: 'bg-purple-500' }, -] - interface ExifData { Make?: string Model?: string diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index c160db7..6a551d2 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -137,9 +137,7 @@ function buildItems( photos.forEach((photo, globalIndex) => { const dateStr = - sortBy === 'taken_at' - ? photo.taken_at - : (photo as any).added_at ?? photo.taken_at + sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at let label: string if (dateStr) { try { diff --git a/frontend/src/constants/colorLabels.ts b/frontend/src/constants/colorLabels.ts new file mode 100644 index 0000000..8993923 --- /dev/null +++ b/frontend/src/constants/colorLabels.ts @@ -0,0 +1,22 @@ +/** + * Single source of truth for the six Lightroom-style color labels. + * Both filter UIs and edit UIs (FilterBar, PhotoInfoPanel, RightSidebar) + * read from this list so dot colors and ordering stay consistent. + */ + +export type ColorLabel = + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + +export const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [ + { value: 'red', className: 'bg-red-500' }, + { value: 'orange', className: 'bg-orange-500' }, + { value: 'yellow', className: 'bg-yellow-400' }, + { value: 'green', className: 'bg-green-500' }, + { value: 'blue', className: 'bg-blue-500' }, + { value: 'purple', className: 'bg-purple-500' }, +] diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index b53ad5b..cb0d57f 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -1,7 +1,8 @@ import { create } from 'zustand' +import type { ColorLabel } from '../constants/colorLabels' export type MediaType = 'photo' | 'video' | 'raw' | 'heic' -export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' +export type { ColorLabel } export type FlagFilter = 'any' | 'discarded' export type SortField = | 'taken_at' diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts index 0f4f467..d23e46f 100644 --- a/frontend/src/types/photo.ts +++ b/frontend/src/types/photo.ts @@ -16,7 +16,8 @@ export interface Photo { is_discarded: boolean is_duplicate: boolean file_hash: string - folder_id?: string | null + folder_id: string | null + added_at: string | null thumb_small?: string thumb_medium?: string thumb_large?: string