1 Commits
Author SHA1 Message Date
nils 30d0b3064e Client accept/reject votes with keyboard-driven culling
ci / docker (push) Successful in 13s
- verdicts table (per link, like ratings), PUT verdict endpoint, typed Verdict enum
- share page: thumbs up/down, verdict filter with counts, view-scoped selection bar
- lightbox: per-page shortcut table (P/X/U, 1-5/0, S), ? help overlay, action
  toast when a keyboard vote auto-advances
- shared useLightbox hook; single keydown subscription reading live state via ref
- album view: per-link thumbs and vote counts; share list shows accept/reject totals
- feedback queries deduped and run concurrently; verdict counts in one scan
2026-07-17 15:55:46 +02:00
26 changed files with 723 additions and 629 deletions
Generated
-1
View File
@@ -2574,7 +2574,6 @@ dependencies = [
"time", "time",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tower",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
-1
View File
@@ -45,7 +45,6 @@ uuid = { version = "1", features = ["v4", "serde"] }
[dev-dependencies] [dev-dependencies]
cookie = { version = "0.18", features = ["signed"] } cookie = { version = "0.18", features = ["signed"] }
tower = { version = "0.5", features = ["util"] }
[profile.release] [profile.release]
lto = "thin" lto = "thin"
+17 -20
View File
@@ -2,7 +2,8 @@
Self-hosted client gallery for photographers. Upload RAWs/JPGs into albums, Self-hosted client gallery for photographers. Upload RAWs/JPGs into albums,
share them with clients via private (optionally password-protected) links, share them with clients via private (optionally password-protected) links,
collect ratings and tags, and let clients download originals. collect accept/reject votes, ratings and tags, and let clients download
originals.
## Architecture ## Architecture
@@ -30,17 +31,16 @@ collect ratings and tags, and let clients download originals.
`photos/<photo_id>/preview.jpg`, `photos/<photo_id>/thumb.jpg`. The bucket `photos/<photo_id>/preview.jpg`, `photos/<photo_id>/thumb.jpg`. The bucket
stays fully private; all image traffic is streamed through the API with stays fully private; all image traffic is streamed through the API with
auth checks (no bucket CORS or public access needed). auth checks (no bucket CORS or public access needed).
- **Auth**: photographers sign in via any OIDC provider (authorization-code - **Auth**: photographer signs in via any OIDC provider (authorization-code
flow + userinfo); only emails in `ALLOWED_EMAILS` may sign in, and sessions flow + userinfo); only emails in `ALLOWED_EMAILS` may sign in, and sessions
are re-checked against the allowlist on every request, so removing an email are re-checked against the allowlist on every request, so removing an email
revokes access immediately. **Multi-tenant**: each allowed email is its own revokes access immediately. Clients use unguessable share tokens, optionally
workspace — albums, photos, and share links are owned per photographer and
invisible to the others (enforced via ownership-scoped data access and
covered by the tenant-isolation test matrix). Clients use unguessable share tokens, optionally
gated by an argon2-hashed password (10 wrong guesses lock the link for gated by an argon2-hashed password (10 wrong guesses lock the link for
15 minutes). 15 minutes).
- **Frontend**: React + Vite SPA — justified gallery, lightbox with rating - **Frontend**: React + Vite SPA — justified gallery, lightbox with
stars and tag chips, drag-and-drop multi-file upload with progress. accept/reject thumbs, rating stars and tag chips, keyboard-driven culling
(`P`/`X`/`U`, `1``5`, `?` shows all shortcuts), drag-and-drop multi-file
upload with progress.
## Local development ## Local development
@@ -58,13 +58,6 @@ cargo run --bin worker # job worker (separate terminal, same env)
cd frontend && npm install && npm run dev # UI on :5173, proxies /api cd frontend && npm install && npm run dev # UI on :5173, proxies /api
``` ```
Tests (the tenant-isolation matrix needs a disposable database):
```sh
createdb photos_test # or: docker compose exec postgres createdb -U photos photos_test
TEST_DATABASE_URL=postgres://photos:photos@localhost:5432/photos_test cargo test
```
Register the OIDC client with redirect URI `<PUBLIC_URL>/api/auth/callback` Register the OIDC client with redirect URI `<PUBLIC_URL>/api/auth/callback`
(locally: `http://localhost:5173/api/auth/callback`). Any standard OIDC (locally: `http://localhost:5173/api/auth/callback`). Any standard OIDC
provider works (Authentik, Keycloak, Zitadel, Dex, ...); the app uses provider works (Authentik, Keycloak, Zitadel, Dex, ...); the app uses
@@ -145,9 +138,11 @@ Notes:
- Each album can have any number of share links (`/s/<24-char-token>`), each - Each album can have any number of share links (`/s/<24-char-token>`), each
with its own label (e.g. the client's name), optional password, optional with its own label (e.g. the client's name), optional password, optional
expiry, and a per-link download toggle. expiry, and a per-link download toggle.
- Ratings (15 stars) and free-form tags are stored **per link**, so create - Accept/reject votes (👍/👎), ratings (15 stars) and free-form tags are
one link per client to keep feedback separate. The album view shows all stored **per link**, so create one link per client to keep feedback
feedback grouped by link label. separate. The album view shows all feedback grouped by link label, and
clients can filter their gallery by verdict (e.g. review only what's still
undecided, or select-all + download the accepted set).
- Clients (and you) can multi-select photos and download them — or the whole - Clients (and you) can multi-select photos and download them — or the whole
album — as a ZIP. Archives are streamed (each file spools briefly through a album — as a ZIP. Archives are streamed (each file spools briefly through a
temp file for its checksum, then pipelines while the next one prefetches), temp file for its checksum, then pipelines while the next one prefetches),
@@ -160,14 +155,16 @@ Notes:
- 10 wrong passwords lock a link for 15 minutes (fresh attempts after the - 10 wrong passwords lock a link for 15 minutes (fresh attempts after the
window). A locked link shows in the album's share list with an Unlock window). A locked link shows in the album's share list with an Unlock
button. button.
- Deleting a link removes its ratings/tags; deleting photos or albums cleans - Deleting a link removes its votes/ratings/tags; deleting photos or albums
up S3 objects via background jobs. cleans up S3 objects via background jobs.
## Known limitations / deliberate v1 cuts ## Known limitations / deliberate v1 cuts
- Full RAW develop fallback for files whose embedded preview is tiny - Full RAW develop fallback for files whose embedded preview is tiny
(exceedingly rare on modern cameras; `darktable-cli` in the worker image (exceedingly rare on modern cameras; `darktable-cli` in the worker image
would cover it). would cover it).
- Multiple photographer accounts with separate libraries (any allowed email
sees everything).
- No S3 orphan sweeper: a crash in the narrow window between an upload's S3 - No S3 orphan sweeper: a crash in the narrow window between an upload's S3
put and its DB commit can leave an unreferenced original in the bucket put and its DB commit can leave an unreferenced original in the bucket
(never data loss — just unclaimed storage). (never data loss — just unclaimed storage).
+2 -8
View File
@@ -73,15 +73,9 @@ export function postDownload(url, ids = '') {
form.remove() form.remove()
} }
// Above this, hashing whole-file in memory (WebCrypto has no streaming digest) // SHA-256 of a File, matching the server's content hash — used to skip
// risks OOM / the ~2GiB ArrayBuffer cap, so we skip the client dedup check and // uploading bytes the album already has.
// just upload — the server still dedups on arrival.
const CLIENT_HASH_LIMIT = 512 * 1024 * 1024
// SHA-256 of a File (lowercase hex), matching the server's content hash, or
// null when the file is too large to hash safely in the browser.
export async function sha256Hex(file) { export async function sha256Hex(file) {
if (file.size > CLIENT_HASH_LIMIT) return null
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer()) const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
} }
+7 -11
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { imgUrl } from '../api' import { imgUrl } from '../api'
// True justified layout: pack photos greedily into rows at their real aspect // True justified layout: pack photos greedily into rows at their real aspect
@@ -32,19 +32,15 @@ function layoutRows(photos, containerWidth, targetHeight, gap) {
} }
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) { export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
const containerRef = useRef(null)
const [width, setWidth] = useState(0) const [width, setWidth] = useState(0)
const observerRef = useRef(null)
// Callback ref: (re)attaches the observer whenever the container node useEffect(() => {
// mounts. A plain mount-effect misses the case where Gallery first renders const el = containerRef.current
// empty (no container) and photos arrive later. if (!el) return
const containerRef = useCallback((node) => {
observerRef.current?.disconnect()
if (!node) return
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width)) const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
observer.observe(node) observer.observe(el)
observerRef.current = observer return () => observer.disconnect()
setWidth(node.getBoundingClientRect().width)
}, []) }, [])
if (photos.length === 0) return null if (photos.length === 0) return null
+88 -9
View File
@@ -1,18 +1,71 @@
import { useEffect } from 'react' import { useEffect, useRef, useState } from 'react'
import { imgUrl } from '../api' import { imgUrl } from '../api'
export default function Lightbox({ photos, index, onClose, onNav, footer }) { const BASE_SHORTCUTS = [
['← / →', 'previous / next photo'],
['Space', 'next photo (Shift+Space back)'],
['?', 'show / hide shortcuts'],
['Esc', 'close'],
]
// `actions` defines the page's shortcuts as one table — display and dispatch
// come from the same entry, so the help overlay can't drift from behavior:
// { keys: ['p'], help: ['P', 'accept…'], run: (photo, key) => … }.
// Keys fire only outside text inputs and while the help overlay is closed.
export default function Lightbox({ photos, index, onClose, onNav, footer, actions }) {
const photo = photos[index] const photo = photos[index]
const [showHelp, setShowHelp] = useState(false)
// Handlers and view state live in a ref, updated every render, so the
// window listener is attached once yet always dispatches against current
// values — re-subscribing per render leaves a gap until effects re-run in
// which a fast second keystroke hits a stale closure (and e.g. re-votes
// the previous photo).
const live = useRef({})
live.current = { index, count: photos.length, photo, onClose, onNav, actions, showHelp }
useEffect(() => { useEffect(() => {
const onKey = (e) => { // Only text entry captures keys (Escape leaves the field); focus on a
if (e.key === 'Escape') onClose() // checkbox or button must not disable lightbox navigation.
if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1) const isTyping = (el) =>
if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1) el.tagName === 'TEXTAREA' ||
el.isContentEditable ||
(el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type))
const handler = (e) => {
const s = live.current
if (isTyping(e.target)) {
if (e.key === 'Escape') e.target.blur()
return
}
if (e.metaKey || e.ctrlKey || e.altKey) return
if (e.key === 'Escape') {
if (s.showHelp) setShowHelp(false)
else s.onClose()
return
}
if (e.key === '?') {
setShowHelp((h) => !h)
return
}
// With the help overlay up, keys must not act on the photo behind it.
if (s.showHelp) return
if (e.key === 'ArrowRight' || (e.key === ' ' && !e.shiftKey)) {
e.preventDefault()
if (s.index < s.count - 1) s.onNav(s.index + 1)
return
}
if (e.key === 'ArrowLeft' || (e.key === ' ' && e.shiftKey)) {
e.preventDefault()
if (s.index > 0) s.onNav(s.index - 1)
return
}
const key = e.key.toLowerCase()
const action = s.actions?.find((a) => a.keys.includes(key))
if (action && s.photo) action.run(s.photo, key)
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', handler)
}, [index, photos.length, onClose, onNav]) }, [])
useEffect(() => { useEffect(() => {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
@@ -30,6 +83,13 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
<span className="lb-count"> <span className="lb-count">
{index + 1} / {photos.length} {index + 1} / {photos.length}
</span> </span>
<button
className="lb-btn"
onClick={() => setShowHelp((h) => !h)}
title="Keyboard shortcuts (?)"
>
?
</button>
<button className="lb-btn" onClick={onClose} title="Close (Esc)"> <button className="lb-btn" onClick={onClose} title="Close (Esc)">
</button> </button>
@@ -67,6 +127,25 @@ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
{footer(photo)} {footer(photo)}
</div> </div>
)} )}
{showHelp && (
<div
className="lb-help"
onClick={(e) => {
e.stopPropagation()
setShowHelp(false)
}}
>
<div className="lb-help-card">
<h3>Keyboard shortcuts</h3>
{[...(actions?.map((a) => a.help) || []), ...BASE_SHORTCUTS].map(([keys, label]) => (
<div key={keys} className="lb-help-row">
<kbd>{keys}</kbd>
<span className="muted">{label}</span>
</div>
))}
</div>
</div>
)}
</div> </div>
) )
} }
+24
View File
@@ -0,0 +1,24 @@
// Accept/reject vote. `value` is 'accept', 'reject' or null; clicking the
// active thumb clears it. Without onChange it renders read-only.
export default function Thumbs({ value, onChange, small }) {
const thumb = (verdict, glyph, label) => (
<button
type="button"
className={`thumb${value === verdict ? ' active' : ''}`}
disabled={!onChange}
onClick={(e) => {
e.stopPropagation()
onChange(value === verdict ? null : verdict)
}}
title={onChange ? label : undefined}
>
{glyph}
</button>
)
return (
<span className={`thumbs${small ? ' thumbs-small' : ''}`}>
{thumb('accept', '👍', 'Accept (P)')}
{thumb('reject', '👎', 'Reject (X)')}
</span>
)
}
+32 -29
View File
@@ -5,6 +5,8 @@ import Gallery from '../components/Gallery'
import Lightbox from '../components/Lightbox' import Lightbox from '../components/Lightbox'
import SelectionBar, { fmtBytes } from '../components/SelectionBar' import SelectionBar, { fmtBytes } from '../components/SelectionBar'
import Stars from '../components/Stars' import Stars from '../components/Stars'
import Thumbs from '../components/Thumbs'
import useLightbox from '../useLightbox'
import useSelection from '../useSelection' import useSelection from '../useSelection'
function fmtEta(seconds) { function fmtEta(seconds) {
@@ -71,16 +73,13 @@ function UploadZone({ albumId, onUploaded }) {
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, ...patch } : x))) setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, ...patch } : x)))
const transfer = async () => { const transfer = async () => {
// Hash locally first: content the album already has is skipped // Hash locally first: content the album already has is skipped
// without transferring a single byte. Files too large to hash in the // without transferring a single byte.
// browser (null) fall straight through to a normal upload.
update({ status: 'checking' }) update({ status: 'checking' })
try { try {
const hash = await sha256Hex(item.file) const hash = await sha256Hex(item.file)
if (hash) { await api(`/api/albums/${albumId}/photos/by-hash/${hash}`)
await api(`/api/albums/${albumId}/photos/by-hash/${hash}`) update({ status: 'skipped', progress: 1 })
update({ status: 'skipped', progress: 1 }) return
return
}
} catch { } catch {
// 404 (not there yet) or hashing unavailable — upload normally. // 404 (not there yet) or hashing unavailable — upload normally.
} }
@@ -239,7 +238,8 @@ function SharesPanel({ albumId }) {
? ` · expires ${new Date(s.expires_at).toLocaleDateString()}` ? ` · expires ${new Date(s.expires_at).toLocaleDateString()}`
: ' · never expires'} : ' · never expires'}
{' · '} {' · '}
{s.rating_count} ratings, {s.tag_count} tags {s.rating_count} ratings, {s.tag_count} tags, 👍 {s.accept_count} 👎{' '}
{s.reject_count}
</span> </span>
</div> </div>
<div className="row"> <div className="row">
@@ -323,9 +323,6 @@ export default function AlbumPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [detail, setDetail] = useState(null) const [detail, setDetail] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
// Track the open photo by id, not index — the polling refetch can reorder
// the array underneath an open lightbox.
const [lightboxId, setLightboxId] = useState(null)
const load = useCallback( const load = useCallback(
() => api(`/api/albums/${id}`).then(setDetail).catch((e) => setError(e.message)), () => api(`/api/albums/${id}`).then(setDetail).catch((e) => setError(e.message)),
@@ -345,14 +342,7 @@ export default function AlbumPage() {
const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready') const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready) const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
const lightboxIndex = ready.findIndex((p) => p.id === lightboxId) const lightbox = useLightbox(ready)
// If the open photo leaves the ready list (deleted elsewhere, reprocess),
// close for good — otherwise the lightbox would pop back open when the
// photo returns to ready.
useEffect(() => {
if (lightboxId && lightboxIndex < 0) setLightboxId(null)
}, [lightboxId, lightboxIndex])
if (error) return <p className="error">{error}</p> if (error) return <p className="error">{error}</p>
if (!detail) return <p className="muted">Loading</p> if (!detail) return <p className="muted">Loading</p>
@@ -382,7 +372,7 @@ export default function AlbumPage() {
const removePhoto = async (photoId) => { const removePhoto = async (photoId) => {
if (!confirm('Delete this photo?')) return if (!confirm('Delete this photo?')) return
setLightboxId(null) lightbox.close()
await api(`/api/photos/${photoId}`, { method: 'DELETE' }) await api(`/api/photos/${photoId}`, { method: 'DELETE' })
load() load()
} }
@@ -439,15 +429,20 @@ export default function AlbumPage() {
<Gallery <Gallery
photos={ready} photos={ready}
onOpen={(i) => setLightboxId(ready[i].id)} onOpen={lightbox.openAt}
selected={selected} selected={selected}
onToggleSelect={toggle} onToggleSelect={toggle}
overlay={(p) => { overlay={(p) => {
const avg = avgRating(p.id) const avg = avgRating(p.id)
const tagCount = feedback[p.id]?.tags.length || 0 const tagCount = feedback[p.id]?.tags.length || 0
if (avg === null && tagCount === 0) return null const verdicts = feedback[p.id]?.verdicts || []
const accepts = verdicts.filter((v) => v.verdict === 'accept').length
const rejects = verdicts.length - accepts
if (avg === null && tagCount === 0 && accepts === 0 && rejects === 0) return null
return ( return (
<div className="g-overlay"> <div className="g-overlay">
{accepts > 0 && <span>👍 {accepts}</span>}
{rejects > 0 && <span>👎 {rejects}</span>}
{avg !== null && <span> {avg.toFixed(1)}</span>} {avg !== null && <span> {avg.toFixed(1)}</span>}
{tagCount > 0 && <span># {tagCount}</span>} {tagCount > 0 && <span># {tagCount}</span>}
</div> </div>
@@ -463,26 +458,34 @@ export default function AlbumPage() {
total={ready.length} total={ready.length}
selectedBytes={selectedBytes} selectedBytes={selectedBytes}
totalBytes={totalBytes} totalBytes={totalBytes}
onSelectAll={selectAll} onSelectAll={() => selectAll(ready)}
onClear={clear} onClear={clear}
onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))} onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)} onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
/> />
{lightboxIndex >= 0 && ( {lightbox.index >= 0 && (
<Lightbox <Lightbox
photos={ready} photos={ready}
index={lightboxIndex} index={lightbox.index}
onClose={() => setLightboxId(null)} onClose={lightbox.close}
onNav={(i) => setLightboxId(ready[i].id)} onNav={lightbox.openAt}
actions={[
{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) },
]}
footer={(p) => { footer={(p) => {
const fb = feedback[p.id] || { ratings: [], tags: [] } const fb = feedback[p.id] || { ratings: [], verdicts: [], tags: [] }
return ( return (
<div className="admin-footer"> <div className="admin-footer">
<div className="feedback"> <div className="feedback">
{fb.ratings.length === 0 && fb.tags.length === 0 && ( {fb.ratings.length === 0 && fb.verdicts.length === 0 && fb.tags.length === 0 && (
<span className="muted">No client feedback yet</span> <span className="muted">No client feedback yet</span>
)} )}
{fb.verdicts.map((v, i) => (
<span key={`v${i}`} className="feedback-item">
{v.share_label || 'client'}: <Thumbs value={v.verdict} small />
</span>
))}
{fb.ratings.map((r, i) => ( {fb.ratings.map((r, i) => (
<span key={`r${i}`} className="feedback-item"> <span key={`r${i}`} className="feedback-item">
{r.share_label || 'client'}: <Stars value={r.rating} small /> {r.share_label || 'client'}: <Stars value={r.rating} small />
+143 -40
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { api, postDownload } from '../api' import { api, postDownload } from '../api'
import Gallery from '../components/Gallery' import Gallery from '../components/Gallery'
@@ -6,18 +6,43 @@ import Lightbox from '../components/Lightbox'
import SelectionBar from '../components/SelectionBar' import SelectionBar from '../components/SelectionBar'
import Stars from '../components/Stars' import Stars from '../components/Stars'
import TagEditor from '../components/TagEditor' import TagEditor from '../components/TagEditor'
import Thumbs from '../components/Thumbs'
import useLightbox from '../useLightbox'
import useSelection from '../useSelection' import useSelection from '../useSelection'
const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'accept', label: '👍' },
{ key: 'reject', label: '👎' },
{ key: 'undecided', label: 'Undecided' },
]
const matchesFilter = (photo, key) =>
key === 'all' || (key === 'undecided' ? !photo.my_verdict : photo.my_verdict === key)
export default function SharePage() { export default function SharePage() {
const { token } = useParams() const { token } = useParams()
const [view, setView] = useState(null) const [view, setView] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [unlockError, setUnlockError] = useState(null) const [unlockError, setUnlockError] = useState(null)
const [lightbox, setLightbox] = useState(-1) const [filter, setFilter] = useState('all')
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(
view?.photos ?? [], const photos = view?.photos ?? []
) const visible = useMemo(() => photos.filter((p) => matchesFilter(p, filter)), [photos, filter])
const filterCounts = useMemo(() => {
const counts = { all: photos.length, accept: 0, reject: 0, undecided: 0 }
for (const p of photos) counts[p.my_verdict ?? 'undecided'] += 1
return counts
}, [photos])
// Selection spans the whole album (so switching filters keeps it), while
// the selection bar describes only the current view: its counts, bytes and
// downloads cover the visible photos, and "select all" adds them.
const { selected, toggle, selectAll, clear } = useSelection(photos)
const visibleSelected = visible.filter((p) => selected.has(p.id))
const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0)
const lightbox = useLightbox(visible)
const load = useCallback( const load = useCallback(
() => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)), () => api(`/api/share/${token}`).then(setView).catch((e) => setError(e.message)),
@@ -45,29 +70,75 @@ export default function SharePage() {
})) }))
} }
const setRating = async (photo, rating) => { // Optimistic write: patch local state, PUT, reload from the server on
patchPhoto(photo.id, { my_rating: rating || null }) // failure to undo the patch.
const saveFeedback = async (photo, patch, endpoint, body) => {
patchPhoto(photo.id, patch)
try { try {
await api(`/api/share/${token}/photos/${photo.id}/rating`, { await api(`/api/share/${token}/photos/${photo.id}/${endpoint}`, { method: 'PUT', body })
method: 'PUT',
body: { rating },
})
} catch { } catch {
load() load()
} }
} }
const setRating = (photo, rating) =>
saveFeedback(photo, { my_rating: rating || null }, 'rating', { rating })
const setVerdict = (photo, verdict) =>
saveFeedback(photo, { my_verdict: verdict }, 'verdict', { verdict })
const setTags = (photo, tags) => saveFeedback(photo, { my_tags: tags }, 'tags', { tags })
// Keyboard votes navigate away from the photo they change, so a transient
// toast names what just happened to it — without it the jump reads as
// "did that register?".
const [flash, setFlash] = useState(null)
const flashSeq = useRef(0)
const flashTimer = useRef()
const showFlash = (text) => {
flashSeq.current += 1
setFlash({ text, key: flashSeq.current })
clearTimeout(flashTimer.current)
flashTimer.current = setTimeout(() => setFlash(null), 1400)
}
useEffect(() => () => clearTimeout(flashTimer.current), [])
// Culling flow: vote, then advance to the photo that was next in the
// current view. Under a filter that hides the voted photo, the advance
// target stays visible, so the run continues seamlessly.
const voteAndAdvance = (photo, verdict) => {
const next = visible[lightbox.index + 1]
setVerdict(photo, verdict)
showFlash(
verdict === 'accept'
? `👍 ${photo.filename}`
: verdict === 'reject'
? `👎 ${photo.filename}`
: `${photo.filename} cleared`,
)
if (next) lightbox.show(next.id)
}
const setTags = async (photo, tags) => { const keyActions = [
patchPhoto(photo.id, { my_tags: tags }) { keys: ['p'], help: ['P', 'accept and go to next'], run: (p) => voteAndAdvance(p, 'accept') },
try { { keys: ['x'], help: ['X', 'reject and go to next'], run: (p) => voteAndAdvance(p, 'reject') },
await api(`/api/share/${token}/photos/${photo.id}/tags`, { {
method: 'PUT', keys: ['u'],
body: { tags }, help: ['U', 'clear accept / reject'],
}) // When clearing hides the photo from the current filter, advance like
} catch { // a vote so the lightbox doesn't just close.
load() run: (p) =>
} matchesFilter({ my_verdict: null }, filter)
} ? setVerdict(p, null)
: voteAndAdvance(p, null),
},
{
keys: ['1', '2', '3', '4', '5'],
help: ['15', 'star rating'],
run: (p, key) => setRating(p, Number(key)),
},
{ keys: ['0'], help: ['0', 'clear star rating'], run: (p) => setRating(p, 0) },
...(view?.allow_download
? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }]
: []),
]
if (error) return <div className="center-page">{error}</div> if (error) return <div className="center-page">{error}</div>
if (!view) return <div className="center-page">Loading</div> if (!view) return <div className="center-page">Loading</div>
@@ -100,49 +171,76 @@ export default function SharePage() {
<h1>{view.album_name}</h1> <h1>{view.album_name}</h1>
{view.album_description && <p className="muted">{view.album_description}</p>} {view.album_description && <p className="muted">{view.album_description}</p>}
<p className="muted"> <p className="muted">
{view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view, {photos.length} photo{photos.length === 1 ? '' : 's'} · click a photo to view, rate and
rate and tag tag · press <kbd>?</kbd> in the viewer for shortcuts
</p> </p>
{photos.length > 0 && (
<div className="filter-bar">
{FILTERS.map((f) => (
<button
key={f.key}
className={`filter-chip${filter === f.key ? ' active' : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label} {filterCounts[f.key]}
</button>
))}
</div>
)}
</header> </header>
<main className="page"> <main className="page">
<Gallery <Gallery
photos={view.photos} photos={visible}
onOpen={setLightbox} onOpen={lightbox.openAt}
selected={view.allow_download ? selected : undefined} selected={view.allow_download ? selected : undefined}
onToggleSelect={view.allow_download ? toggle : undefined} onToggleSelect={view.allow_download ? toggle : undefined}
overlay={(p) => overlay={(p) =>
p.my_rating || p.my_tags.length > 0 ? ( p.my_verdict || p.my_rating || p.my_tags.length > 0 ? (
<div className="g-overlay"> <div className="g-overlay">
{p.my_verdict && <span>{p.my_verdict === 'accept' ? '👍' : '👎'}</span>}
{p.my_rating && <span> {p.my_rating}</span>} {p.my_rating && <span> {p.my_rating}</span>}
{p.my_tags.length > 0 && <span># {p.my_tags.length}</span>} {p.my_tags.length > 0 && <span># {p.my_tags.length}</span>}
</div> </div>
) : null ) : null
} }
/> />
{view.photos.length === 0 && ( {photos.length === 0 && (
<p className="center-page muted">Nothing here yet check back soon.</p> <p className="center-page muted">Nothing here yet check back soon.</p>
)} )}
{photos.length > 0 && visible.length === 0 && (
<p className="center-page muted">No photos match this filter.</p>
)}
</main> </main>
{view.allow_download && ( {view.allow_download && (
<SelectionBar <SelectionBar
count={selected.size} count={visibleSelected.length}
total={view.photos.length} total={visible.length}
selectedBytes={selectedBytes} selectedBytes={sumBytes(visibleSelected)}
totalBytes={totalBytes} totalBytes={sumBytes(visible)}
onSelectAll={selectAll} onSelectAll={() => selectAll(visible)}
onClear={clear} onClear={clear}
onDownload={() => postDownload(`/api/share/${token}/zip`, [...selected].join(','))} onDownload={() =>
onDownloadAll={() => postDownload(`/api/share/${token}/zip`)} postDownload(`/api/share/${token}/zip`, visibleSelected.map((p) => p.id).join(','))
}
onDownloadAll={() =>
// Under a filter, "download all" means all photos shown.
postDownload(
`/api/share/${token}/zip`,
filter === 'all' ? '' : visible.map((p) => p.id).join(','),
)
}
/> />
)} )}
{lightbox >= 0 && ( {lightbox.index >= 0 && (
<Lightbox <Lightbox
photos={view.photos} photos={visible}
index={lightbox} index={lightbox.index}
onClose={() => setLightbox(-1)} onClose={lightbox.close}
onNav={setLightbox} onNav={lightbox.openAt}
actions={keyActions}
footer={(p) => ( footer={(p) => (
<div className="client-footer"> <div className="client-footer">
<Thumbs value={p.my_verdict} onChange={(v) => setVerdict(p, v)} />
<Stars value={p.my_rating || 0} onChange={(r) => setRating(p, r)} /> <Stars value={p.my_rating || 0} onChange={(r) => setRating(p, r)} />
<TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} /> <TagEditor tags={p.my_tags} onChange={(tags) => setTags(p, tags)} />
{view.allow_download && ( {view.allow_download && (
@@ -164,6 +262,11 @@ export default function SharePage() {
)} )}
/> />
)} )}
{flash && (
<div key={flash.key} className="action-flash">
{flash.text}
</div>
)}
</> </>
) )
} }
+124
View File
@@ -534,6 +534,130 @@ progress {
cursor: default; cursor: default;
} }
/* thumbs (accept / reject) */
.thumbs {
display: inline-flex;
gap: 0.15rem;
}
.thumb {
background: none;
border: none;
font-size: 1.35rem;
line-height: 1;
padding: 0 0.15rem;
cursor: pointer;
filter: grayscale(1);
opacity: 0.4;
transition: opacity 0.12s;
}
.thumb:hover:enabled {
opacity: 0.8;
}
.thumb.active {
filter: none;
opacity: 1;
}
.thumb:disabled {
cursor: default;
}
.thumbs-small .thumb {
font-size: 0.95rem;
}
/* verdict filter */
.filter-bar {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.9rem;
}
.filter-chip {
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 999px;
color: var(--muted);
padding: 0.25rem 0.8rem;
font-size: 0.85rem;
cursor: pointer;
}
.filter-chip.active {
background: var(--panel-2);
color: var(--text);
border-color: var(--accent);
}
/* transient action feedback (keyboard votes that navigate away) */
.action-flash {
position: fixed;
top: 3rem;
left: 50%;
transform: translateX(-50%);
z-index: 110;
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 999px;
padding: 0.35rem 1rem;
font-size: 0.9rem;
white-space: nowrap;
pointer-events: none;
animation: flash-fade 1.4s ease forwards;
}
@keyframes flash-fade {
0% {
opacity: 0;
transform: translate(-50%, -6px);
}
8%,
70% {
opacity: 1;
transform: translate(-50%, 0);
}
100% {
opacity: 0;
transform: translate(-50%, 0);
}
}
/* lightbox shortcut help */
.lb-help {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(8, 9, 11, 0.6);
z-index: 102;
}
.lb-help-card {
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 12px;
padding: 1.1rem 1.5rem 1.25rem;
min-width: 280px;
cursor: default;
}
.lb-help-card h3 {
font-size: 0.95rem;
margin: 0 0 0.6rem;
}
.lb-help-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1.5rem;
padding: 0.18rem 0;
font-size: 0.88rem;
}
kbd {
background: var(--panel-2);
border-radius: 4px;
padding: 0.08rem 0.45rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
white-space: nowrap;
}
/* tags */ /* tags */
.tag-editor { .tag-editor {
display: inline-flex; display: inline-flex;
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react'
// Lightbox state tracked by photo id, not index — the list can reorder
// (polling refetch) or shrink (filter change, delete) underneath an open
// lightbox. When the open photo leaves the list, close for good — otherwise
// the lightbox would pop back open when the photo returns to the list.
export default function useLightbox(photos) {
const [openId, setOpenId] = useState(null)
const index = openId ? photos.findIndex((p) => p.id === openId) : -1
useEffect(() => {
if (openId && index < 0) setOpenId(null)
}, [openId, index])
return {
index,
openAt: (i) => setOpenId(photos[i].id),
show: (id) => setOpenId(id),
close: () => setOpenId(null),
}
}
+4 -1
View File
@@ -23,7 +23,10 @@ export default function useSelection(photos) {
return next return next
}) })
const selectAll = () => setSelected(new Set(photos.map((p) => p.id))) // Adds `list` (the caller's currently visible photos) to the selection —
// additive, so selecting all of one filtered view keeps picks from another.
const selectAll = (list) =>
setSelected((prev) => new Set([...prev, ...list.map((p) => p.id)]))
const clear = () => setSelected(new Set()) const clear = () => setSelected(new Set())
const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0) const selectedBytes = photos.reduce((sum, p) => sum + (selected.has(p.id) ? p.size_bytes : 0), 0)
const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0) const totalBytes = photos.reduce((sum, p) => sum + p.size_bytes, 0)
-27
View File
@@ -1,27 +0,0 @@
-- Albums gain an owner: the tenancy root. Photos, shares, ratings and tags
-- all hang off albums, so this single column scopes everything.
alter table albums add column owner_id uuid references users(id);
-- Backfill: pre-tenancy albums had no owner. Assigning them to "the" original
-- photographer is only unambiguous when exactly one user exists. With several
-- (v0.1.0 let every allowed email share all albums) the correct owner is
-- unknowable, so refuse rather than silently transfer everyone's work to one
-- account — the operator must assign ownership manually before migrating.
do $$
declare
n_users int;
n_albums int;
begin
select count(*) into n_users from users;
select count(*) into n_albums from albums;
if n_albums > 0 and n_users <> 1 then
raise exception
'multi-tenant migration: % albums exist but there are % users (need exactly 1 to auto-assign ownership); set albums.owner_id manually first',
n_albums, n_users;
end if;
update albums set owner_id = (select id from users order by created_at limit 1);
end $$;
alter table albums alter column owner_id set not null;
create index albums_owner_idx on albums (owner_id);
+13
View File
@@ -0,0 +1,13 @@
-- Client accept/reject votes, one per (link, photo) — a separate axis from
-- the 1-5 star rating so a photo can be e.g. accepted but unrated.
create table verdicts (
share_id uuid not null references shares(id) on delete cascade,
photo_id uuid not null references photos(id) on delete cascade,
verdict text not null check (verdict in ('accept', 'reject')),
updated_at timestamptz not null default now(),
primary key (share_id, photo_id)
);
-- Cascaded photo deletes fire per-row FK triggers; without this each one
-- sequential-scans the table.
create index verdicts_photo_idx on verdicts (photo_id);
+1 -7
View File
@@ -114,17 +114,11 @@ pub(crate) fn base_cookie(name: &'static str, value: String, secure: bool) -> Co
.build() .build()
} }
/// The signed session-cookie payload, in one place so tests and the
/// mint_session dev tool can't drift from what user_from_jar parses.
pub fn session_payload(user_id: Uuid, email: &str, exp: i64) -> String {
format!("{user_id}|{exp}|{email}")
}
fn session_cookie(state: &AppState, user_id: Uuid, email: &str) -> Cookie<'static> { fn session_cookie(state: &AppState, user_id: Uuid, email: &str) -> Cookie<'static> {
let exp = Utc::now().timestamp() + SESSION_DAYS * 86400; let exp = Utc::now().timestamp() + SESSION_DAYS * 86400;
let mut cookie = base_cookie( let mut cookie = base_cookie(
SESSION_COOKIE, SESSION_COOKIE,
session_payload(user_id, email, exp), format!("{user_id}|{exp}|{email}"),
state.config.cookie_secure(), state.config.cookie_secure(),
); );
cookie.set_max_age(time::Duration::days(SESSION_DAYS)); cookie.set_max_age(time::Duration::days(SESSION_DAYS));
-1
View File
@@ -4,7 +4,6 @@ pub mod error;
pub mod imaging; pub mod imaging;
pub mod jobs; pub mod jobs;
pub mod models; pub mod models;
pub mod owned;
pub mod routes; pub mod routes;
pub mod s3; pub mod s3;
pub mod state; pub mod state;
+20 -3
View File
@@ -1,5 +1,5 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
/// Stored as text in Postgres; decoded via TryFrom so an unknown value is a /// Stored as text in Postgres; decoded via TryFrom so an unknown value is a
@@ -47,6 +47,25 @@ impl TryFrom<String> for PhotoStatus {
} }
} }
/// Client accept/reject vote. Same convention as PhotoStatus: text in
/// Postgres (check-constrained), this enum everywhere Rust touches the value
/// — serde rejects anything but "accept"/"reject" at the API boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Accept,
Reject,
}
impl Verdict {
pub fn as_str(self) -> &'static str {
match self {
Self::Accept => "accept",
Self::Reject => "reject",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobKind { pub enum JobKind {
ProcessPhoto, ProcessPhoto,
@@ -96,8 +115,6 @@ impl JobStatus {
#[derive(Debug, Clone, sqlx::FromRow, Serialize)] #[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct Album { pub struct Album {
pub id: Uuid, pub id: Uuid,
#[serde(skip_serializing)]
pub owner_id: Uuid,
pub name: String, pub name: String,
pub description: String, pub description: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
-46
View File
@@ -1,46 +0,0 @@
//! Tenant-scoped data access. These are the ONLY functions admin handlers may
//! use to fetch albums, photos, or shares — every one joins ownership, so a
//! handler cannot accidentally reach across tenants. Another user's resource
//! is indistinguishable from a nonexistent one (404).
use uuid::Uuid;
use crate::error::ApiError;
use crate::models::{Album, Photo, Share};
use crate::state::AppState;
pub async fn album(state: &AppState, album_id: Uuid, owner: Uuid) -> Result<Album, ApiError> {
let album: Option<Album> =
sqlx::query_as("select * from albums where id = $1 and owner_id = $2")
.bind(album_id)
.bind(owner)
.fetch_optional(&state.db)
.await?;
album.ok_or_else(ApiError::not_found)
}
pub async fn photo(state: &AppState, photo_id: Uuid, owner: Uuid) -> Result<Photo, ApiError> {
let photo: Option<Photo> = sqlx::query_as(
"select p.* from photos p
join albums a on a.id = p.album_id
where p.id = $1 and a.owner_id = $2",
)
.bind(photo_id)
.bind(owner)
.fetch_optional(&state.db)
.await?;
photo.ok_or_else(ApiError::not_found)
}
pub async fn share(state: &AppState, share_id: Uuid, owner: Uuid) -> Result<Share, ApiError> {
let share: Option<Share> = sqlx::query_as(
"select s.* from shares s
join albums a on a.id = s.album_id
where s.id = $1 and a.owner_id = $2",
)
.bind(share_id)
.bind(owner)
.fetch_optional(&state.db)
.await?;
share.ok_or_else(ApiError::not_found)
}
+85 -60
View File
@@ -6,10 +6,8 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::models::{Album, JobKind, Photo, PhotoStatus}; use crate::models::{Album, JobKind, Photo, PhotoStatus};
use crate::owned;
use crate::state::AppState; use crate::state::AppState;
#[derive(Serialize, sqlx::FromRow)] #[derive(Serialize, sqlx::FromRow)]
@@ -25,7 +23,6 @@ pub struct AlbumListItem {
pub async fn list( pub async fn list(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
) -> ApiResult<Json<Vec<AlbumListItem>>> { ) -> ApiResult<Json<Vec<AlbumListItem>>> {
let albums: Vec<AlbumListItem> = sqlx::query_as( let albums: Vec<AlbumListItem> = sqlx::query_as(
"select a.id, a.name, a.description, a.created_at, "select a.id, a.name, a.description, a.created_at,
@@ -38,11 +35,9 @@ pub async fn list(
order by coalesce(p.taken_at, p.created_at), p.filename order by coalesce(p.taken_at, p.created_at), p.filename
limit 1 limit 1
) c on true ) c on true
where a.owner_id = $2
order by a.created_at desc", order by a.created_at desc",
) )
.bind(PhotoStatus::Ready.as_str()) .bind(PhotoStatus::Ready.as_str())
.bind(user.id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .await?;
Ok(Json(albums)) Ok(Json(albums))
@@ -57,21 +52,18 @@ pub struct CreateAlbum {
pub async fn create( pub async fn create(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Json(body): Json<CreateAlbum>, Json(body): Json<CreateAlbum>,
) -> ApiResult<Json<Album>> { ) -> ApiResult<Json<Album>> {
let name = body.name.trim(); let name = body.name.trim();
if name.is_empty() { if name.is_empty() {
return Err(ApiError::bad_request("album name is required")); return Err(ApiError::bad_request("album name is required"));
} }
let album: Album = sqlx::query_as( let album: Album =
"insert into albums (owner_id, name, description) values ($1, $2, $3) returning *", sqlx::query_as("insert into albums (name, description) values ($1, $2) returning *")
) .bind(name)
.bind(user.id) .bind(body.description.trim())
.bind(name) .fetch_one(&state.db)
.bind(body.description.trim()) .await?;
.fetch_one(&state.db)
.await?;
Ok(Json(album)) Ok(Json(album))
} }
@@ -87,12 +79,40 @@ pub struct ShareTag {
pub tag: String, pub tag: String,
} }
#[derive(Serialize)]
pub struct ShareVerdict {
pub share_label: String,
pub verdict: String,
}
#[derive(Serialize, Default)] #[derive(Serialize, Default)]
pub struct PhotoFeedback { pub struct PhotoFeedback {
pub ratings: Vec<ShareRating>, pub ratings: Vec<ShareRating>,
pub verdicts: Vec<ShareVerdict>,
pub tags: Vec<ShareTag>, pub tags: Vec<ShareTag>,
} }
async fn feedback_rows<T>(
db: &sqlx::PgPool,
sql: &str,
album_id: Uuid,
) -> Result<Vec<(Uuid, String, T)>, sqlx::Error>
where
(Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
{
sqlx::query_as(sql).bind(album_id).fetch_all(db).await
}
fn fold_feedback<T>(
feedback: &mut HashMap<Uuid, PhotoFeedback>,
rows: Vec<(Uuid, String, T)>,
push: impl Fn(&mut PhotoFeedback, String, T),
) {
for (photo_id, share_label, value) in rows {
push(feedback.entry(photo_id).or_default(), share_label, value);
}
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct AlbumDetail { pub struct AlbumDetail {
pub album: Album, pub album: Album,
@@ -102,10 +122,12 @@ pub struct AlbumDetail {
pub async fn get_one( pub async fn get_one(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>, Path(album_id): Path<Uuid>,
) -> ApiResult<Json<AlbumDetail>> { ) -> ApiResult<Json<AlbumDetail>> {
let album = owned::album(&state, album_id, user.id).await?; let album: Album = sqlx::query_as("select * from albums where id = $1")
.bind(album_id)
.fetch_one(&state.db)
.await?;
let photos: Vec<Photo> = sqlx::query_as( let photos: Vec<Photo> = sqlx::query_as(
"select * from photos where album_id = $1 "select * from photos where album_id = $1
order by coalesce(taken_at, created_at), filename", order by coalesce(taken_at, created_at), filename",
@@ -114,41 +136,49 @@ pub async fn get_one(
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .await?;
// The three feedback kinds are independent (photo_id, share label, value)
// queries — run them concurrently and fold with one shared shape.
let (ratings, verdicts, tags) = tokio::try_join!(
feedback_rows::<i32>(
&state.db,
"select r.photo_id, s.label, r.rating
from ratings r join shares s on s.id = r.share_id
where s.album_id = $1",
album_id,
),
feedback_rows::<String>(
&state.db,
"select v.photo_id, s.label, v.verdict
from verdicts v join shares s on s.id = v.share_id
where s.album_id = $1",
album_id,
),
feedback_rows::<String>(
&state.db,
"select t.photo_id, s.label, t.tag
from tags t join shares s on s.id = t.share_id
where s.album_id = $1
order by t.created_at",
album_id,
),
)?;
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new(); let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as( fold_feedback(&mut feedback, ratings, |f, share_label, rating| {
"select r.photo_id, s.label, r.rating f.ratings.push(ShareRating {
from ratings r join shares s on s.id = r.share_id share_label,
where s.album_id = $1", rating,
) })
.bind(album_id) });
.fetch_all(&state.db) fold_feedback(&mut feedback, verdicts, |f, share_label, verdict| {
.await?; f.verdicts.push(ShareVerdict {
for (photo_id, share_label, rating) in ratings { share_label,
feedback verdict,
.entry(photo_id) })
.or_default() });
.ratings fold_feedback(&mut feedback, tags, |f, share_label, tag| {
.push(ShareRating { f.tags.push(ShareTag { share_label, tag })
share_label, });
rating,
});
}
let tags: Vec<(Uuid, String, String)> = sqlx::query_as(
"select t.photo_id, s.label, t.tag
from tags t join shares s on s.id = t.share_id
where s.album_id = $1
order by t.created_at",
)
.bind(album_id)
.fetch_all(&state.db)
.await?;
for (photo_id, share_label, tag) in tags {
feedback
.entry(photo_id)
.or_default()
.tags
.push(ShareTag { share_label, tag });
}
Ok(Json(AlbumDetail { Ok(Json(AlbumDetail {
album, album,
@@ -165,7 +195,6 @@ pub struct UpdateAlbum {
pub async fn update( pub async fn update(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>, Path(album_id): Path<Uuid>,
Json(body): Json<UpdateAlbum>, Json(body): Json<UpdateAlbum>,
) -> ApiResult<Json<Album>> { ) -> ApiResult<Json<Album>> {
@@ -177,13 +206,12 @@ pub async fn update(
let album: Album = sqlx::query_as( let album: Album = sqlx::query_as(
"update albums "update albums
set name = coalesce($2, name), description = coalesce($3, description) set name = coalesce($2, name), description = coalesce($3, description)
where id = $1 and owner_id = $4 where id = $1
returning *", returning *",
) )
.bind(album_id) .bind(album_id)
.bind(body.name.as_deref().map(str::trim)) .bind(body.name.as_deref().map(str::trim))
.bind(body.description.as_deref().map(str::trim)) .bind(body.description.as_deref().map(str::trim))
.bind(user.id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
Ok(Json(album)) Ok(Json(album))
@@ -191,19 +219,16 @@ pub async fn update(
pub async fn delete( pub async fn delete(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>, Path(album_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> { ) -> ApiResult<Json<serde_json::Value>> {
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
// Lock the album row: concurrent uploads block on their FK check against // Lock the album row: concurrent uploads block on their FK check against
// it, then fail once it's gone and clean up their own S3 objects — so no // it, then fail once it's gone and clean up their own S3 objects — so no
// photo can slip in between the cleanup enqueue and the cascade delete. // photo can slip in between the cleanup enqueue and the cascade delete.
let locked: Option<(Uuid,)> = let locked: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1 for update")
sqlx::query_as("select id from albums where id = $1 and owner_id = $2 for update") .bind(album_id)
.bind(album_id) .fetch_optional(&mut *tx)
.bind(user.id) .await?;
.fetch_optional(&mut *tx)
.await?;
if locked.is_none() { if locked.is_none() {
return Err(ApiError::not_found()); return Err(ApiError::not_found());
} }
+40 -2
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::models::{PhotoStatus, Share}; use crate::models::{PhotoStatus, Share, Verdict};
use crate::state::AppState; use crate::state::AppState;
pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> { pub async fn load_share(state: &AppState, token: &str) -> Result<Share, ApiError> {
@@ -137,6 +137,7 @@ struct ClientPhotoRow {
taken_at: Option<DateTime<Utc>>, taken_at: Option<DateTime<Utc>>,
processed_at: Option<DateTime<Utc>>, processed_at: Option<DateTime<Utc>>,
my_rating: Option<i32>, my_rating: Option<i32>,
my_verdict: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -185,9 +186,10 @@ pub async fn get_share(
let jar = grant_access(&state, jar, share.id); let jar = grant_access(&state, jar, share.id);
let rows: Vec<ClientPhotoRow> = sqlx::query_as( let rows: Vec<ClientPhotoRow> = sqlx::query_as(
"select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating "select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating, v.verdict as my_verdict
from photos p from photos p
left join ratings r on r.photo_id = p.id and r.share_id = $2 left join ratings r on r.photo_id = p.id and r.share_id = $2
left join verdicts v on v.photo_id = p.id and v.share_id = $2
where p.album_id = $1 and p.status = $3 where p.album_id = $1 and p.status = $3
order by coalesce(p.taken_at, p.created_at), p.filename", order by coalesce(p.taken_at, p.created_at), p.filename",
) )
@@ -352,6 +354,42 @@ pub async fn set_rating(
Ok(Json(serde_json::json!({ "ok": true }))) Ok(Json(serde_json::json!({ "ok": true })))
} }
#[derive(Deserialize)]
pub struct VerdictBody {
verdict: Option<Verdict>,
}
pub async fn set_verdict(
State(state): State<AppState>,
Path((token, photo_id)): Path<(String, Uuid)>,
jar: SignedCookieJar,
Json(body): Json<VerdictBody>,
) -> ApiResult<Json<serde_json::Value>> {
let share = share_photo(&state, &jar, &token, photo_id).await?;
match body.verdict {
None => {
sqlx::query("delete from verdicts where share_id = $1 and photo_id = $2")
.bind(share.id)
.bind(photo_id)
.execute(&state.db)
.await?;
}
Some(verdict) => {
sqlx::query(
"insert into verdicts (share_id, photo_id, verdict) values ($1, $2, $3)
on conflict (share_id, photo_id)
do update set verdict = excluded.verdict, updated_at = now()",
)
.bind(share.id)
.bind(photo_id)
.bind(verdict.as_str())
.execute(&state.db)
.await?;
}
}
Ok(Json(serde_json::json!({ "ok": true })))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct TagsBody { pub struct TagsBody {
tags: Vec<String>, tags: Vec<String>,
+2 -13
View File
@@ -28,19 +28,8 @@ async fn authorize_photo(
.await?; .await?;
let photo = photo.ok_or_else(ApiError::not_found)?; let photo = photo.ok_or_else(ApiError::not_found)?;
// Photographers may only reach their OWN photos (scoped by album owner); if user_from_jar(state, jar).is_some() {
// otherwise fall through to share-cookie access. return Ok(photo);
if let Some(user) = user_from_jar(state, jar) {
let owns: Option<(Uuid,)> = sqlx::query_as(
"select a.id from albums a where a.id = $1 and a.owner_id = $2",
)
.bind(photo.album_id)
.bind(user.id)
.fetch_optional(&state.db)
.await?;
if owns.is_some() {
return Ok(photo);
}
} }
authorize_album_via_cookie(state, jar, photo.album_id, need_download).await?; authorize_album_via_cookie(state, jar, photo.album_id, need_download).await?;
// Clients may only reach photos the share listing exposes. // Clients may only reach photos the share listing exposes.
+4
View File
@@ -100,6 +100,10 @@ pub fn router(state: &AppState) -> Router<AppState> {
"/api/share/{token}/photos/{photo_id}/rating", "/api/share/{token}/photos/{photo_id}/rating",
put(client::set_rating), put(client::set_rating),
) )
.route(
"/api/share/{token}/photos/{photo_id}/verdict",
put(client::set_verdict),
)
.route( .route(
"/api/share/{token}/photos/{photo_id}/tags", "/api/share/{token}/photos/{photo_id}/tags",
put(client::set_tags), put(client::set_tags),
+9 -31
View File
@@ -9,11 +9,9 @@ use sha2::Digest;
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::jobs; use crate::jobs;
use crate::models::{JobKind, Photo, PhotoStatus}; use crate::models::{JobKind, Photo, PhotoStatus};
use crate::owned;
use crate::s3; use crate::s3;
use crate::state::AppState; use crate::state::AppState;
@@ -41,34 +39,20 @@ fn sanitize_filename(raw: &str) -> Result<String, ApiError> {
Ok(cleaned.chars().take(150).collect()) Ok(cleaned.chars().take(150).collect())
} }
/// A dedup hit on a photo that previously failed processing means the user is
/// re-uploading to fix it — reset it and re-enqueue instead of handing back a
/// broken row that the UI would report as "already uploaded".
async fn heal_if_errored(state: &AppState, photo: Photo) -> ApiResult<Photo> {
if photo.status != PhotoStatus::Error {
return Ok(photo);
}
let mut tx = state.db.begin().await?;
let healed: Photo =
sqlx::query_as("update photos set status = $2, error = null where id = $1 returning *")
.bind(photo.id)
.bind(PhotoStatus::Uploaded.as_str())
.fetch_one(&mut *tx)
.await?;
jobs::ensure_process_photo(&mut tx, photo.id).await?;
tx.commit().await?;
Ok(healed)
}
pub async fn upload( pub async fn upload(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>, Path(album_id): Path<Uuid>,
Query(query): Query<UploadQuery>, Query(query): Query<UploadQuery>,
headers: HeaderMap, headers: HeaderMap,
body: Body, body: Body,
) -> ApiResult<Json<Photo>> { ) -> ApiResult<Json<Photo>> {
owned::album(&state, album_id, user.id).await?; let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
if album_exists.is_none() {
return Err(ApiError::not_found());
}
let filename = sanitize_filename(&query.filename)?; let filename = sanitize_filename(&query.filename)?;
let content_type = headers let content_type = headers
@@ -113,7 +97,7 @@ pub async fn upload(
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
if let Some(existing) = existing { if let Some(existing) = existing {
return Ok(Json(heal_if_errored(&state, existing).await?)); return Ok(Json(existing));
} }
// Upload to S3 first, then create the row and enqueue processing in one // Upload to S3 first, then create the row and enqueue processing in one
@@ -172,7 +156,7 @@ pub async fn upload(
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .await?;
if let Some(winner) = winner { if let Some(winner) = winner {
return Ok(Json(heal_if_errored(&state, winner).await?)); return Ok(Json(winner));
} }
} }
return Err(e.into()); return Err(e.into());
@@ -194,10 +178,8 @@ pub async fn upload(
/// content already exists in the album. /// content already exists in the album.
pub async fn by_hash( pub async fn by_hash(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path((album_id, sha256)): Path<(Uuid, String)>, Path((album_id, sha256)): Path<(Uuid, String)>,
) -> ApiResult<Json<Photo>> { ) -> ApiResult<Json<Photo>> {
owned::album(&state, album_id, user.id).await?;
let photo: Option<Photo> = let photo: Option<Photo> =
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2") sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
.bind(album_id) .bind(album_id)
@@ -209,10 +191,8 @@ pub async fn by_hash(
pub async fn delete( pub async fn delete(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path(photo_id): Path<Uuid>, Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> { ) -> ApiResult<Json<serde_json::Value>> {
owned::photo(&state, photo_id, user.id).await?;
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
let deleted = sqlx::query("delete from photos where id = $1") let deleted = sqlx::query("delete from photos where id = $1")
.bind(photo_id) .bind(photo_id)
@@ -233,10 +213,8 @@ pub async fn delete(
pub async fn reprocess( pub async fn reprocess(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path(photo_id): Path<Uuid>, Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> { ) -> ApiResult<Json<serde_json::Value>> {
owned::photo(&state, photo_id, user.id).await?;
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
let updated = sqlx::query("update photos set status = $2, error = null where id = $1") let updated = sqlx::query("update photos set status = $2, error = null where id = $1")
.bind(photo_id) .bind(photo_id)
+28 -19
View File
@@ -7,9 +7,8 @@ use chrono::{DateTime, Utc};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::{random_token, AuthUser}; use crate::auth::random_token;
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
use crate::owned;
use crate::state::AppState; use crate::state::AppState;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -24,6 +23,8 @@ struct ShareAdminRow {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
rating_count: i64, rating_count: i64,
tag_count: i64, tag_count: i64,
accept_count: i64,
reject_count: i64,
} }
fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value { fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
@@ -39,22 +40,30 @@ fn share_json(state: &AppState, row: &ShareAdminRow) -> serde_json::Value {
"created_at": row.created_at, "created_at": row.created_at,
"rating_count": row.rating_count, "rating_count": row.rating_count,
"tag_count": row.tag_count, "tag_count": row.tag_count,
"accept_count": row.accept_count,
"reject_count": row.reject_count,
}) })
} }
const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_download, // Aggregates run as laterals so each feedback table is scanned once per
// share (the verdict lateral yields both counts from a single pass).
const SHARE_SELECT: &str = "select s.id, s.token, s.label, s.password_hash, s.allow_download,
s.expires_at, s.locked_until, s.created_at, s.expires_at, s.locked_until, s.created_at,
(select count(*) from ratings r where r.share_id = s.id) as rating_count, rc.rating_count, tc.tag_count, vc.accept_count, vc.reject_count
(select count(*) from tags t where t.share_id = s.id) as tag_count"; from shares s
cross join lateral (select count(*) as rating_count from ratings r where r.share_id = s.id) rc
cross join lateral (select count(*) as tag_count from tags t where t.share_id = s.id) tc
cross join lateral (
select count(*) filter (where v.verdict = 'accept') as accept_count,
count(*) filter (where v.verdict = 'reject') as reject_count
from verdicts v where v.share_id = s.id) vc";
pub async fn list( pub async fn list(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>, Path(album_id): Path<Uuid>,
) -> ApiResult<Json<Vec<serde_json::Value>>> { ) -> ApiResult<Json<Vec<serde_json::Value>>> {
owned::album(&state, album_id, user.id).await?;
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!( let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!(
"select {SHARE_COLUMNS} from shares s where s.album_id = $1 order by s.created_at desc" "{SHARE_SELECT} where s.album_id = $1 order by s.created_at desc"
)) ))
.bind(album_id) .bind(album_id)
.fetch_all(&state.db) .fetch_all(&state.db)
@@ -78,11 +87,16 @@ fn default_true() -> bool {
pub async fn create( pub async fn create(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>, Path(album_id): Path<Uuid>,
Json(body): Json<CreateShare>, Json(body): Json<CreateShare>,
) -> ApiResult<Json<serde_json::Value>> { ) -> ApiResult<Json<serde_json::Value>> {
owned::album(&state, album_id, user.id).await?; let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
if album_exists.is_none() {
return Err(ApiError::not_found());
}
let password_hash = match body.password.as_deref().map(str::trim) { let password_hash = match body.password.as_deref().map(str::trim) {
Some(pw) if !pw.is_empty() => { Some(pw) if !pw.is_empty() => {
@@ -117,11 +131,10 @@ pub async fn create(
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
let row: ShareAdminRow = let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1"))
sqlx::query_as(&format!("select {SHARE_COLUMNS} from shares s where s.id = $1")) .bind(share_id)
.bind(share_id) .fetch_one(&state.db)
.fetch_one(&state.db) .await?;
.await?;
Ok(Json(share_json(&state, &row))) Ok(Json(share_json(&state, &row)))
} }
@@ -129,10 +142,8 @@ pub async fn create(
/// their way into the 15-minute lock). /// their way into the 15-minute lock).
pub async fn reset_lock( pub async fn reset_lock(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path(share_id): Path<Uuid>, Path(share_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> { ) -> ApiResult<Json<serde_json::Value>> {
owned::share(&state, share_id, user.id).await?;
let updated = let updated =
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1") sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
.bind(share_id) .bind(share_id)
@@ -146,10 +157,8 @@ pub async fn reset_lock(
pub async fn delete( pub async fn delete(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthUser,
Path(share_id): Path<Uuid>, Path(share_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> { ) -> ApiResult<Json<serde_json::Value>> {
owned::share(&state, share_id, user.id).await?;
let deleted = sqlx::query("delete from shares where id = $1") let deleted = sqlx::query("delete from shares where id = $1")
.bind(share_id) .bind(share_id)
.execute(&state.db) .execute(&state.db)
+59 -46
View File
@@ -81,13 +81,12 @@ async fn album_name(state: &AppState, album_id: Uuid) -> Result<String, ApiError
pub async fn album_zip( pub async fn album_zip(
State(state): State<AppState>, State(state): State<AppState>,
user: crate::auth::AuthUser,
Path(album_id): Path<Uuid>, Path(album_id): Path<Uuid>,
Form(request): Form<ZipRequest>, Form(request): Form<ZipRequest>,
) -> ApiResult<Response> { ) -> ApiResult<Response> {
let album = crate::owned::album(&state, album_id, user.id).await?; let name = album_name(&state, album_id).await?;
let photos = ready_photos(&state, album_id, &parse_ids(&request.ids)?).await?; let photos = ready_photos(&state, album_id, &parse_ids(&request.ids)?).await?;
stream_zip(state, photos, &album.name) stream_zip(state, photos, &name)
} }
pub async fn share_zip( pub async fn share_zip(
@@ -137,6 +136,9 @@ struct Entry {
offset: u64, offset: u64,
dos_time: u16, dos_time: u16,
dos_date: u16, dos_date: u16,
/// Stored at upload/processing time; photos from before hashes existed
/// have None and take the slower spool path (which backfills it).
crc: Option<u32>,
} }
struct ZipPlan { struct ZipPlan {
@@ -177,6 +179,7 @@ fn plan_zip(photos: &[Photo]) -> Result<ZipPlan, ApiError> {
offset: entry_offset, offset: entry_offset,
dos_time, dos_time,
dos_date, dos_date,
crc: photo.crc32.map(|v| v as u32),
}); });
} }
let cd_offset = offset; let cd_offset = offset;
@@ -278,26 +281,23 @@ fn stream_zip(state: AppState, photos: Vec<Photo>, album_name: &str) -> ApiResul
.map_err(|e| anyhow::anyhow!("building response: {e}").into()) .map_err(|e| anyhow::anyhow!("building response: {e}").into())
} }
struct Fetched { enum Fetched {
file: tokio::fs::File, /// CRC already known — the body streams straight into the response.
crc: u32, Direct(Box<aws_sdk_s3::operation::get_object::GetObjectOutput>),
/// sha256 hex — always computed so legacy photos (crc-only) get their /// Pre-hash photo: spooled to a temp file to compute the CRC first.
/// content hash backfilled, restoring upload dedup for them. Spooled(tokio::fs::File, u32),
sha256: String,
} }
/// Fetch an original and spool it to an anonymous temp file, computing crc32 /// Start fetching an original. With a known CRC this only opens the S3
/// and sha256 and verifying the byte count the zip plan promised. Spooling /// response (the body is consumed later, straight into the zip stream);
/// (rather than streaming the live S3 body straight through) is deliberate: /// otherwise the object is spooled to an anonymous temp file to compute the
/// the prefetched entry is fully read immediately, so no S3 connection sits /// CRC, verifying the byte count the zip plan promised.
/// idle across the previous entry's (possibly slow) client stream — which S3
/// idle-timeouts would otherwise reset mid-download.
fn fetch_entry( fn fetch_entry(
state: &AppState, state: &AppState,
key: String, key: String,
expected_size: u64, expected_size: u64,
crc_known: bool,
) -> JoinHandle<anyhow::Result<Fetched>> { ) -> JoinHandle<anyhow::Result<Fetched>> {
use sha2::Digest;
let state = state.clone(); let state = state.clone();
tokio::spawn(async move { tokio::spawn(async move {
let object = state let object = state
@@ -308,10 +308,12 @@ fn fetch_entry(
.send() .send()
.await .await
.map_err(|e| anyhow::anyhow!("fetching {key}: {e}"))?; .map_err(|e| anyhow::anyhow!("fetching {key}: {e}"))?;
if crc_known {
return Ok(Fetched::Direct(Box::new(object)));
}
let mut file = tokio::fs::File::from_std(tempfile::tempfile()?); let mut file = tokio::fs::File::from_std(tempfile::tempfile()?);
let mut reader = object.body.into_async_read(); let mut reader = object.body.into_async_read();
let mut crc = crc32fast::Hasher::new(); let mut hasher = crc32fast::Hasher::new();
let mut sha = sha2::Sha256::new();
let mut written: u64 = 0; let mut written: u64 = 0;
let mut buf = vec![0u8; 128 * 1024]; let mut buf = vec![0u8; 128 * 1024];
loop { loop {
@@ -319,8 +321,7 @@ fn fetch_entry(
if n == 0 { if n == 0 {
break; break;
} }
crc.update(&buf[..n]); hasher.update(&buf[..n]);
sha.update(&buf[..n]);
file.write_all(&buf[..n]).await?; file.write_all(&buf[..n]).await?;
written += n as u64; written += n as u64;
} }
@@ -330,11 +331,7 @@ fn fetch_entry(
); );
file.flush().await?; file.flush().await?;
file.seek(std::io::SeekFrom::Start(0)).await?; file.seek(std::io::SeekFrom::Start(0)).await?;
Ok(Fetched { Ok(Fetched::Spooled(file, hasher.finalize()))
file,
crc: crc.finalize(),
sha256: hex::encode(sha.finalize()),
})
}) })
} }
@@ -347,23 +344,30 @@ async fn write_zip(
const FLAGS: u16 = 0x0800; const FLAGS: u16 = 0x0800;
let mut crcs = Vec::with_capacity(plan.entries.len()); let mut crcs = Vec::with_capacity(plan.entries.len());
// Prefetch: spool the next object to a temp file while streaming the // Prefetch: start fetching the next object while streaming the current one.
// current one — the prefetched body is drained immediately, never held
// open across the current entry's client stream.
let mut pending: Option<JoinHandle<anyhow::Result<Fetched>>> = None; let mut pending: Option<JoinHandle<anyhow::Result<Fetched>>> = None;
for (i, entry) in plan.entries.iter().enumerate() { for (i, entry) in plan.entries.iter().enumerate() {
let current = match pending.take() { let current = match pending.take() {
Some(handle) => handle, Some(handle) => handle,
None => fetch_entry(state, entry.s3_key.clone(), entry.size), None => fetch_entry(state, entry.s3_key.clone(), entry.size, entry.crc.is_some()),
}; };
if let Some(next) = plan.entries.get(i + 1) { if let Some(next) = plan.entries.get(i + 1) {
pending = Some(fetch_entry(state, next.s3_key.clone(), next.size)); pending = Some(fetch_entry(
state,
next.s3_key.clone(),
next.size,
next.crc.is_some(),
));
} }
let mut fetched = current let fetched = current
.await .await
.map_err(|e| anyhow::anyhow!("fetch task failed: {e}"))? .map_err(|e| anyhow::anyhow!("fetch task failed: {e}"))?
.map_err(|e| anyhow::anyhow!("photo {}: {e:#}", entry.photo_id))?; .map_err(|e| anyhow::anyhow!("photo {}: {e:#}", entry.photo_id))?;
crcs.push(fetched.crc); let crc = match &fetched {
Fetched::Direct(_) => entry.crc.expect("direct fetch implies known crc"),
Fetched::Spooled(_, crc) => *crc,
};
crcs.push(crc);
let mut lfh = Vec::with_capacity(30 + entry.name.len()); let mut lfh = Vec::with_capacity(30 + entry.name.len());
lfh.extend_from_slice(&0x04034b50u32.to_le_bytes()); lfh.extend_from_slice(&0x04034b50u32.to_le_bytes());
@@ -372,26 +376,35 @@ async fn write_zip(
lfh.extend_from_slice(&0u16.to_le_bytes()); // method: stored lfh.extend_from_slice(&0u16.to_le_bytes()); // method: stored
lfh.extend_from_slice(&entry.dos_time.to_le_bytes()); lfh.extend_from_slice(&entry.dos_time.to_le_bytes());
lfh.extend_from_slice(&entry.dos_date.to_le_bytes()); lfh.extend_from_slice(&entry.dos_date.to_le_bytes());
lfh.extend_from_slice(&fetched.crc.to_le_bytes()); lfh.extend_from_slice(&crc.to_le_bytes());
lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // compressed lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // compressed
lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // uncompressed lfh.extend_from_slice(&(entry.size as u32).to_le_bytes()); // uncompressed
lfh.extend_from_slice(&(entry.name.len() as u16).to_le_bytes()); lfh.extend_from_slice(&(entry.name.len() as u16).to_le_bytes());
lfh.extend_from_slice(&0u16.to_le_bytes()); // extra len lfh.extend_from_slice(&0u16.to_le_bytes()); // extra len
lfh.extend_from_slice(&entry.name); lfh.extend_from_slice(&entry.name);
out.write_all(&lfh).await?; out.write_all(&lfh).await?;
tokio::io::copy(&mut fetched.file, &mut out).await?; match fetched {
Fetched::Direct(object) => {
// Self-heal legacy photos: backfill both hashes so future zips and let mut reader = object.body.into_async_read();
// upload dedup both work for them. let copied = tokio::io::copy(&mut reader, &mut out).await?;
let _ = sqlx::query( anyhow::ensure!(
"update photos set crc32 = coalesce(crc32, $2), sha256 = coalesce(sha256, $3) copied == entry.size,
where id = $1", "{} is {copied} bytes in s3 but {} in the database",
) entry.s3_key,
.bind(entry.photo_id) entry.size
.bind(i64::from(fetched.crc)) );
.bind(&fetched.sha256) }
.execute(&state.db) Fetched::Spooled(mut file, crc) => {
.await; tokio::io::copy(&mut file, &mut out).await?;
// Self-heal: store the freshly computed crc so the next
// download of this photo streams directly.
let _ = sqlx::query("update photos set crc32 = coalesce(crc32, $2) where id = $1")
.bind(entry.photo_id)
.bind(i64::from(crc))
.execute(&state.db)
.await;
}
}
} }
// Central directory. // Central directory.
-254
View File
@@ -1,254 +0,0 @@
//! Tenant-isolation matrix: every admin endpoint must treat another user's
//! resources as nonexistent (404), and unauthenticated requests as 401.
//!
//! Needs a disposable Postgres database:
//! TEST_DATABASE_URL=postgres://photos:photos@localhost:5432/photos_test cargo test
//! Skips silently when TEST_DATABASE_URL is unset. S3 is never contacted —
//! the matrix stops at the ownership checks by construction.
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use cookie::{Cookie, CookieJar, Key};
use sha2::{Digest, Sha512};
use tower::ServiceExt;
use uuid::Uuid;
use photos::config::Config;
use photos::state::AppState;
const SECRET: &str = "test-secret-test-secret-test-secret-1234";
fn test_config(database_url: String) -> Config {
Config {
database_url,
bind_addr: "127.0.0.1:0".into(),
public_url: "http://localhost:8080".into(),
session_secret: SECRET.into(),
s3_bucket: "photos".into(),
// Unroutable on purpose: no test may reach S3.
s3_endpoint: Some("http://127.0.0.1:9".into()),
s3_region: "us-east-1".into(),
s3_access_key: "test".into(),
s3_secret_key: "test".into(),
s3_force_path_style: true,
oidc_issuer: "https://auth.invalid".into(),
oidc_client_id: "x".into(),
oidc_client_secret: "x".into(),
allowed_emails: vec!["a@test".into(), "b@test".into()],
static_dir: "frontend/dist".into(),
worker_concurrency: 1,
dev_autologin_email: None,
}
}
fn session_for(user_id: Uuid, email: &str) -> String {
let key = Key::from(&Sha512::digest(SECRET.as_bytes()));
let exp = chrono::Utc::now().timestamp() + 3600;
// Use the production payload builder so the test can't drift from what
// user_from_jar parses.
let mut jar = CookieJar::new();
jar.signed_mut(&key).add(Cookie::new(
"photos_session",
photos::auth::session_payload(user_id, email, exp),
));
format!("photos_session={}", jar.get("photos_session").unwrap().value())
}
async fn request(
router: &axum::Router,
method: &str,
path: &str,
cookie: Option<&str>,
json: Option<serde_json::Value>,
) -> (StatusCode, serde_json::Value) {
let mut builder = Request::builder().method(method).uri(path);
if let Some(cookie) = cookie {
builder = builder.header(header::COOKIE, cookie);
}
let body = match json {
Some(value) => {
builder = builder.header(header::CONTENT_TYPE, "application/json");
Body::from(value.to_string())
}
// Zip endpoints take a form; everything else ignores the body.
None if path.ends_with("/zip") => {
builder = builder.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
Body::from("ids=")
}
None => Body::empty(),
};
let response = router
.clone()
.oneshot(builder.body(body).unwrap())
.await
.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
.await
.unwrap();
let value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
(status, value)
}
async fn seed_user(state: &AppState, email: &str) -> Uuid {
let (id,): (Uuid,) = sqlx::query_as(
"insert into users (oidc_subject, email) values ($1, $2) returning id",
)
.bind(format!("test:{email}"))
.bind(email)
.fetch_one(&state.db)
.await
.unwrap();
id
}
#[tokio::test]
async fn tenant_isolation_matrix() {
let Ok(database_url) = std::env::var("TEST_DATABASE_URL") else {
eprintln!("TEST_DATABASE_URL not set — skipping tenancy matrix");
return;
};
let state = AppState::new(test_config(database_url)).await.unwrap();
sqlx::query("truncate users, albums, photos, shares, ratings, tags, jobs cascade")
.execute(&state.db)
.await
.unwrap();
let alice = seed_user(&state, "a@test").await;
let bob = seed_user(&state, "b@test").await;
let cookie_a = session_for(alice, "a@test");
let cookie_b = session_for(bob, "b@test");
let router = photos::routes::router(&state).with_state(state.clone());
// Alice creates an album through the real API.
let (status, album) = request(
&router,
"POST",
"/api/albums",
Some(&cookie_a),
Some(serde_json::json!({ "name": "Alice's Wedding" })),
)
.await;
assert_eq!(status, StatusCode::OK);
let album_id = album["id"].as_str().unwrap().to_string();
// Seed a photo (kept non-ready so no code path reaches S3) and a share.
let photo_id = Uuid::new_v4();
sqlx::query(
"insert into photos (id, album_id, filename, content_type, size_bytes, sha256)
values ($1, $2::uuid, 'a.jpg', 'image/jpeg', 3, 'hash-a')",
)
.bind(photo_id)
.bind(&album_id)
.execute(&state.db)
.await
.unwrap();
let share_id = Uuid::new_v4();
sqlx::query("insert into shares (id, album_id, token) values ($1, $2::uuid, 'tenanttesttoken123456789')")
.bind(share_id)
.bind(&album_id)
.execute(&state.db)
.await
.unwrap();
// ---- Bob vs Alice's resources: everything must be a 404 (or absent). ----
let bob_hits: &[(&str, String, Option<serde_json::Value>)] = &[
("GET", format!("/api/albums/{album_id}"), None),
(
"PATCH",
format!("/api/albums/{album_id}"),
Some(serde_json::json!({ "name": "stolen" })),
),
("DELETE", format!("/api/albums/{album_id}"), None),
("POST", format!("/api/albums/{album_id}/photos?filename=x.jpg"), None),
("GET", format!("/api/albums/{album_id}/shares"), None),
(
"POST",
format!("/api/albums/{album_id}/shares"),
Some(serde_json::json!({ "label": "x" })),
),
("POST", format!("/api/albums/{album_id}/zip"), None),
("GET", format!("/api/albums/{album_id}/photos/by-hash/hash-a"), None),
("DELETE", format!("/api/photos/{photo_id}"), None),
("POST", format!("/api/photos/{photo_id}/reprocess"), None),
("DELETE", format!("/api/shares/{share_id}"), None),
("POST", format!("/api/shares/{share_id}/reset-lock"), None),
];
for (method, path, body) in bob_hits {
let (status, _) = request(&router, method, path, Some(&cookie_b), body.clone()).await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"cross-tenant {method} {path} must 404"
);
}
let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_b), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list.as_array().unwrap().len(), 0, "bob must see no albums");
// ---- Dual-auth image routes must ALSO reject a non-owning photographer.
// Bob owns no share and doesn't own the album, so authorize_photo falls
// through to the share-cookie path and 401s BEFORE any S3 access — the
// cross-tenant original leak these routes previously allowed. ----
for path in [
format!("/api/img/{photo_id}/thumb"),
format!("/api/img/{photo_id}/preview"),
format!("/api/photos/{photo_id}/original"),
] {
let (status, _) = request(&router, "GET", &path, Some(&cookie_b), None).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"cross-tenant image GET {path} must not authorize"
);
}
// ---- Alice keeps full access to her own resources. ----
let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_a), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list.as_array().unwrap().len(), 1);
let (status, _) = request(
&router,
"GET",
&format!("/api/albums/{album_id}"),
Some(&cookie_a),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = request(
&router,
"GET",
&format!("/api/albums/{album_id}/photos/by-hash/hash-a"),
Some(&cookie_a),
None,
)
.await;
assert_eq!(status, StatusCode::OK);
// Ownership check runs before the body is read: empty upload = 400, not 404.
let (status, _) = request(
&router,
"POST",
&format!("/api/albums/{album_id}/photos?filename=x.jpg"),
Some(&cookie_a),
None,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
// No ready photos yet: zip is a 400 for the owner, never an S3 call.
let (status, _) = request(
&router,
"POST",
&format!("/api/albums/{album_id}/zip"),
Some(&cookie_a),
None,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
// ---- No session at all: 401 on the admin surface. ----
for path in ["/api/albums", "/api/me"] {
let (status, _) = request(&router, "GET", path, None, None).await;
assert_eq!(status, StatusCode::UNAUTHORIZED, "{path} without session");
}
}