fix: high-severity findings from code audit
- backend/photos: whitelist sortable columns instead of getattr(Photo, sort). Previously any client-supplied string was passed to SQLAlchemy, exposing every Photo attribute (filepath, file_hash, etc.) as a sort target. - App: move the auto-show-right-sidebar logic out of the render body and into a useEffect. The previous version called setState during render, causing extra re-render passes the audit caught. - types/photo: add added_at and tighten folder_id from optional to nullable. Drops a (photo as any).added_at cast in Timeline. - constants/colorLabels: extract a single COLOR_LABEL_OPTIONS used by FilterBar, RightSidebar, and PhotoInfoPanel. filterStore re-exports the ColorLabel type so existing imports keep working. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -162,8 +162,17 @@ async def list_photos(
|
|||||||
if filters:
|
if filters:
|
||||||
query = query.where(and_(*filters))
|
query = query.where(and_(*filters))
|
||||||
|
|
||||||
# Apply sorting
|
# Apply sorting. The sort field is whitelisted so a malicious client
|
||||||
sort_column = getattr(Photo, sort, Photo.taken_at)
|
# 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":
|
if order == "desc":
|
||||||
query = query.order_by(sort_column.desc())
|
query = query.order_by(sort_column.desc())
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Timeline } from './components/timeline/Timeline'
|
import { Timeline } from './components/timeline/Timeline'
|
||||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||||
import { RightSidebar } from './components/layout/RightSidebar'
|
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,
|
// 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.
|
// 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) {
|
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
|
||||||
setRightSidebarOpen(true)
|
setRightSidebarOpen(true)
|
||||||
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
||||||
setRightSidebarOpen(false)
|
setRightSidebarOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}, [viewMode, selectedPhotos.length, rightSidebarOpen])
|
||||||
|
|
||||||
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import {
|
|||||||
useFilterStore,
|
useFilterStore,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
type MediaType,
|
type MediaType,
|
||||||
type ColorLabel,
|
|
||||||
type SortField,
|
type SortField,
|
||||||
} from '../../store/filterStore'
|
} from '../../store/filterStore'
|
||||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
import { FilterPill } from './FilterPill'
|
import { FilterPill } from './FilterPill'
|
||||||
|
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||||
|
|
||||||
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
||||||
{ value: 'photo', label: 'Photo' },
|
{ value: 'photo', label: 'Photo' },
|
||||||
@@ -17,15 +17,6 @@ const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
|||||||
{ value: 'heic', label: 'HEIC' },
|
{ 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 }[] = [
|
const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
||||||
{ value: 'taken_at', label: 'Date taken' },
|
{ value: 'taken_at', label: 'Date taken' },
|
||||||
{ value: 'added_at', label: 'Date added' },
|
{ value: 'added_at', label: 'Date added' },
|
||||||
|
|||||||
@@ -7,17 +7,7 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
|||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||||
|
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||||
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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-hand details panel.
|
* Right-hand details panel.
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
|||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
|
import {
|
||||||
|
COLOR_LABEL_OPTIONS,
|
||||||
|
type ColorLabel,
|
||||||
|
} from '../../constants/colorLabels'
|
||||||
|
|
||||||
interface PhotoTagSummary {
|
interface PhotoTagSummary {
|
||||||
id: string
|
id: string
|
||||||
@@ -47,17 +51,6 @@ interface PhotoDetails {
|
|||||||
tags?: PhotoTagSummary[]
|
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 {
|
interface ExifData {
|
||||||
Make?: string
|
Make?: string
|
||||||
Model?: string
|
Model?: string
|
||||||
|
|||||||
@@ -137,9 +137,7 @@ function buildItems(
|
|||||||
|
|
||||||
photos.forEach((photo, globalIndex) => {
|
photos.forEach((photo, globalIndex) => {
|
||||||
const dateStr =
|
const dateStr =
|
||||||
sortBy === 'taken_at'
|
sortBy === 'taken_at' ? photo.taken_at : photo.added_at ?? photo.taken_at
|
||||||
? photo.taken_at
|
|
||||||
: (photo as any).added_at ?? photo.taken_at
|
|
||||||
let label: string
|
let label: string
|
||||||
if (dateStr) {
|
if (dateStr) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
22
frontend/src/constants/colorLabels.ts
Normal file
22
frontend/src/constants/colorLabels.ts
Normal file
@@ -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' },
|
||||||
|
]
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import type { ColorLabel } from '../constants/colorLabels'
|
||||||
|
|
||||||
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
|
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 FlagFilter = 'any' | 'discarded'
|
||||||
export type SortField =
|
export type SortField =
|
||||||
| 'taken_at'
|
| 'taken_at'
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export interface Photo {
|
|||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
is_duplicate: boolean
|
is_duplicate: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
folder_id?: string | null
|
folder_id: string | null
|
||||||
|
added_at: string | null
|
||||||
thumb_small?: string
|
thumb_small?: string
|
||||||
thumb_medium?: string
|
thumb_medium?: string
|
||||||
thumb_large?: string
|
thumb_large?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user