2 Commits
Author SHA1 Message Date
nils 84323ab637 Fix code-review findings on multi-tenant branch
- images.rs: scope /api/img and /api/photos/{id}/original by album owner —
  close the cross-tenant original/thumbnail leak (tenancy test now covers
  these routes)
- migration 0003: refuse to run when albums exist and users != 1 instead of
  silently reassigning every album to the oldest user
- Gallery: callback-ref ResizeObserver so a gallery mounted empty still
  lays out once photos arrive (was permanently blank)
- upload dedup: re-uploading identical content whose photo is in 'error'
  resets and re-enqueues it instead of returning the broken row
- client hashing: skip (and fall back to plain upload) above 512MB to avoid
  whole-file arrayBuffer OOM / the ~2GiB cap
- zip: always spool each entry (no unread prefetched S3 body held across a
  slow client stream) and backfill BOTH sha256 and crc32 for legacy photos
- tests/auth: share one session_payload builder instead of re-implementing
  the cookie format in the test and mint_session
2026-07-17 15:16:25 +02:00
nils 40f7f2fb5e Multi-tenant: albums owned per photographer
- albums.owner_id (migration 0003, backfilled to the original user)
- owned::{album,photo,share} are the only admin data-access paths; another
  tenant's resources are indistinguishable from nonexistent (404)
- every admin handler threaded through ownership; each ALLOWED_EMAILS entry
  is now its own isolated workspace
- tenant-isolation integration test matrix (tests/tenancy.rs, env-gated on
  TEST_DATABASE_URL) driving the real router
2026-07-17 14:52:23 +02:00
39 changed files with 1176 additions and 3326 deletions
+2 -2
View File
@@ -37,8 +37,7 @@ tempfile = "3"
time = "0.3" time = "0.3"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["fs", "trace"] }
tower-http = { version = "0.6", features = ["fs", "set-header", "trace"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
urlencoding = "2" urlencoding = "2"
@@ -46,6 +45,7 @@ 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"
+26 -44
View File
@@ -2,25 +2,7 @@
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 accept/reject votes, ratings and tags, and let clients download collect ratings and tags, and let clients download originals.
originals.
## Screenshots
The album view — drag-and-drop upload, justified gallery, and each client's
feedback (votes, stars, tags) overlaid on the thumbnails:
![Album view with upload zone and feedback overlays](docs/screenshots/album-admin.jpg)
What clients see on a share link — vote on favorites, filter by verdict,
download selects or the whole album as a ZIP:
![Client gallery with verdict filters](docs/screenshots/share-client.jpg)
The lightbox — accept/reject, star rating, tags, and keyboard-driven culling
(`P`/`X`/`U`, `1``5`):
![Lightbox with voting and rating controls](docs/screenshots/lightbox.jpg)
## Architecture ## Architecture
@@ -48,19 +30,17 @@ The lightbox — accept/reject, star rating, tags, and keyboard-driven culling
`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**: photographer signs in via any OIDC provider (authorization-code - **Auth**: photographers sign 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. Clients use unguessable share tokens, optionally revokes access immediately. **Multi-tenant**: each allowed email is its own
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 — Lightroom-style photo grid (fixed cells, no crop), lightbox with - **Frontend**: React + Vite SPA — justified gallery, lightbox with rating
accept/reject thumbs, rating stars and tag chips, keyboard-driven culling stars and tag chips, drag-and-drop multi-file upload with progress.
(`P`/`X`/`U`, `1``5`, `?` shows all shortcuts), drag-and-drop multi-file
upload with progress. Touch-first on mobile: swipe sideways to browse,
flick a photo up/down to accept/reject (real physics — votes commit
instantly, the animation is decoration), pinch to zoom, always-visible
selection checkmarks. Voting the last photo fades back to the gallery.
## Local development ## Local development
@@ -78,6 +58,13 @@ 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
@@ -108,20 +95,20 @@ All configuration is via environment variables:
## Deploying to Kubernetes ## Deploying to Kubernetes
The prebuilt image is at `git.draic.info/nils/photos` (single image contains Build and push the image (single image contains `server`, `worker`, and the
`server`, `worker`, and the built frontend). To build your own instead: built frontend):
```sh ```sh
docker build -t registry.example.com/you/photos:0.5.1 . docker build -t ghcr.io/YOU/photos:0.1.0 .
docker push registry.example.com/you/photos:0.5.1 docker push ghcr.io/YOU/photos:0.1.0
``` ```
Install the chart, pointing it at your existing Postgres and S3: Install the chart, pointing it at your existing Postgres and S3:
```sh ```sh
helm install photos deploy/chart \ helm install photos deploy/chart \
--set image.repository=git.draic.info/nils/photos \ --set image.repository=ghcr.io/YOU/photos \
--set image.tag=0.5.1 \ --set image.tag=0.1.0 \
--set publicUrl=https://photos.example.com \ --set publicUrl=https://photos.example.com \
--set ingress.host=photos.example.com \ --set ingress.host=photos.example.com \
--set config.oidcIssuer=https://auth.example.com \ --set config.oidcIssuer=https://auth.example.com \
@@ -158,12 +145,9 @@ 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.
- Accept/reject votes (👍/👎), ratings (15 stars) and free-form tags are - Ratings (15 stars) and free-form tags are stored **per link**, so create
stored **per link**, so create one link per client to keep feedback one link per client to keep feedback separate. The album view shows all
separate. The album view shows all feedback grouped by link label, and feedback grouped by link label.
clients can filter their gallery by verdict (e.g. review only what's still
undecided, or select-all + download the accepted set). Culling works with
keyboard (`P`/`X` auto-advance) on desktop and swipe gestures on mobile.
- 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),
@@ -176,16 +160,14 @@ 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 votes/ratings/tags; deleting photos or albums - Deleting a link removes its ratings/tags; deleting photos or albums cleans
cleans up S3 objects via background jobs. 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).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

+8 -2
View File
@@ -73,9 +73,15 @@ export function postDownload(url, ids = '') {
form.remove() form.remove()
} }
// SHA-256 of a File, matching the server's content hash — used to skip // Above this, hashing whole-file in memory (WebCrypto has no streaming digest)
// uploading bytes the album already has. // risks OOM / the ~2GiB ArrayBuffer cap, so we skip the client dedup check and
// 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('')
} }
+80 -124
View File
@@ -1,134 +1,90 @@
import { useEffect, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { imgUrl } from '../api' import { imgUrl } from '../api'
import { isTypingTarget } from '../useEscape'
// Lightroom-style grid: fixed square cells, photos fully visible via // True justified layout: pack photos greedily into rows at their real aspect
// object-fit: contain. Cell size depends only on the container width, so a // ratios, then scale each row's height so it fills the container width
// photo renders identically no matter which other photos are in view. // exactly. No cropping, no stretch, and the last row simply renders at the
// // target height instead of being padded by a spacer.
// Keyboard (while `keyboard` is true, i.e. no lightbox/modal open): arrow function layoutRows(photos, containerWidth, targetHeight, gap) {
// keys move a cursor through the grid, Space opens the viewer on it, const rows = []
// Enter toggles selection, and the page's culling `actions` (P/X/U, stars, let row = []
// S) run against the cursor photo — same table as in the lightbox. let arSum = 0
// `externalIndex` mirrors the lightbox position into the cursor, so closing let index = 0
// the viewer continues where the culling run ended. for (const photo of photos) {
export default function Gallery({ const ar = photo.width && photo.height ? photo.width / photo.height : 1.5
photos, row.push({ photo, ar, index: index++ })
onOpen, arSum += ar
overlay, const gaps = (row.length - 1) * gap
selected, if (arSum * targetHeight + gaps >= containerWidth) {
onToggleSelect, rows.push({ items: row, height: (containerWidth - gaps) / arSum })
keyboard = false, row = []
externalIndex = -1, arSum = 0
actions, }
}) { }
if (row.length > 0) {
const gaps = (row.length - 1) * gap
rows.push({
items: row,
height: Math.min(targetHeight, (containerWidth - gaps) / arSum),
})
}
return rows
}
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
const [width, setWidth] = useState(0)
const observerRef = useRef(null)
// Callback ref: (re)attaches the observer whenever the container node
// mounts. A plain mount-effect misses the case where Gallery first renders
// empty (no container) and photos arrive later.
const containerRef = useCallback((node) => {
observerRef.current?.disconnect()
if (!node) return
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
observer.observe(node)
observerRef.current = observer
setWidth(node.getBoundingClientRect().width)
}, [])
if (photos.length === 0) return null
const gap = 6
const targetHeight = width < 700 ? 170 : 240
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
const selecting = selected && selected.size > 0 const selecting = selected && selected.size > 0
const containerRef = useRef(null)
// The cursor is always a valid index; `revealed` says whether the user has
// interacted yet. The first key press reveals the cursor where it is
// (nothing pre-marked on a fresh page, first arrow doesn't skip a photo).
const [cursor, setCursor] = useState(0)
const [revealed, setRevealed] = useState(false)
const live = useRef({})
live.current = { cursor, revealed, photos, onOpen, onToggleSelect, actions }
useEffect(() => {
if (externalIndex >= 0) {
setCursor(externalIndex)
setRevealed(true)
}
}, [externalIndex])
useEffect(() => {
if (cursor >= photos.length) setCursor(Math.max(0, photos.length - 1))
}, [cursor, photos.length])
useEffect(() => {
if (!keyboard) return
const handler = (e) => {
const tag = e.target.tagName
if (isTypingTarget(e.target) || tag === 'SELECT') return
// Focused buttons/links keep their native Space/Enter; everything else
// still reaches the grid.
const clickable = ['BUTTON', 'A', 'LABEL', 'INPUT'].includes(tag)
if (clickable && (e.key === ' ' || e.key === 'Enter')) return
if (e.metaKey || e.ctrlKey || e.altKey) return
const s = live.current
if (s.photos.length === 0) return
const photo = s.photos[s.cursor]
const el = containerRef.current
const columns = el ? getComputedStyle(el).gridTemplateColumns.split(' ').length : 1
const count = s.photos.length
let next = null
if (e.key === 'ArrowRight') next = Math.min(count - 1, s.cursor + 1)
else if (e.key === 'ArrowLeft') next = Math.max(0, s.cursor - 1)
else if (e.key === 'ArrowDown') next = Math.min(count - 1, s.cursor + columns)
else if (e.key === 'ArrowUp') next = Math.max(0, s.cursor - columns)
else if (e.key === ' ') {
if (photo) {
e.preventDefault()
setRevealed(true)
s.onOpen?.(s.cursor)
}
return
} else if (e.key === 'Enter') {
if (photo && s.onToggleSelect) {
e.preventDefault()
setRevealed(true)
s.onToggleSelect(photo.id)
}
return
} else {
const key = e.key.toLowerCase()
const action = s.actions?.find((a) => a.keys.includes(key))
if (action && photo) {
setRevealed(true)
action.run(photo, key)
}
return
}
e.preventDefault()
setRevealed(true)
// The first arrow press only reveals the cursor; movement starts with
// the second.
if (s.revealed) {
setCursor(next)
el?.children[next]?.scrollIntoView({ block: 'nearest' })
} else {
el?.children[s.cursor]?.scrollIntoView({ block: 'nearest' })
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [keyboard])
return ( return (
<div ref={containerRef} className={`gallery${selecting ? ' selecting' : ''}`}> <div ref={containerRef} className={`gallery${selecting ? ' selecting' : ''}`}>
{photos.map((p, index) => { {rows.map((row) => (
const isSelected = selected ? selected.has(p.id) : false <div key={row.items[0].photo.id} className="g-row" style={{ height: row.height }}>
return ( {row.items.map(({ photo: p, ar, index }) => {
<div const isSelected = selected ? selected.has(p.id) : false
key={p.id} return (
className={`g-item${isSelected ? ' selected' : ''}${revealed && index === cursor ? ' focused' : ''}`} <div
onClick={() => onOpen && onOpen(index)} key={p.id}
> className={`g-item${isSelected ? ' selected' : ''}`}
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} /> style={{ width: ar * row.height }}
{onToggleSelect && ( onClick={() => onOpen && onOpen(index)}
<button
className="g-check"
title={isSelected ? 'Deselect' : 'Select'}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(p.id, e.shiftKey)
}}
> >
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
</button> {onToggleSelect && (
)} <button
{overlay && overlay(p)} className="g-check"
</div> title={isSelected ? 'Deselect' : 'Select'}
) onClick={(e) => {
})} e.stopPropagation()
onToggleSelect(p.id)
}}
>
</button>
)}
{overlay && overlay(p)}
</div>
)
})}
</div>
))}
</div> </div>
) )
} }
+17 -185
View File
@@ -1,86 +1,18 @@
import { useEffect, useRef, useState } from 'react' import { useEffect } from 'react'
import { imgUrl } from '../api' import { imgUrl } from '../api'
import { isTypingTarget } from '../useEscape'
import useSwipe from '../useSwipe'
const BASE_SHORTCUTS = [ export default function Lightbox({ photos, index, onClose, onNav, footer }) {
['← / →', 'previous / next photo'],
['?', 'show / hide shortcuts'],
['Space / 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.
// `gestures` ({ up, down }) maps vertical touch flicks on the photo — the
// horizontal axis always navigates, so voting and browsing never collide.
// `closing` fades the whole modal out; `onClosed` fires when the fade ends
// (the page then actually unmounts the lightbox).
export default function Lightbox({
photos,
index,
onClose,
onNav,
footer,
actions,
gestures,
closing,
onClosed,
}) {
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, closing }
useEffect(() => { useEffect(() => {
const handler = (e) => { const onKey = (e) => {
const s = live.current if (e.key === 'Escape') onClose()
if (s.closing) return if (e.key === 'ArrowRight' && index < photos.length - 1) onNav(index + 1)
if (isTypingTarget(e.target)) { if (e.key === 'ArrowLeft' && index > 0) onNav(index - 1)
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 === ' ') {
e.preventDefault()
s.onClose()
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
if (s.index < s.count - 1) s.onNav(s.index + 1)
return
}
if (e.key === 'ArrowLeft') {
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', handler) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', handler) return () => window.removeEventListener('keydown', onKey)
}, []) }, [index, photos.length, onClose, onNav])
useEffect(() => { useEffect(() => {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
@@ -89,54 +21,15 @@ export default function Lightbox({
} }
}, []) }, [])
const preview = (p) => imgUrl(p, 'preview')
const prevPhoto = photos[index - 1]
const nextPhoto = photos[index + 1]
const swipe = useSwipe({
photo,
index,
count: photos.length,
onNav,
gestures,
hasNext: index < photos.length - 1,
})
// Immediate neighbors are real DOM slides (loaded by definition); warm the
// browser cache a few photos further out so fast swiping never hits the
// network.
useEffect(() => {
for (const i of [index + 2, index + 3, index - 2]) {
if (photos[i]) new Image().src = preview(photos[i])
}
}, [index, photos])
if (!photo) return null if (!photo) return null
// Did the vote fly off the photo we're still showing (no advance target)?
const exit = swipe.exit
const exitSelf = exit && exit.photo.id === photo.id
return ( return (
<div <div className="lightbox" onClick={onClose}>
className={`lightbox${closing ? ' lb-closing' : ''}`}
onClick={onClose}
onAnimationEnd={(e) => {
if (e.animationName === 'lb-fade-out') onClosed?.()
}}
>
<div className="lb-top" onClick={(e) => e.stopPropagation()}> <div className="lb-top" onClick={(e) => e.stopPropagation()}>
<span className="lb-name">{photo.filename}</span> <span className="lb-name">{photo.filename}</span>
<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>
@@ -151,55 +44,13 @@ export default function Lightbox({
> >
</button> </button>
<div className="lb-stage" {...swipe.handlers}> <div className="lb-stage">
<div <img
className="lb-track" className="lb-img"
ref={swipe.trackRef} src={imgUrl(photo, 'preview')}
style={swipe.trackStyle} alt={photo.filename}
onTransitionEnd={swipe.onTrackTransitionEnd} onClick={(e) => e.stopPropagation()}
> />
{prevPhoto && (
<div key={prevPhoto.id} className="lb-slide" style={{ transform: 'translateX(-100vw)' }}>
<img className="lb-img" src={preview(prevPhoto)} alt="" />
</div>
)}
<div key={photo.id} className="lb-slide" style={{ zIndex: 1 }}>
<img
ref={swipe.imgRef}
className={`lb-img${exit ? (exitSelf ? ' lb-hidden' : ' lb-enter') : ''}`}
style={swipe.imgStyle}
onTransitionEnd={swipe.onImgTransitionEnd}
src={preview(photo)}
alt={photo.filename}
onClick={(e) => e.stopPropagation()}
/>
</div>
{nextPhoto &&
(swipe.showBehind ? (
<div
key={nextPhoto.id}
ref={swipe.behindRef}
className="lb-slide lb-behind"
style={swipe.behindStyle}
>
<img className="lb-img" src={preview(nextPhoto)} alt="" />
</div>
) : (
<div key={nextPhoto.id} className="lb-slide" style={{ transform: 'translateX(100vw)' }}>
<img className="lb-img" src={preview(nextPhoto)} alt="" />
</div>
))}
</div>
{exit && (
<div
className="lb-slide lb-ghost"
style={{ '--dy': `${exit.dy}px`, '--rot': `${exit.dy / 40}deg` }}
onAnimationEnd={swipe.clearExit}
>
<img className={`lb-img lb-exit-${exit.dir}`} src={preview(exit.photo)} alt="" />
</div>
)}
{swipe.showBadge && <div ref={swipe.badgeRef} className="lb-flick" />}
</div> </div>
<button <button
className="lb-nav lb-next" className="lb-nav lb-next"
@@ -216,25 +67,6 @@ export default function Lightbox({
{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>
) )
} }
-19
View File
@@ -1,19 +0,0 @@
import useEscape from '../useEscape'
export default function Modal({ title, onClose, children }) {
useEscape(onClose)
return (
<div className="modal" onClick={onClose}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2>{title}</h2>
<button className="lb-btn" onClick={onClose} title="Close (Esc)">
</button>
</div>
{children}
</div>
</div>
)
}
-6
View File
@@ -15,7 +15,6 @@ export default function SelectionBar({
onClear, onClear,
onDownload, onDownload,
onDownloadAll, onDownloadAll,
onDelete,
}) { }) {
if (total === 0) return null if (total === 0) return null
@@ -48,11 +47,6 @@ export default function SelectionBar({
<button className="btn btn-primary" onClick={onDownload}> <button className="btn btn-primary" onClick={onDownload}>
Download {count} as ZIP ({fmtBytes(selectedBytes)}) Download {count} as ZIP ({fmtBytes(selectedBytes)})
</button> </button>
{onDelete && (
<button className="btn btn-danger" onClick={onDelete}>
Delete {count}
</button>
)}
</div> </div>
) )
} }
-282
View File
@@ -1,282 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../api'
import { ACCEPT_GLYPH, REJECT_GLYPH } from './Thumbs'
// Expiry convention, in one place for the create form and the row editor:
// end of the chosen day in the photographer's local timezone — date-only
// strings would parse as UTC midnight and expire a day early. Half-typed
// years (Safari fires change with e.g. 0002) count as "no expiry".
const endOfDayIso = (day) =>
!day || day.slice(0, 4) < '2000' ? null : new Date(`${day}T23:59:59`).toISOString()
const localDate = (iso) => {
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// Staged expiry editor: commits on blur/Enter, never per keystroke. Renders
// "never" until an expiry exists or the user asks for one — an empty date
// input is not shown at all (Safari fills it with today's date as a
// pseudo-placeholder, which reads like a set expiry).
function ExpiryDate({ value, onCommit }) {
const current = value ? localDate(value) : ''
const [draft, setDraft] = useState(current)
const [editing, setEditing] = useState(false)
useEffect(() => setDraft(current), [current])
const commit = () => {
setEditing(false)
if (draft === current) return
const iso = endOfDayIso(draft)
if (draft && !iso) {
setDraft(current)
return
}
onCommit(iso)
}
if (!value && !editing) {
return (
<label className="row field-label">
<span className="muted">expires</span>
<button
type="button"
className="btn btn-ghost"
title="Set an expiry date"
onClick={() => setEditing(true)}
>
never
</button>
</label>
)
}
return (
<label className="row field-label">
<span className="muted">expires</span>
<input
type="date"
autoFocus={editing}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.target.blur()
}}
/>
{value && (
<button
type="button"
className="btn btn-ghost"
title="Remove expiry — link never expires"
onClick={() => {
setDraft('')
setEditing(false)
onCommit(null)
}}
>
</button>
)}
</label>
)
}
export default function SharesPanel({ albumId }) {
const [shares, setShares] = useState([])
const [form, setForm] = useState({ label: '', password: '', allow_download: true, expires_at: '' })
const [expiryOpen, setExpiryOpen] = useState(false)
const [error, setError] = useState(null)
const [copied, setCopied] = useState(null)
const load = useCallback(
() => api(`/api/albums/${albumId}/shares`).then(setShares).catch((e) => setError(e.message)),
[albumId],
)
useEffect(() => {
load()
}, [load])
const create = async (e) => {
e.preventDefault()
try {
await api(`/api/albums/${albumId}/shares`, {
method: 'POST',
body: {
label: form.label,
password: form.password || null,
allow_download: form.allow_download,
expires_at: endOfDayIso(form.expires_at),
},
})
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
setExpiryOpen(false)
setError(null)
load()
} catch (e) {
setError(e.message)
}
}
const copy = async (share) => {
await navigator.clipboard.writeText(share.url)
setCopied(share.id)
setTimeout(() => setCopied(null), 1500)
}
const update = async (shareId, patch) => {
try {
const updated = await api(`/api/shares/${shareId}`, { method: 'PATCH', body: patch })
setShares((list) => list.map((s) => (s.id === shareId ? updated : s)))
setError(null)
} catch (e) {
setError(e.message)
// Re-sync the controlled inputs with what the server actually has.
load()
}
}
return (
<div>
{shares.length === 0 && <p className="muted">No links yet.</p>}
{shares.map((s) => (
<div key={s.id} className="share-row">
<div className="share-info">
<span className="row">
<strong>{s.label || 'unnamed link'}</strong>
{s.has_password && <span title="Password protected">🔒</span>}
{s.locked && (
<>
<span className="error">locked (too many wrong passwords)</span>
<button
className="btn"
onClick={async () => {
await api(`/api/shares/${s.id}/reset-lock`, { method: 'POST' })
load()
}}
>
Unlock
</button>
</>
)}
</span>
<button className="share-url" title="Copy link" onClick={() => copy(s)}>
{copied === s.id ? 'Copied!' : s.url.replace(/^https?:\/\//, '')}
</button>
{s.accept_count + s.reject_count + s.rating_count + s.tag_count > 0 && (
<span className="row share-feedback">
<span className="share-stats">
{s.accept_count > 0 && (
<span className="chip chip-accept">
{ACCEPT_GLYPH} {s.accept_count}
</span>
)}
{s.reject_count > 0 && (
<span className="chip chip-reject">
{REJECT_GLYPH} {s.reject_count}
</span>
)}
{s.rating_count > 0 && <span className="chip"> {s.rating_count}</span>}
{s.tag_count > 0 && <span className="chip"># {s.tag_count}</span>}
</span>
</span>
)}
</div>
<div className="row share-controls">
<label className="select-toggle" title="Allow this link to download originals/ZIPs">
<input
type="checkbox"
checked={s.allow_download}
onChange={(e) => update(s.id, { allow_download: e.target.checked })}
/>
downloads
</label>
<ExpiryDate value={s.expires_at} onCommit={(iso) => update(s.id, { expires_at: iso })} />
<button className="btn" onClick={() => copy(s)}>
{copied === s.id ? 'Copied!' : 'Copy link'}
</button>
<button
className="btn btn-danger"
title="Delete link — removes its votes, ratings and tags too"
onClick={async () => {
if (!confirm(`Delete link "${s.label || s.token}"? Client votes, ratings and tags from this link are removed too.`)) return
await api(`/api/shares/${s.id}`, { method: 'DELETE' })
load()
}}
>
Delete
</button>
</div>
</div>
))}
<form className="share-form" onSubmit={create}>
<h3 className="share-form-title">New link</h3>
<label className="field">
<span className="muted">Label</span>
<input
placeholder="e.g. client name"
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
/>
</label>
<label className="field">
<span className="muted">Password</span>
<input
placeholder="optional"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</label>
<label className="field">
<span className="muted">Expires</span>
<span className="row">
{form.expires_at || expiryOpen ? (
<>
<input
type="date"
autoFocus={expiryOpen && !form.expires_at}
value={form.expires_at}
onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
/>
<button
type="button"
className="btn btn-ghost"
title="No expiry — link never expires"
onClick={() => {
setExpiryOpen(false)
setForm({ ...form, expires_at: '' })
}}
>
</button>
</>
) : (
<button
type="button"
className="btn btn-ghost"
title="Set an expiry date"
onClick={() => setExpiryOpen(true)}
>
never
</button>
)}
</span>
</label>
<label className="field">
<span className="muted">Downloads</span>
<input
type="checkbox"
checked={form.allow_download}
onChange={(e) => setForm({ ...form, allow_download: e.target.checked })}
/>
</label>
<div className="share-form-full">
<button className="btn btn-primary" type="submit">
Create link
</button>
</div>
</form>
{error && <p className="error">{error}</p>}
</div>
)
}
-27
View File
@@ -1,27 +0,0 @@
export const ACCEPT_GLYPH = '👍'
export const REJECT_GLYPH = '👎'
// 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_GLYPH, 'Accept (P)')}
{thumb('reject', REJECT_GLYPH, 'Reject (X)')}
</span>
)
}
-306
View File
@@ -1,306 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, sha256Hex, uploadFile } from '../api'
import { fmtBytes } from './SelectionBar'
const UPLOAD_CONCURRENCY = 3
function fmtEta(seconds) {
if (!isFinite(seconds) || seconds < 0) return ''
if (seconds < 60) return `${Math.ceil(seconds)}s`
if (seconds < 3600) return `${Math.ceil(seconds / 60)} min`
return `${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)} min`
}
export default function UploadZone({ albumId, onUploaded, processing, onRetry, onDelete }) {
const [queue, setQueue] = useState([])
const [dragging, setDragging] = useState(false)
const [speed, setSpeed] = useState(0)
const inputRef = useRef(null)
const running = useRef(0)
const pending = useRef([])
const lastRefresh = useRef(0)
const loadedRef = useRef(0)
const totalBytes = queue.reduce((sum, item) => sum + item.file.size, 0)
const loadedBytes = queue.reduce(
(sum, item) =>
sum + (item.status === 'done' ? item.file.size : (item.progress || 0) * item.file.size),
0,
)
loadedRef.current = loadedBytes
const active = queue.some((item) => ['uploading', 'queued', 'checking'].includes(item.status))
// Sample throughput once a second (EMA-smoothed) while uploads run.
useEffect(() => {
if (!active) {
setSpeed(0)
return
}
let last = { loaded: loadedRef.current, time: Date.now() }
const timer = setInterval(() => {
const now = Date.now()
const instant = (loadedRef.current - last.loaded) / ((now - last.time) / 1000)
last = { loaded: loadedRef.current, time: now }
setSpeed((prev) => (prev > 0 ? prev * 0.7 + instant * 0.3 : instant))
}, 1000)
return () => clearInterval(timer)
}, [active])
// Refresh the album at most every 5s during a bulk upload (the processing
// poll keeps it fresh anyway), plus once when the queue drains.
const refresh = useCallback(() => {
const drained = running.current === 0 && pending.current.length === 0
if (drained || Date.now() - lastRefresh.current > 5000) {
lastRefresh.current = Date.now()
onUploaded()
}
}, [onUploaded])
const pump = useCallback(() => {
while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) {
const item = pending.current.shift()
running.current += 1
const update = (patch) =>
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, ...patch } : x)))
const transfer = async () => {
// Hash locally first: content the album already has is skipped
// without transferring a single byte.
update({ status: 'checking' })
try {
const hash = await sha256Hex(item.file)
await api(`/api/albums/${albumId}/photos/by-hash/${hash}`)
update({ status: 'skipped', progress: 1 })
return
} catch {
// 404 (not there yet) or hashing unavailable — upload normally.
}
update({ status: 'uploading' })
const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}`
await uploadFile(url, item.file, (p) => update({ progress: p }))
update({ status: 'done', progress: 1 })
}
transfer()
.catch((e) => update({ status: 'error', error: e.message }))
.finally(() => {
running.current -= 1
refresh()
pump()
})
}
}, [albumId, refresh])
const addFiles = useCallback(
(files) => {
const items = [...files].map((file, i) => ({
key: `${Date.now()}-${i}-${file.name}`,
file,
status: 'queued',
progress: 0,
}))
if (items.length === 0) return
setQueue((q) => [...q.filter((x) => x.status !== 'done'), ...items])
pending.current.push(...items)
pump()
},
[pump],
)
// The whole window is the drop target; a fullscreen overlay signals it
// while a file drag is over the page. Depth counter because dragenter/
// dragleave fire for every crossed element.
useEffect(() => {
let depth = 0
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes('Files')
const enter = (e) => {
if (!hasFiles(e)) return
e.preventDefault()
depth += 1
setDragging(true)
}
const over = (e) => {
if (hasFiles(e)) e.preventDefault()
}
const leave = (e) => {
if (!hasFiles(e)) return
depth -= 1
if (depth <= 0) {
depth = 0
setDragging(false)
}
}
const drop = (e) => {
if (!hasFiles(e)) return
e.preventDefault()
depth = 0
setDragging(false)
addFiles(e.dataTransfer.files)
}
window.addEventListener('dragenter', enter)
window.addEventListener('dragover', over)
window.addEventListener('dragleave', leave)
window.addEventListener('drop', drop)
return () => {
window.removeEventListener('dragenter', enter)
window.removeEventListener('dragover', over)
window.removeEventListener('dragleave', leave)
window.removeEventListener('drop', drop)
}
}, [addFiles])
return (
<>
<button
className="btn btn-primary"
title="Or drop RAWs/JPGs anywhere on the page"
onClick={() => inputRef.current?.click()}
>
Upload
</button>
<input
ref={inputRef}
type="file"
multiple
hidden
onChange={(e) => {
addFiles(e.target.files)
e.target.value = ''
}}
/>
{dragging && <div className="drop-overlay">Drop to upload</div>}
<ActivityOverlay
queue={queue}
active={active}
speed={speed}
loadedBytes={loadedBytes}
totalBytes={totalBytes}
processing={processing}
onRetry={onRetry}
onDelete={onDelete}
onClearDone={() =>
setQueue((q) => q.filter((i) => i.status !== 'done' && i.status !== 'skipped'))
}
/>
</>
)
}
// Bottom-right collapsible activity panel: upload queue and the worker's
// processing queue in one place, out of the page flow.
function ActivityOverlay({
queue,
active,
speed,
loadedBytes,
totalBytes,
processing,
onRetry,
onDelete,
onClearDone,
}) {
const [collapsed, setCollapsed] = useState(false)
if (queue.length === 0 && processing.length === 0) return null
const finished = queue.filter((i) => i.status === 'done' || i.status === 'skipped').length
const uploadErrors = queue.filter((i) => i.status === 'error').length
const working = processing.filter((p) => p.status !== 'error')
const failed = processing.filter((p) => p.status === 'error')
const summary = [
queue.length > 0 &&
(active
? `Uploading ${finished}/${queue.length}`
: uploadErrors > 0
? `${uploadErrors} upload${uploadErrors === 1 ? '' : 's'} failed`
: 'Uploads done'),
working.length > 0 && `${working.length} processing`,
].filter(Boolean)
return (
<div className="activity">
<button className="activity-head" onClick={() => setCollapsed((c) => !c)}>
<span className="upload-name">
{summary.join(' · ')}
{failed.length > 0 && (
<span className="error">
{summary.length > 0 && ' · '}
{failed.length} failed
</span>
)}
</span>
<span className="row">
{!active && finished > 0 && (
<span
className="activity-clear"
title="Clear finished uploads"
onClick={(e) => {
e.stopPropagation()
onClearDone()
}}
>
</span>
)}
{collapsed ? '▸' : '▾'}
</span>
</button>
{active && <progress className="activity-total" value={loadedBytes} max={totalBytes || 1} />}
{!collapsed && (
<div className="activity-body">
{queue.length > 0 && (
<>
<div className="muted activity-section">
{fmtBytes(loadedBytes)} of {fmtBytes(totalBytes)}
{active && speed > 0 && (
<>
{' · '}
{fmtBytes(speed)}/s · ~{fmtEta((totalBytes - loadedBytes) / speed)} left
</>
)}
</div>
<ul className="upload-list">
{queue.map((item) => (
<li key={item.key} className={`upload-item ${item.status}`}>
<span className="upload-name">{item.file.name}</span>
{item.status === 'error' ? (
<span className="error">{item.error}</span>
) : item.status === 'skipped' ? (
<span className="muted">already uploaded</span>
) : item.status === 'checking' ? (
<span className="muted">checking</span>
) : (
<progress value={item.progress} max="1" />
)}
</li>
))}
</ul>
</>
)}
{processing.length > 0 && (
<>
<div className="muted activity-section">Processing</div>
<ul className="pending-list">
{processing.map((p) => (
<li key={p.id}>
<span className="upload-name">{p.filename}</span>
{p.status === 'error' ? (
<span className="row">
<span className="error">{p.error || 'failed'}</span>
<button className="btn" onClick={() => onRetry(p)}>
Retry
</button>
<button className="btn btn-danger" onClick={() => onDelete(p.id)}>
Delete
</button>
</span>
) : (
<span className="muted">{p.status}</span>
)}
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
)
}
-80
View File
@@ -1,80 +0,0 @@
import { useEffect, useState } from 'react'
import { api } from '../api'
import Modal from './Modal'
import { ACCEPT_GLYPH, REJECT_GLYPH } from './Thumbs'
export default function XmpModal({ albumId, onClose }) {
const [shares, setShares] = useState(null)
const [own, setOwn] = useState(true)
const [checked, setChecked] = useState(() => new Set())
const [error, setError] = useState(null)
useEffect(() => {
api(`/api/albums/${albumId}/shares`)
.then((list) => {
setShares(list)
setChecked(new Set(list.map((s) => s.id)))
})
.catch((e) => setError(e.message))
}, [albumId])
const toggleShare = (id) =>
setChecked((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
// Disabled until the share list is loaded — before that, "everything
// checked" can't be expressed and a click would export too little.
const disabled = shares == null || (checked.size === 0 && !own)
const url = `/api/albums/${albumId}/xmp?${new URLSearchParams({
shares: [...checked].join(','),
own: String(own),
})}`
const counts = (s) =>
[
s.accept_count > 0 && `${ACCEPT_GLYPH} ${s.accept_count}`,
s.reject_count > 0 && `${REJECT_GLYPH} ${s.reject_count}`,
s.rating_count > 0 && `${s.rating_count}`,
s.tag_count > 0 && `# ${s.tag_count}`,
]
.filter(Boolean)
.join(' · ') || 'no feedback yet'
return (
<Modal title="XMP export" onClose={onClose}>
<p className="muted">
Sidecar files for Capture One/Lightroom unzip next to the RAWs, then load metadata.
Ratings merge as the highest vote across the selected sources; any accept wins over
rejects.
</p>
<label className="select-toggle xmp-source">
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} />
you
<span className="muted">your votes and ratings</span>
</label>
{(shares ?? []).map((s) => (
<label key={s.id} className="select-toggle xmp-source">
<input type="checkbox" checked={checked.has(s.id)} onChange={() => toggleShare(s.id)} />
{s.label || 'unnamed link'}
<span className="muted">{counts(s)}</span>
</label>
))}
<div className="row modal-foot">
<a
className={`btn btn-primary${disabled ? ' btn-disabled' : ''}`}
href={disabled ? undefined : url}
onClick={(e) => {
if (disabled) e.preventDefault()
}}
>
Download XMP
</a>
</div>
{error && <p className="error">{error}</p>}
</Modal>
)
}
+401 -345
View File
@@ -1,38 +1,321 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, postDownload } from '../api' import { api, postDownload, sha256Hex, uploadFile } from '../api'
import Gallery from '../components/Gallery' import Gallery from '../components/Gallery'
import Lightbox from '../components/Lightbox' import Lightbox from '../components/Lightbox'
import Modal from '../components/Modal' import SelectionBar, { fmtBytes } from '../components/SelectionBar'
import SelectionBar from '../components/SelectionBar'
import SharesPanel from '../components/SharesPanel'
import Stars from '../components/Stars' import Stars from '../components/Stars'
import Thumbs, { ACCEPT_GLYPH, REJECT_GLYPH } from '../components/Thumbs'
import UploadZone from '../components/UploadZone'
import XmpModal from '../components/XmpModal'
import useCulling from '../useCulling'
import useLightbox from '../useLightbox'
import useSelection from '../useSelection' import useSelection from '../useSelection'
const FEEDBACK_FILTERS = [ function fmtEta(seconds) {
{ key: 'all', label: 'All' }, if (!isFinite(seconds) || seconds < 0) return ''
{ key: 'accept', label: ACCEPT_GLYPH }, if (seconds < 60) return `${Math.ceil(seconds)}s`
{ key: 'reject', label: REJECT_GLYPH }, if (seconds < 3600) return `${Math.ceil(seconds / 60)} min`
{ key: 'undecided', label: 'Undecided' }, return `${Math.floor(seconds / 3600)}h ${Math.ceil((seconds % 3600) / 60)} min`
] }
// Feedback regrouped per client link for the lightbox footer — the const UPLOAD_CONCURRENCY = 3
// meaningful reading unit is "what did this person say".
const clientFeedback = (fb) => { function UploadZone({ albumId, onUploaded }) {
const map = new Map() const [queue, setQueue] = useState([])
const entry = (id, label) => { const [dragging, setDragging] = useState(false)
if (!map.has(id)) map.set(id, { label: label || 'client', tags: [] }) const [speed, setSpeed] = useState(0)
return map.get(id) const inputRef = useRef(null)
const running = useRef(0)
const pending = useRef([])
const lastRefresh = useRef(0)
const loadedRef = useRef(0)
const totalBytes = queue.reduce((sum, item) => sum + item.file.size, 0)
const loadedBytes = queue.reduce(
(sum, item) =>
sum + (item.status === 'done' ? item.file.size : (item.progress || 0) * item.file.size),
0,
)
loadedRef.current = loadedBytes
const active = queue.some((item) =>
['uploading', 'queued', 'checking'].includes(item.status),
)
// Sample throughput once a second (EMA-smoothed) while uploads run.
useEffect(() => {
if (!active) {
setSpeed(0)
return
}
let last = { loaded: loadedRef.current, time: Date.now() }
const timer = setInterval(() => {
const now = Date.now()
const instant = (loadedRef.current - last.loaded) / ((now - last.time) / 1000)
last = { loaded: loadedRef.current, time: now }
setSpeed((prev) => (prev > 0 ? prev * 0.7 + instant * 0.3 : instant))
}, 1000)
return () => clearInterval(timer)
}, [active])
// Refresh the album at most every 5s during a bulk upload (the processing
// poll keeps it fresh anyway), plus once when the queue drains.
const refresh = useCallback(() => {
const drained = running.current === 0 && pending.current.length === 0
if (drained || Date.now() - lastRefresh.current > 5000) {
lastRefresh.current = Date.now()
onUploaded()
}
}, [onUploaded])
const pump = useCallback(() => {
while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) {
const item = pending.current.shift()
running.current += 1
const update = (patch) =>
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, ...patch } : x)))
const transfer = async () => {
// Hash locally first: content the album already has is skipped
// without transferring a single byte. Files too large to hash in the
// browser (null) fall straight through to a normal upload.
update({ status: 'checking' })
try {
const hash = await sha256Hex(item.file)
if (hash) {
await api(`/api/albums/${albumId}/photos/by-hash/${hash}`)
update({ status: 'skipped', progress: 1 })
return
}
} catch {
// 404 (not there yet) or hashing unavailable — upload normally.
}
update({ status: 'uploading' })
const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}`
await uploadFile(url, item.file, (p) => update({ progress: p }))
update({ status: 'done', progress: 1 })
}
transfer()
.catch((e) => update({ status: 'error', error: e.message }))
.finally(() => {
running.current -= 1
refresh()
pump()
})
}
}, [albumId, refresh])
const addFiles = (files) => {
const items = [...files].map((file, i) => ({
key: `${Date.now()}-${i}-${file.name}`,
file,
status: 'queued',
progress: 0,
}))
if (items.length === 0) return
setQueue((q) => [...q.filter((x) => x.status !== 'done'), ...items])
pending.current.push(...items)
pump()
} }
for (const r of fb.ratings) entry(r.share_id, r.share_label).rating = r.rating
for (const v of fb.verdicts) entry(v.share_id, v.share_label).verdict = v.verdict return (
for (const t of fb.tags) entry(t.share_id, t.share_label).tags.push(t.tag) <div
return [...map.entries()] className={`upload-zone${dragging ? ' dragging' : ''}`}
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault()
setDragging(false)
addFiles(e.dataTransfer.files)
}}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
multiple
hidden
onChange={(e) => {
addFiles(e.target.files)
e.target.value = ''
}}
/>
<p>Drop RAWs or JPGs here, or click to select</p>
{queue.length > 0 && (
<div className="upload-summary" onClick={(e) => e.stopPropagation()}>
<progress className="upload-total" value={loadedBytes} max={totalBytes || 1} />
<span className="muted">
{queue.filter((i) => i.status === 'done' || i.status === 'skipped').length} /{' '}
{queue.length} files ·{' '}
{fmtBytes(loadedBytes)} of {fmtBytes(totalBytes)}
{active && speed > 0 && (
<>
{' · '}
{fmtBytes(speed)}/s · ~{fmtEta((totalBytes - loadedBytes) / speed)} left
</>
)}
</span>
</div>
)}
{queue.length > 0 && (
<ul className="upload-list" onClick={(e) => e.stopPropagation()}>
{queue.map((item) => (
<li key={item.key} className={`upload-item ${item.status}`}>
<span className="upload-name">{item.file.name}</span>
{item.status === 'error' ? (
<span className="error">{item.error}</span>
) : item.status === 'skipped' ? (
<span className="muted">already uploaded</span>
) : item.status === 'checking' ? (
<span className="muted">checking</span>
) : (
<progress value={item.progress} max="1" />
)}
</li>
))}
</ul>
)}
</div>
)
}
function SharesPanel({ albumId }) {
const [shares, setShares] = useState([])
const [form, setForm] = useState({ label: '', password: '', allow_download: true, expires_at: '' })
const [error, setError] = useState(null)
const [copied, setCopied] = useState(null)
const load = useCallback(
() => api(`/api/albums/${albumId}/shares`).then(setShares).catch((e) => setError(e.message)),
[albumId],
)
useEffect(() => {
load()
}, [load])
const create = async (e) => {
e.preventDefault()
try {
await api(`/api/albums/${albumId}/shares`, {
method: 'POST',
body: {
label: form.label,
password: form.password || null,
allow_download: form.allow_download,
// End of the chosen day in the photographer's local timezone —
// date-only strings would parse as UTC midnight and expire a day early.
expires_at: form.expires_at
? new Date(`${form.expires_at}T23:59:59`).toISOString()
: null,
},
})
setForm({ label: '', password: '', allow_download: true, expires_at: '' })
setError(null)
load()
} catch (e) {
setError(e.message)
}
}
const copy = async (share) => {
await navigator.clipboard.writeText(share.url)
setCopied(share.id)
setTimeout(() => setCopied(null), 1500)
}
return (
<section className="panel">
<h2>Client links</h2>
{shares.length === 0 && <p className="muted">No links yet.</p>}
{shares.map((s) => (
<div key={s.id} className="share-row">
<div className="share-info">
<strong>
{s.label || 'unnamed link'}
{s.locked && <span className="error"> locked (too many wrong passwords)</span>}
</strong>
<span className="muted">
{s.has_password ? '🔒 password' : 'no password'}
{' · '}
{s.allow_download ? 'downloads on' : 'downloads off'}
{s.expires_at
? ` · expires ${new Date(s.expires_at).toLocaleDateString()}`
: ' · never expires'}
{' · '}
{s.rating_count} ratings, {s.tag_count} tags
</span>
</div>
<div className="row">
{s.locked && (
<button
className="btn"
onClick={async () => {
await api(`/api/shares/${s.id}/reset-lock`, { method: 'POST' })
load()
}}
>
Unlock
</button>
)}
<button className="btn" onClick={() => copy(s)}>
{copied === s.id ? 'Copied!' : 'Copy link'}
</button>
<button
className="btn btn-danger"
onClick={async () => {
if (!confirm(`Delete link "${s.label || s.token}"? Client ratings and tags from this link are removed too.`)) return
await api(`/api/shares/${s.id}`, { method: 'DELETE' })
load()
}}
>
Delete
</button>
</div>
</div>
))}
<form className="share-form" onSubmit={create}>
<input
placeholder="Label (e.g. client name)"
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
/>
<input
placeholder="Password (optional)"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<label className="row field-label">
<span className="muted">Expires</span>
<input
type="date"
value={form.expires_at}
onChange={(e) => setForm({ ...form, expires_at: e.target.value })}
/>
{form.expires_at ? (
<button
type="button"
className="btn btn-ghost"
title="Remove expiry — link never expires"
onClick={() => setForm({ ...form, expires_at: '' })}
>
</button>
) : (
<span className="muted">never</span>
)}
</label>
<label className="row">
<input
type="checkbox"
checked={form.allow_download}
onChange={(e) => setForm({ ...form, allow_download: e.target.checked })}
/>
allow downloads
</label>
<button className="btn btn-primary" type="submit">
Create link
</button>
</form>
{error && <p className="error">{error}</p>}
</section>
)
} }
export default function AlbumPage() { export default function AlbumPage() {
@@ -40,6 +323,9 @@ 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)),
@@ -49,184 +335,36 @@ export default function AlbumPage() {
load() load()
}, [load]) }, [load])
// While workers are busy, poll the lightweight pending endpoint and only // Poll while any photo is still being processed by the workers.
// refetch the full album when something actually changed. The signature const hasPending = detail?.photos.some((p) => p.status === 'uploaded' || p.status === 'processing')
// lives in a ref so votes/patches don't restart (and starve) the interval.
const notReady = (detail?.photos ?? []).filter((p) => p.status !== 'ready')
const hasPending = notReady.some((p) => p.status === 'uploaded' || p.status === 'processing')
const signature = (list) => list.map((p) => `${p.id}:${p.status}`).sort().join()
const pendingSig = useRef('')
pendingSig.current = signature(notReady)
useEffect(() => { useEffect(() => {
if (!hasPending) return if (!hasPending) return
const t = setInterval(async () => { const t = setInterval(load, 4000)
try {
const status = await api(`/api/albums/${id}/pending`)
if (signature(status.pending) !== pendingSig.current) load()
} catch {
// transient; next tick retries
}
}, 4000)
return () => clearInterval(t) return () => clearInterval(t)
}, [hasPending, id, load]) }, [hasPending, load])
const ready = useMemo( const ready = (detail?.photos ?? []).filter((p) => p.status === 'ready')
() => (detail?.photos ?? []).filter((p) => p.status === 'ready'), const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(ready)
[detail], const lightboxIndex = ready.findIndex((p) => p.id === lightboxId)
)
const feedback = detail?.feedback ?? {}
const aggregates = detail?.aggregates ?? {}
// Whose feedback the filters and overlays look at: null = everyone // If the open photo leaves the ready list (deleted elsewhere, reprocess),
// (clients + own, server-aggregated), 'own', or a share id. // close for good — otherwise the lightbox would pop back open when the
const [client, setClient] = useState(null) // photo returns to ready.
// One pass per (album data, scope): everything downstream — filter,
// counts, overlay — reads this map instead of re-deriving per render.
const scoped = useMemo(() => {
const map = new Map()
for (const p of ready) {
const fb = feedback[p.id]
let top = null
let accepts = 0
let rejects = 0
let tags = 0
if (client === 'own') {
top = p.owner_rating ?? null
if (p.owner_verdict === 'accept') accepts = 1
if (p.owner_verdict === 'reject') rejects = 1
} else if (client) {
for (const r of fb?.ratings ?? [])
if (r.share_id === client) top = Math.max(top ?? 0, r.rating)
for (const v of fb?.verdicts ?? [])
if (v.share_id === client) v.verdict === 'accept' ? (accepts += 1) : (rejects += 1)
for (const t of fb?.tags ?? []) if (t.share_id === client) tags += 1
} else {
const agg = aggregates[p.id]
top = agg?.top_rating ?? null
accepts = agg?.accepts ?? 0
rejects = agg?.rejects ?? 0
tags = fb?.tags.length ?? 0
}
map.set(p.id, { top, accepts, rejects, tags })
}
return map
}, [detail, client])
const [filter, setFilter] = useState('all')
const [minStars, setMinStars] = useState(0)
const [linksOpen, setLinksOpen] = useState(false)
const [xmpOpen, setXmpOpen] = useState(false)
const matchesVerdict = (s, key) => {
if (key === 'all') return true
if (key === 'accept') return s.accepts > 0
if (key === 'reject') return s.rejects > 0
return s.accepts + s.rejects === 0
}
const shown = useMemo(
() =>
ready.filter((p) => {
const s = scoped.get(p.id)
return matchesVerdict(s, filter) && (minStars === 0 || (s.top ?? 0) >= minStars)
}),
[ready, scoped, filter, minStars],
)
const filterCounts = useMemo(() => {
const counts = { all: ready.length, accept: 0, reject: 0, undecided: 0 }
for (const p of ready) {
const s = scoped.get(p.id)
if (s.accepts > 0) counts.accept += 1
if (s.rejects > 0) counts.reject += 1
if (s.accepts + s.rejects === 0) counts.undecided += 1
}
return counts
}, [ready, scoped])
const clientTotals = useMemo(() => {
const totals = new Map()
const entry = (id, label) => {
if (!totals.has(id))
totals.set(id, { label: label || 'client', accepts: 0, rejects: 0, ratings: 0, tags: 0 })
return totals.get(id)
}
for (const fb of Object.values(feedback)) {
for (const r of fb.ratings) entry(r.share_id, r.share_label).ratings += 1
for (const v of fb.verdicts)
entry(v.share_id, v.share_label)[v.verdict === 'accept' ? 'accepts' : 'rejects'] += 1
for (const t of fb.tags) entry(t.share_id, t.share_label).tags += 1
}
return [...totals.entries()]
}, [detail])
const ownTotals = useMemo(() => {
const totals = { accepts: 0, rejects: 0, ratings: 0, tags: 0 }
for (const p of ready) {
if (p.owner_verdict === 'accept') totals.accepts += 1
if (p.owner_verdict === 'reject') totals.rejects += 1
if (p.owner_rating) totals.ratings += 1
}
return totals
}, [ready])
// The active scope's pill stays visible even at zero counts, so clearing
// your last vote can't strand the filter without a control.
const strip = [
['own', { label: 'you', ...ownTotals }],
...clientTotals,
].filter(([key, t]) => key === client || t.accepts + t.rejects + t.ratings + t.tags > 0)
// Reset only when the scoped share is gone entirely (deleted link).
useEffect(() => { useEffect(() => {
if (client && client !== 'own' && !clientTotals.some(([id]) => id === client)) setClient(null) if (lightboxId && lightboxIndex < 0) setLightboxId(null)
}, [client, clientTotals]) }, [lightboxId, lightboxIndex])
const { selected, toggle, selectAll, clear } = useSelection(ready)
const shownSelected = shown.filter((p) => selected.has(p.id))
const sumBytes = (list) => list.reduce((sum, p) => sum + p.size_bytes, 0)
const lightbox = useLightbox(shown)
const patchPhoto = (photoId, patch) =>
setDetail((d) => ({
...d,
photos: d.photos.map((p) => (p.id === photoId ? { ...p, ...patch } : p)),
}))
// Per-photo sequence: only the latest request's aggregate may land, so
// out-of-order responses can't overwrite fresher state.
const aggSeq = useRef(new Map())
const saveOwner = async (photo, patch, endpoint, body) => {
patchPhoto(photo.id, patch)
const seq = (aggSeq.current.get(photo.id) ?? 0) + 1
aggSeq.current.set(photo.id, seq)
try {
const res = await api(`/api/photos/${photo.id}/${endpoint}`, { method: 'PUT', body })
if (aggSeq.current.get(photo.id) !== seq) return
setDetail((d) => ({
...d,
aggregates: { ...d.aggregates, [photo.id]: res.aggregate },
}))
} catch {
if (aggSeq.current.get(photo.id) === seq) load()
}
}
const setOwnerRating = (photo, rating) =>
saveOwner(photo, { owner_rating: rating || null }, 'rating', { rating })
const setOwnerVerdict = (photo, verdict) =>
saveOwner(photo, { owner_verdict: verdict }, 'verdict', { verdict })
const culling = useCulling({
visible: lightbox.view,
lightbox,
setVerdict: setOwnerVerdict,
setRating: setOwnerRating,
toggle,
})
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>
const { album, photos } = detail const { album, photos, feedback } = detail
const notReady = photos.filter((p) => p.status !== 'ready')
const avgRating = (photoId) => {
const ratings = feedback[photoId]?.ratings || []
if (ratings.length === 0) return null
return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length
}
const rename = async () => { const rename = async () => {
const name = prompt('Album name', album.name) const name = prompt('Album name', album.name)
@@ -244,24 +382,11 @@ export default function AlbumPage() {
const removePhoto = async (photoId) => { const removePhoto = async (photoId) => {
if (!confirm('Delete this photo?')) return if (!confirm('Delete this photo?')) return
lightbox.close() setLightboxId(null)
await api(`/api/photos/${photoId}`, { method: 'DELETE' }) await api(`/api/photos/${photoId}`, { method: 'DELETE' })
load() load()
} }
const removeSelected = async () => {
const ids = shownSelected.map((p) => p.id)
if (!confirm(`Delete ${ids.length} selected photo${ids.length === 1 ? '' : 's'}? This cannot be undone.`))
return
try {
await api('/api/photos/delete', { method: 'POST', body: { ids } })
clear()
} catch (e) {
alert(`Delete failed: ${e.message}`)
}
load()
}
return ( return (
<> <>
<div className="page-head"> <div className="page-head">
@@ -272,19 +397,6 @@ export default function AlbumPage() {
{album.name} {album.name}
</h1> </h1>
<div className="row"> <div className="row">
<UploadZone
albumId={id}
onUploaded={load}
processing={notReady}
onRetry={(p) => api(`/api/photos/${p.id}/reprocess`, { method: 'POST' }).then(load)}
onDelete={removePhoto}
/>
<button className="btn" onClick={() => setLinksOpen(true)}>
Client links
</button>
<button className="btn" onClick={() => setXmpOpen(true)}>
XMP
</button>
<button className="btn" onClick={rename}> <button className="btn" onClick={rename}>
Rename Rename
</button> </button>
@@ -294,119 +406,95 @@ export default function AlbumPage() {
</div> </div>
</div> </div>
{strip.length > 0 && ( <UploadZone albumId={id} onUploaded={load} />
<div className="feedback-strip">
{strip.map(([key, t]) => (
<button
key={key}
className={`client-fb client-fb-btn${client === key ? ' active' : ''}`}
title={
client === key
? 'Show everyones feedback again'
: `Filter by ${t.label === 'you' ? 'your' : `${t.label}s`} feedback`
}
onClick={() => setClient((c) => (c === key ? null : key))}
>
<span className="muted">{t.label}</span>
{t.accepts > 0 && (
<span className="chip chip-accept">
{ACCEPT_GLYPH} {t.accepts}
</span>
)}
{t.rejects > 0 && (
<span className="chip chip-reject">
{REJECT_GLYPH} {t.rejects}
</span>
)}
{t.ratings > 0 && <span className="chip"> {t.ratings}</span>}
{t.tags > 0 && <span className="chip"># {t.tags}</span>}
</button>
))}
</div>
)}
{ready.length > 0 && ( {notReady.length > 0 && (
<div className="filter-bar filter-bar-admin"> <section className="panel">
{FEEDBACK_FILTERS.map((f) => ( <h2>Processing</h2>
<button <ul className="pending-list">
key={f.key} {notReady.map((p) => (
className={`filter-chip${filter === f.key ? ' active' : ''}`} <li key={p.id}>
onClick={() => setFilter(f.key)} <span className="upload-name">{p.filename}</span>
> {p.status === 'error' ? (
{f.label} {filterCounts[f.key]} <span className="row">
</button> <span className="error">{p.error || 'failed'}</span>
))} <button
<span className="btn"
className={`filter-stars${minStars > 0 ? ' active' : ''}`} onClick={() => api(`/api/photos/${p.id}/reprocess`, { method: 'POST' }).then(load)}
title="Minimum top rating in the selected scope — click a star to filter, click it again to clear" >
> Retry
<Stars value={minStars} onChange={(n) => setMinStars(n)} small /> </button>
{minStars > 0 && <span className="muted"> {minStars}</span>} <button className="btn btn-danger" onClick={() => removePhoto(p.id)}>
</span> Delete
</div> </button>
</span>
) : (
<span className="muted">{p.status}</span>
)}
</li>
))}
</ul>
</section>
)} )}
<Gallery <Gallery
photos={shown} photos={ready}
onOpen={lightbox.openAt} onOpen={(i) => setLightboxId(ready[i].id)}
selected={selected} selected={selected}
onToggleSelect={(photoId, shift) => toggle(photoId, shift, shown)} onToggleSelect={toggle}
keyboard={lightbox.index < 0 && !linksOpen && !xmpOpen}
externalIndex={lightbox.index}
actions={culling.keyActions}
overlay={(p) => { overlay={(p) => {
const s = scoped.get(p.id) const avg = avgRating(p.id)
if (!s || (s.top === null && s.tags === 0 && s.accepts === 0 && s.rejects === 0)) const tagCount = feedback[p.id]?.tags.length || 0
return null if (avg === null && tagCount === 0) return null
return ( return (
<div className="g-overlay"> <div className="g-overlay">
{s.accepts > 0 && <span>👍 {s.accepts}</span>} {avg !== null && <span> {avg.toFixed(1)}</span>}
{s.rejects > 0 && <span>👎 {s.rejects}</span>} {tagCount > 0 && <span># {tagCount}</span>}
{s.top !== null && <span>{'★'.repeat(s.top)}</span>}
{s.tags > 0 && <span># {s.tags}</span>}
</div> </div>
) )
}} }}
/> />
{ready.length === 0 && notReady.length === 0 && ( {ready.length === 0 && notReady.length === 0 && (
<p className="muted">No photos yet use Upload or drop files anywhere on the page.</p> <p className="muted">No photos yet drop some above.</p>
)}
{ready.length > 0 && shown.length === 0 && (
<p className="muted">No photos match this filter.</p>
)} )}
<SelectionBar <SelectionBar
count={shownSelected.length} count={selected.size}
total={shown.length} total={ready.length}
selectedBytes={sumBytes(shownSelected)} selectedBytes={selectedBytes}
totalBytes={sumBytes(shown)} totalBytes={totalBytes}
onSelectAll={() => selectAll(shown)} onSelectAll={selectAll}
onClear={clear} onClear={clear}
onDownload={() => onDownload={() => postDownload(`/api/albums/${id}/zip`, [...selected].join(','))}
postDownload(`/api/albums/${id}/zip`, shownSelected.map((p) => p.id).join(',')) onDownloadAll={() => postDownload(`/api/albums/${id}/zip`)}
}
onDownloadAll={() =>
postDownload(`/api/albums/${id}/zip`, filter === 'all' ? '' : shown.map((p) => p.id).join(','))
}
onDelete={removeSelected}
/> />
{lightbox.index >= 0 && ( {lightboxIndex >= 0 && (
<Lightbox <Lightbox
photos={lightbox.view} photos={ready}
index={lightbox.index} index={lightboxIndex}
onClose={lightbox.close} onClose={() => setLightboxId(null)}
onNav={lightbox.openAt} onNav={(i) => setLightboxId(ready[i].id)}
{...culling.lightboxProps}
footer={(p) => { footer={(p) => {
const groups = clientFeedback(feedback[p.id] || { ratings: [], verdicts: [], tags: [] }) const fb = feedback[p.id] || { ratings: [], tags: [] }
return ( return (
<div className="admin-footer"> <div className="admin-footer">
<div className="admin-actions"> <div className="feedback">
<span className="own-feedback"> {fb.ratings.length === 0 && fb.tags.length === 0 && (
<Thumbs value={p.owner_verdict} onChange={(v) => setOwnerVerdict(p, v)} /> <span className="muted">No client feedback yet</span>
<Stars value={p.owner_rating || 0} onChange={(r) => setOwnerRating(p, r)} /> )}
</span> {fb.ratings.map((r, i) => (
<span key={`r${i}`} className="feedback-item">
{r.share_label || 'client'}: <Stars value={r.rating} small />
</span>
))}
{fb.tags.map((t, i) => (
<span key={`t${i}`} className="chip">
{t.tag} <em className="muted">({t.share_label || 'client'})</em>
</span>
))}
</div>
<div className="row">
<label className="select-toggle"> <label className="select-toggle">
<input <input
type="checkbox" type="checkbox"
@@ -422,45 +510,13 @@ export default function AlbumPage() {
Delete Delete
</button> </button>
</div> </div>
<div className="feedback">
{groups.length === 0 && <span className="muted">No client feedback yet</span>}
{groups.map(([shareId, c]) => (
<span
key={shareId}
className={`client-fb${c.verdict ? ` client-fb-${c.verdict}` : ''}`}
>
<span className="muted">{c.label}</span>
{c.verdict && (
<span className="client-fb-verdict">
{c.verdict === 'accept' ? ACCEPT_GLYPH : REJECT_GLYPH}
</span>
)}
{c.rating && <Stars value={c.rating} small />}
{c.tags.map((tag) => (
<span key={tag} className="chip">
{tag}
</span>
))}
</span>
))}
</div>
</div> </div>
) )
}} }}
/> />
)} )}
{linksOpen && ( <SharesPanel albumId={id} />
<Modal title="Client links" onClose={() => setLinksOpen(false)}>
<SharesPanel albumId={id} />
</Modal>
)}
{xmpOpen && <XmpModal albumId={id} onClose={() => setXmpOpen(false)} />}
{culling.flash && (
<div key={culling.flash.key} className="action-flash">
{culling.flash.text}
</div>
)}
</> </>
) )
} }
+42 -108
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, 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,44 +6,18 @@ 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, { ACCEPT_GLYPH, REJECT_GLYPH } from '../components/Thumbs'
import useCulling from '../useCulling'
import useLightbox from '../useLightbox'
import useSelection from '../useSelection' import useSelection from '../useSelection'
const FILTERS = [
{ key: 'all', label: 'All' },
{ key: 'accept', label: ACCEPT_GLYPH },
{ key: 'reject', label: REJECT_GLYPH },
{ 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 [filter, setFilter] = useState('all') const [lightbox, setLightbox] = useState(-1)
const { selected, toggle, selectAll, clear, selectedBytes, totalBytes } = useSelection(
const photos = view?.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)),
@@ -71,30 +45,29 @@ export default function SharePage() {
})) }))
} }
// Optimistic write: patch local state, PUT, reload from the server on const setRating = async (photo, rating) => {
// failure to undo the patch. patchPhoto(photo.id, { my_rating: rating || null })
const saveFeedback = async (photo, patch, endpoint, body) => {
patchPhoto(photo.id, patch)
try { try {
await api(`/api/share/${token}/photos/${photo.id}/${endpoint}`, { method: 'PUT', body }) await api(`/api/share/${token}/photos/${photo.id}/rating`, {
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 })
const culling = useCulling({ const setTags = async (photo, tags) => {
visible: lightbox.view, patchPhoto(photo.id, { my_tags: tags })
lightbox, try {
setVerdict, await api(`/api/share/${token}/photos/${photo.id}/tags`, {
setRating, method: 'PUT',
toggle, body: { tags },
canSelect: !!view?.allow_download, })
}) } catch {
load()
}
}
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>
@@ -127,83 +100,49 @@ 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">
{photos.length} photo{photos.length === 1 ? '' : 's'} · tap a photo to view, rate and {view.photos.length} photo{view.photos.length === 1 ? '' : 's'} · click a photo to view,
tag · swipe to accept, to reject · <kbd>?</kbd> shows keyboard shortcuts rate and tag
</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={visible} photos={view.photos}
onOpen={lightbox.openAt} onOpen={setLightbox}
selected={view.allow_download ? selected : undefined} selected={view.allow_download ? selected : undefined}
// Shift-ranges must walk the filtered view, not the full album onToggleSelect={view.allow_download ? toggle : undefined}
// otherwise hidden photos get swept into the selection.
onToggleSelect={
view.allow_download ? (id, shift) => toggle(id, shift, visible) : undefined
}
keyboard={lightbox.index < 0}
externalIndex={lightbox.index}
actions={culling.keyActions}
overlay={(p) => overlay={(p) =>
p.my_verdict || p.my_rating || p.my_tags.length > 0 ? ( 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>{'★'.repeat(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
} }
/> />
{photos.length === 0 && ( {view.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={visibleSelected.length} count={selected.size}
total={visible.length} total={view.photos.length}
selectedBytes={sumBytes(visibleSelected)} selectedBytes={selectedBytes}
totalBytes={sumBytes(visible)} totalBytes={totalBytes}
onSelectAll={() => selectAll(visible)} onSelectAll={selectAll}
onClear={clear} onClear={clear}
onDownload={() => onDownload={() => postDownload(`/api/share/${token}/zip`, [...selected].join(','))}
postDownload(`/api/share/${token}/zip`, visibleSelected.map((p) => p.id).join(',')) onDownloadAll={() => postDownload(`/api/share/${token}/zip`)}
}
onDownloadAll={() =>
// Under a filter, "download all" means all photos shown.
postDownload(
`/api/share/${token}/zip`,
filter === 'all' ? '' : visible.map((p) => p.id).join(','),
)
}
/> />
)} )}
{lightbox.index >= 0 && ( {lightbox >= 0 && (
<Lightbox <Lightbox
photos={lightbox.view} photos={view.photos}
index={lightbox.index} index={lightbox}
onClose={lightbox.close} onClose={() => setLightbox(-1)}
onNav={lightbox.openAt} onNav={setLightbox}
{...culling.lightboxProps}
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 && (
@@ -225,11 +164,6 @@ export default function SharePage() {
)} )}
/> />
)} )}
{culling.flash && (
<div key={culling.flash.key} className="action-flash">
{culling.flash.text}
</div>
)}
</> </>
) )
} }
+58 -539
View File
@@ -10,16 +10,9 @@
--muted: #9a978f; --muted: #9a978f;
--accent: #d9a441; --accent: #d9a441;
--danger: #e5645a; --danger: #e5645a;
--accept: #7ac97a;
--radius: 8px; --radius: 8px;
} }
html {
/* Reserve the scrollbar gutter — content width must not shift when a
filter change toggles the scrollbar. */
scrollbar-gutter: stable;
}
body { body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
@@ -112,11 +105,6 @@ input {
input:focus { input:focus {
outline: 1px solid var(--accent); outline: 1px solid var(--accent);
} }
input[type='checkbox'] {
accent-color: var(--accent);
width: 16px;
height: 16px;
}
.btn { .btn {
display: inline-block; display: inline-block;
background: var(--panel-2); background: var(--panel-2);
@@ -170,9 +158,10 @@ input[type='checkbox'] {
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
text-decoration: none; text-decoration: none;
transition: transform 0.1s;
} }
.album-card:hover .album-cover img { .album-card:hover {
filter: brightness(1.08); transform: translateY(-2px);
} }
.album-cover { .album-cover {
aspect-ratio: 3 / 2; aspect-ratio: 3 / 2;
@@ -199,41 +188,41 @@ input[type='checkbox'] {
gap: 0.5rem; gap: 0.5rem;
} }
/* photo grid (Lightroom-style fixed cells) */ /* justified gallery */
.gallery { .gallery {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); flex-direction: column;
gap: 6px; gap: 6px;
margin: 1rem 0; margin: 1rem 0;
} }
.g-row {
display: flex;
gap: 6px;
}
.g-item { .g-item {
position: relative; position: relative;
aspect-ratio: 1 / 1; flex: none;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
background: var(--panel); background: var(--panel);
content-visibility: auto;
contain-intrinsic-size: 300px;
} }
.g-item img { .g-item img {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 8px; object-fit: cover;
object-fit: contain;
display: block; display: block;
} }
.g-overlay { .g-overlay {
position: absolute; position: absolute;
bottom: 6px; bottom: 0;
left: 6px; left: 0;
display: inline-flex; right: 0;
align-items: center; display: flex;
gap: 0.6rem; gap: 0.6rem;
padding: 0.2rem 0.55rem; padding: 0.35rem 0.55rem;
font-size: 0.8rem; font-size: 0.8rem;
background: rgba(0, 0, 0, 0.55); background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
border-radius: 6px;
color: #ffd97a; color: #ffd97a;
} }
@@ -268,10 +257,6 @@ input[type='checkbox'] {
outline: 3px solid var(--accent); outline: 3px solid var(--accent);
outline-offset: -3px; outline-offset: -3px;
} }
.g-item.focused {
outline: 2px solid var(--text);
outline-offset: -2px;
}
.select-bar { .select-bar {
position: fixed; position: fixed;
bottom: 1.25rem; bottom: 1.25rem;
@@ -299,8 +284,41 @@ input[type='checkbox'] {
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
} }
.select-toggle input {
accent-color: var(--accent);
width: 16px;
height: 16px;
}
/* upload */ /* upload zone */
.upload-zone {
border: 2px dashed var(--panel-2);
border-radius: var(--radius);
padding: 1.25rem;
text-align: center;
color: var(--muted);
cursor: pointer;
margin-bottom: 1rem;
}
.upload-zone.dragging {
border-color: var(--accent);
color: var(--accent);
}
.upload-zone p {
margin: 0;
}
.upload-summary {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 1rem;
cursor: default;
font-size: 0.9rem;
}
.upload-total {
width: 100%;
accent-color: var(--accent);
}
.upload-list { .upload-list {
list-style: none; list-style: none;
margin: 1rem 0 0; margin: 1rem 0 0;
@@ -331,121 +349,6 @@ progress {
accent-color: var(--accent); accent-color: var(--accent);
} }
/* fullscreen drag target */
.drop-overlay {
position: fixed;
inset: 10px;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(16, 18, 22, 0.82);
border: 3px dashed var(--accent);
border-radius: 12px;
color: var(--accent);
font-size: 1.4rem;
pointer-events: none;
}
/* activity overlay: uploads + processing, bottom right */
.activity {
position: fixed;
right: 1rem;
bottom: calc(1rem + env(safe-area-inset-bottom));
width: min(340px, calc(100vw - 2rem));
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: var(--radius);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
z-index: 60;
font-size: 0.85rem;
}
.activity-head {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
background: none;
border: none;
color: var(--text);
padding: 0.55rem 0.8rem;
font-size: 0.85rem;
cursor: pointer;
}
.activity-clear {
color: var(--muted);
padding: 0 0.2rem;
}
.activity-total {
display: block;
width: calc(100% - 1.6rem);
margin: 0 0.8rem 0.5rem;
}
.activity-body {
border-top: 1px solid var(--panel-2);
padding: 0.5rem 0.8rem 0.7rem;
max-height: 40vh;
overflow-y: auto;
}
.activity-body .upload-list {
margin: 0.25rem 0 0;
max-height: none;
}
.activity-body progress {
width: 110px;
}
.activity-section {
margin-top: 0.4rem;
font-size: 0.8rem;
}
.activity-section:first-child {
margin-top: 0;
}
/* modal */
.modal {
position: fixed;
inset: 0;
z-index: 90;
background: rgba(8, 9, 11, 0.7);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 8vh 1rem 1rem;
}
.modal-card {
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 12px;
width: min(760px, 100%);
max-height: 80vh;
overflow-y: auto;
padding: 1rem 1.25rem 1.25rem;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.modal-head h2 {
margin: 0;
}
.modal-foot {
margin-top: 1rem;
}
.xmp-source {
display: flex;
gap: 0.5rem;
padding: 0.3rem 0;
color: var(--text);
}
.btn-disabled {
opacity: 0.5;
cursor: default;
}
/* panels */ /* panels */
.panel { .panel {
background: var(--panel); background: var(--panel);
@@ -484,72 +387,14 @@ progress {
.share-info .muted { .share-info .muted {
font-size: 0.82rem; font-size: 0.82rem;
} }
.share-stats { .share-form {
display: inline-flex;
gap: 0.3rem;
}
.share-feedback {
margin-top: 0.35rem;
}
.feedback-strip {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
margin: 0 0 0.75rem;
}
.client-fb-btn {
cursor: pointer;
font-size: inherit;
color: var(--text);
}
.client-fb-btn.active {
border-color: var(--accent);
background: var(--panel-2);
}
.chip-accept {
background: color-mix(in srgb, var(--accept) 18%, var(--panel-2));
}
.chip-reject {
background: color-mix(in srgb, var(--danger) 18%, var(--panel-2));
}
.share-url {
background: none;
border: none;
padding: 0;
margin-top: 0.15rem;
color: var(--muted);
font-size: 0.82rem;
cursor: pointer;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.share-url:hover {
color: var(--accent);
}
.share-form {
display: grid;
grid-template-columns: max-content minmax(0, 320px);
gap: 0.6rem 1rem;
align-items: center; align-items: center;
margin-top: 1rem; margin-top: 1rem;
border-top: 1px solid var(--panel-2);
padding-top: 0.9rem;
} }
.share-form-title { .share-form label {
grid-column: 1 / -1;
font-size: 0.95rem;
font-weight: 600;
margin: 0;
}
.share-form .field {
display: contents;
font-size: 0.85rem;
}
.share-form-full {
grid-column: 1 / -1;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--muted); color: var(--muted);
} }
@@ -606,18 +451,6 @@ progress {
.lb-stage { .lb-stage {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
position: relative;
overflow: hidden;
/* Swipes are handled in JS; stop the browser from panning/zooming. */
touch-action: none;
}
.lb-track {
position: absolute;
inset: 0;
}
.lb-slide {
position: absolute;
inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -629,79 +462,6 @@ progress {
object-fit: contain; object-fit: contain;
cursor: default; cursor: default;
} }
.lb-flick {
z-index: 2;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
opacity: 0;
pointer-events: none;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.6);
}
/* Next photo waiting behind a vote drag, before its zoom-in. */
.lb-behind {
transform: scale(0.22);
opacity: 0;
}
/* Ghost of a just-voted photo flying off; the vote itself is already saved. */
.lb-ghost {
z-index: 3;
pointer-events: none;
}
.lb-exit-up {
animation: lb-exit-up 0.26s ease-in forwards;
}
.lb-exit-down {
animation: lb-exit-down 0.26s ease-in forwards;
}
@keyframes lb-exit-up {
from {
transform: translateY(var(--dy)) rotate(var(--rot));
}
to {
transform: translateY(-120vh) rotate(var(--rot));
opacity: 0;
}
}
@keyframes lb-exit-down {
from {
transform: translateY(var(--dy)) rotate(var(--rot));
}
to {
transform: translateY(120vh) rotate(var(--rot));
opacity: 0;
}
}
.lb-hidden {
opacity: 0;
}
/* Whole-modal fade after voting the last photo — the gallery is behind it. */
.lightbox.lb-closing {
animation: lb-fade-out 0.26s ease forwards;
pointer-events: none;
}
@keyframes lb-fade-out {
to {
opacity: 0;
}
}
/* The photo taking over after a vote zooms in organically. */
.lb-enter {
animation: lb-enter 0.26s ease-out;
}
@keyframes lb-enter {
from {
transform: scale(0.3);
opacity: 0.2;
}
to {
transform: scale(1);
opacity: 1;
}
}
.lb-nav { .lb-nav {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -729,49 +489,14 @@ progress {
.lb-footer { .lb-footer {
padding: 0.7rem 1rem 1rem; padding: 0.7rem 1rem 1rem;
} }
.client-footer { .client-footer,
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
flex-wrap: wrap;
}
/* Two rows: things you do (own vote + photo actions), things you read
(per-client feedback pills). */
.admin-footer { .admin-footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
}
.admin-actions {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1.25rem; gap: 1.25rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.client-fb {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 999px;
padding: 0.15rem 0.7rem;
}
.client-fb-verdict {
font-size: 1.1rem;
line-height: 1;
}
.client-fb-accept {
border-color: color-mix(in srgb, var(--accept) 60%, transparent);
background: color-mix(in srgb, var(--accept) 16%, var(--panel));
}
.client-fb-reject {
border-color: color-mix(in srgb, var(--danger) 60%, transparent);
background: color-mix(in srgb, var(--danger) 16%, var(--panel));
}
.feedback { .feedback {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -779,10 +504,10 @@ progress {
flex-wrap: wrap; flex-wrap: wrap;
font-size: 0.85rem; font-size: 0.85rem;
} }
.own-feedback { .feedback-item {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.3rem;
} }
/* stars */ /* stars */
@@ -809,150 +534,6 @@ 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);
}
.filter-bar-admin {
justify-content: flex-start;
margin: 0 0 0.75rem;
}
.filter-stars {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: var(--panel);
border: 1px solid var(--panel-2);
border-radius: 999px;
padding: 0.1rem 0.7rem;
font-size: 0.85rem;
}
.filter-stars.active {
border-color: var(--accent);
}
.filter-stars .star {
cursor: pointer;
}
/* 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;
@@ -983,74 +564,12 @@ kbd {
padding: 0; padding: 0;
} }
/* Touch devices: no hover — selection checkmarks must always be visible,
and the overlay nav arrows give way to swiping. */
@media (hover: none) {
.g-check {
opacity: 1;
}
.lb-nav {
display: none;
}
/* No visible arrows to keep clear of — give the photo the width. */
.lb-slide {
padding: 0 0.75rem;
}
}
@media (max-width: 700px) { @media (max-width: 700px) {
.gallery { .lb-stage {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.lb-slide {
padding: 0 0.5rem; padding: 0 0.5rem;
} }
.login-card { .login-card {
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
margin: 0 1rem; margin: 0 1rem;
} }
.share-head {
padding: 1.25rem 0.75rem 0.25rem;
}
.lb-footer {
padding: 0.5rem 0.6rem calc(0.8rem + env(safe-area-inset-bottom));
}
.client-footer {
gap: 0.8rem;
}
/* Finger-sized vote and rating targets in the client lightbox. */
.client-footer .thumb {
font-size: 1.7rem;
}
.client-footer .star {
font-size: 1.8rem;
}
.select-bar {
flex-wrap: wrap;
justify-content: center;
max-width: calc(100vw - 1.5rem);
padding: 0.5rem 0.75rem;
bottom: calc(0.75rem + env(safe-area-inset-bottom));
}
/* Share rows: info and controls stack instead of fighting for one line. */
.share-row {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
padding: 0.75rem 0;
}
.share-row .row {
flex-wrap: wrap;
row-gap: 0.5rem;
}
.share-form {
grid-template-columns: 1fr;
}
.share-form .field {
display: block;
}
.share-form .field > input {
width: 100%;
margin-top: 0.2rem;
}
} }
-86
View File
@@ -1,86 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { ACCEPT_GLYPH, REJECT_GLYPH } from './components/Thumbs'
// Shared culling interaction for a lightbox over a (possibly filtered) photo
// list: the keyboard table (P/X/U, stars, select), swipe gestures, the
// action toast, and the modal fade-out after voting the last photo.
export default function useCulling({
visible,
lightbox,
setVerdict,
setRating,
toggle,
canSelect = true,
}) {
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), [])
const [fading, setFading] = useState(false)
useEffect(() => {
if (lightbox.index < 0) setFading(false)
}, [lightbox.index])
// Keyboard votes stay on the photo (the footer controls show the result);
// only touch gestures advance, where the fly-off animation carries the
// context.
const vote = (photo, verdict) => {
setVerdict(photo, verdict)
showFlash(
verdict === 'accept' ? `${ACCEPT_GLYPH} ${photo.filename}` : `${REJECT_GLYPH} ${photo.filename}`,
)
}
const voteAndAdvance = (photo, verdict) => {
const next = visible[lightbox.index + 1]
setVerdict(photo, verdict)
showFlash(
verdict === 'accept'
? `${ACCEPT_GLYPH} ${photo.filename}`
: verdict === 'reject'
? `${REJECT_GLYPH} ${photo.filename}`
: `${photo.filename} cleared`,
)
if (next) lightbox.show(next.id)
else setFading(true)
}
const keyActions = [
{ keys: ['p'], help: ['P', 'accept'], run: (p) => vote(p, 'accept') },
{ keys: ['x'], help: ['X', 'reject'], run: (p) => vote(p, 'reject') },
{ keys: ['u'], help: ['U', 'clear accept / reject'], run: (p) => setVerdict(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) },
...(canSelect
? [{ keys: ['s'], help: ['S', 'select for download'], run: (p) => toggle(p.id) }]
: []),
]
return {
flash,
keyActions,
lightboxProps: {
actions: keyActions,
gestures: {
up: (p) => voteAndAdvance(p, 'accept'),
down: (p) => voteAndAdvance(p, 'reject'),
},
closing: fading,
onClosed: () => {
setFading(false)
lightbox.close()
},
},
}
}
-22
View File
@@ -1,22 +0,0 @@
import { useEffect } from 'react'
export const isTypingTarget = (el) =>
el.tagName === 'TEXTAREA' ||
el.isContentEditable ||
(el.tagName === 'INPUT' && !['checkbox', 'radio', 'button'].includes(el.type))
// Escape closes — unless focus is in a text field, where it only leaves it.
export default function useEscape(onClose) {
useEffect(() => {
const handler = (e) => {
if (e.key !== 'Escape') return
if (isTypingTarget(e.target)) {
e.target.blur()
return
}
onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
}
-40
View File
@@ -1,40 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
// Lightbox state tracked by photo id, not index — the list can reorder or
// shrink underneath an open lightbox. While open, the visible list is FROZEN
// to the ids present at open time (mapped to live photo objects, so votes
// still update the open photo): voting a photo out of the active filter must
// not yank it from under the viewer. Filters re-apply on close. If the open
// photo disappears entirely (deleted), the lightbox closes for good.
export default function useLightbox(photos) {
const [openId, setOpenId] = useState(null)
const frozen = useRef(null)
const byId = useMemo(() => new Map(photos.map((p) => [p.id, p])), [photos])
const view =
openId && frozen.current ? frozen.current.map((id) => byId.get(id)).filter(Boolean) : photos
const index = openId ? view.findIndex((p) => p.id === openId) : -1
useEffect(() => {
if (openId && index < 0) {
frozen.current = null
setOpenId(null)
}
}, [openId, index])
const open = (id) => {
if (!frozen.current) frozen.current = photos.map((p) => p.id)
setOpenId(id)
}
return {
index,
view,
openAt: (i) => view[i] && open(view[i].id),
show: (id) => open(id),
close: () => {
frozen.current = null
setOpenId(null)
},
}
}
+4 -33
View File
@@ -1,12 +1,10 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
// Multi-select over a photo list. Selection lives here (not in the gallery) // Multi-select over a photo list. Selection lives here (not in the gallery)
// so it survives lightbox open/close, and is pruned automatically when // so it survives lightbox open/close, and is pruned automatically when
// photos disappear from the list (deletes, polling refreshes). // photos disappear from the list (deletes, polling refreshes).
export default function useSelection(photos) { export default function useSelection(photos) {
const [selected, setSelected] = useState(() => new Set()) const [selected, setSelected] = useState(() => new Set())
// Anchor for shift-click range selection: the photo last toggled.
const lastToggled = useRef(null)
useEffect(() => { useEffect(() => {
setSelected((prev) => { setSelected((prev) => {
@@ -17,43 +15,16 @@ export default function useSelection(photos) {
}) })
}, [photos]) }, [photos])
// Shift-toggle selects the whole range from the previously toggled photo const toggle = (photoId) =>
// (both directions), so contiguous runs don't need per-photo clicks. The
// range walks `list` — the photo order the user is actually looking at —
// which callers with a filtered view must pass explicitly, so hidden
// photos are never swept into the selection. Anchor updates and range
// computation stay out of the setSelected updater: React may re-invoke
// updaters (StrictMode does), so they must be pure.
const toggle = (photoId, shift = false, list = photos) => {
const anchor = lastToggled.current
lastToggled.current = photoId
if (shift && anchor) {
const a = list.findIndex((p) => p.id === anchor)
const b = list.findIndex((p) => p.id === photoId)
if (a >= 0 && b >= 0) {
const range = list.slice(Math.min(a, b), Math.max(a, b) + 1).map((p) => p.id)
setSelected((prev) => new Set([...prev, ...range]))
return
}
}
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(photoId)) next.delete(photoId) if (next.has(photoId)) next.delete(photoId)
else next.add(photoId) else next.add(photoId)
return next return next
}) })
}
// Adds `list` (the caller's currently visible photos) to the selection — const selectAll = () => setSelected(new Set(photos.map((p) => p.id)))
// additive, so selecting all of one filtered view keeps picks from another. const clear = () => setSelected(new Set())
const selectAll = (list) =>
setSelected((prev) => new Set([...prev, ...list.map((p) => p.id)]))
const clear = () => {
// Reset the anchor too — a shift-click after Clear must start fresh, not
// extend a range from a photo selected before the wipe.
lastToggled.current = null
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)
-230
View File
@@ -1,230 +0,0 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { ACCEPT_GLYPH, REJECT_GLYPH } from './components/Thumbs'
const COMMIT_MS = 260
const CANCEL_MS = 180
const DEAD_ZONE = 12 // px of travel before the axis locks
const FLICK_MS = 250 // releases faster than this commit at the lower threshold
const H_THRESHOLD = { slow: 70, fast: 30 }
const V_THRESHOLD = { slow: 90, fast: 40 }
const V_SATURATE = 160 // px of vertical drag for full badge/preview intensity
const ZOOM_MAX = 4
// Touch engine for the lightbox: horizontal swipes navigate, vertical flicks
// dispatch gestures.up/down (when provided), two fingers pinch-zoom the
// current photo (springs back on release, never votes).
//
// Navigation and votes are dispatched SYNCHRONOUSLY on release; every
// animation afterwards is pure decoration (a ghost flying off, the track
// settling). Closing the lightbox, key presses or list changes during an
// animation therefore can't drop, duplicate or misdirect an action.
//
// Per-move motion is written straight to the DOM via refs — dragging costs
// no React renders; state changes happen only at axis lock and release.
export default function useSwipe({ photo, index, count, onNav, gestures, hasNext }) {
const [dragAxis, setDragAxis] = useState(null) // 'h' | 'v' | 'z' while a finger is down
const [spring, setSpring] = useState(null) // 'h' | 'v' | 'z': sub-threshold release gliding back
const [settleFrom, setSettleFrom] = useState(null) // px the track settles from after a nav commit
const [settleRun, setSettleRun] = useState(false)
const [exit, setExit] = useState(null) // { photo, dir, dy }: ghost flying off after a vote
const trackRef = useRef(null)
const imgRef = useRef(null)
const behindRef = useRef(null)
const badgeRef = useRef(null)
const g = useRef(null)
// FLIP: paint the track at the release offset around the NEW index first,
// force a style flush, then let it transition to center.
useLayoutEffect(() => {
if (settleFrom == null) {
setSettleRun(false)
return
}
trackRef.current?.getBoundingClientRect()
setSettleRun(true)
}, [settleFrom])
const clearManual = () => {
for (const ref of [trackRef, imgRef, behindRef, badgeRef]) {
if (ref.current) {
ref.current.style.transform = ''
ref.current.style.opacity = ''
ref.current.style.transition = ''
ref.current.style.transformOrigin = ''
}
}
}
const onTouchStart = (e) => {
// Fresh input takes over: decorative animations yield immediately.
setSpring(null)
setSettleFrom(null)
if (e.touches.length === 2 && imgRef.current) {
// Second finger at any point turns the gesture into a pinch — a pinch
// never navigates and never votes.
clearManual()
const [a, b] = e.touches
const rect = imgRef.current.getBoundingClientRect()
const cx = (a.clientX + b.clientX) / 2
const cy = (a.clientY + b.clientY) / 2
imgRef.current.style.transformOrigin = `${cx - rect.left}px ${cy - rect.top}px`
g.current = {
mode: 'pinch',
d0: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
cx,
cy,
}
setDragAxis('z')
return
}
if (e.touches.length !== 1) {
g.current = null
setDragAxis(null)
clearManual()
return
}
const t = e.touches[0]
g.current = { mode: 'swipe', x0: t.clientX, y0: t.clientY, t0: e.timeStamp, axis: null, dx: 0, dy: 0 }
}
const onTouchMove = (e) => {
const s = g.current
if (!s) return
if (s.mode === 'pinch') {
if (e.touches.length < 2 || !imgRef.current) return
const [a, b] = e.touches
const scale = Math.min(
ZOOM_MAX,
Math.max(1, Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) / s.d0),
)
const mx = (a.clientX + b.clientX) / 2 - s.cx
const my = (a.clientY + b.clientY) / 2 - s.cy
imgRef.current.style.transition = 'none'
imgRef.current.style.transform = `translate(${mx}px, ${my}px) scale(${scale})`
return
}
if (e.touches.length !== 1) return
const t = e.touches[0]
s.dx = t.clientX - s.x0
s.dy = t.clientY - s.y0
if (!s.axis) {
if (Math.hypot(s.dx, s.dy) < DEAD_ZONE) return
s.axis = Math.abs(s.dx) >= Math.abs(s.dy) || !gestures ? 'h' : 'v'
setDragAxis(s.axis)
}
if (s.axis === 'h') {
if (trackRef.current) {
trackRef.current.style.transition = 'none'
trackRef.current.style.transform = `translateX(${s.dx}px)`
}
} else {
if (imgRef.current) {
imgRef.current.style.transition = 'none'
imgRef.current.style.transform = `translateY(${s.dy}px) rotate(${s.dy / 40}deg)`
}
const p = Math.min(1, Math.abs(s.dy) / V_SATURATE)
if (behindRef.current) {
behindRef.current.style.transition = 'none'
behindRef.current.style.transform = `scale(${0.22 + 0.15 * p})`
behindRef.current.style.opacity = p
}
if (badgeRef.current) {
badgeRef.current.style.opacity = p
badgeRef.current.textContent = s.dy < 0 ? ACCEPT_GLYPH : REJECT_GLYPH
}
}
}
const onTouchEnd = (e) => {
const s = g.current
if (!s) return
if (s.mode === 'pinch') {
if (e.touches.length > 0) return
g.current = null
setDragAxis(null)
setSpring('z')
return
}
g.current = null
setDragAxis(null)
if (!s.axis) {
clearManual()
return
}
const quick = e.timeStamp - s.t0 < FLICK_MS
const past = (v, th) => Math.abs(v) > th.slow || (quick && Math.abs(v) > th.fast)
if (s.axis === 'h') {
if (s.dx < 0 && past(s.dx, H_THRESHOLD) && index < count - 1) {
onNav(index + 1)
setSettleFrom(s.dx + window.innerWidth)
} else if (s.dx > 0 && past(s.dx, H_THRESHOLD) && index > 0) {
onNav(index - 1)
setSettleFrom(s.dx - window.innerWidth)
} else {
setSpring('h')
}
} else if (past(s.dy, V_THRESHOLD)) {
const dir = s.dy < 0 ? 'up' : 'down'
gestures?.[dir]?.(photo)
clearManual()
setExit({ photo, dir, dy: s.dy })
} else {
setSpring('v')
}
}
const onTouchCancel = () => {
g.current = null
setDragAxis(null)
clearManual()
}
const trackStyle =
settleFrom != null
? settleRun
? { transform: 'translateX(0)', transition: `transform ${COMMIT_MS}ms ease-out` }
: { transform: `translateX(${settleFrom}px)`, transition: 'none' }
: spring === 'h'
? { transform: 'translateX(0)', transition: `transform ${CANCEL_MS}ms ease-out` }
: undefined
const imgStyle =
spring === 'v'
? { transform: 'translateY(0) rotate(0deg)', transition: `transform ${CANCEL_MS}ms ease-out` }
: spring === 'z'
? { transform: 'none', transition: `transform ${CANCEL_MS}ms ease-out` }
: undefined
const behindStyle =
spring === 'v'
? { transform: 'scale(0.22)', opacity: 0, transition: `all ${CANCEL_MS}ms ease-out` }
: undefined
return {
handlers: { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel },
trackRef,
imgRef,
behindRef,
badgeRef,
trackStyle,
imgStyle,
behindStyle,
showBehind: (dragAxis === 'v' || spring === 'v') && hasNext,
showBadge: dragAxis === 'v',
exit,
clearExit: () => setExit(null),
onTrackTransitionEnd: (e) => {
if (e.target !== trackRef.current) return
setSettleFrom(null)
if (spring === 'h') setSpring(null)
},
onImgTransitionEnd: (e) => {
if (e.target !== imgRef.current) return
if (spring === 'v' || spring === 'z') {
setSpring(null)
clearManual()
}
},
}
}
+27
View File
@@ -0,0 +1,27 @@
-- 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
@@ -1,13 +0,0 @@
-- 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);
-4
View File
@@ -1,4 +0,0 @@
-- The photographer's own verdict/rating. One opinion per photo, so these
-- live on the photo itself rather than in the per-link feedback tables.
alter table photos add column owner_rating int check (owner_rating between 1 and 5);
alter table photos add column owner_verdict text check (owner_verdict in ('accept', 'reject'));
+7 -1
View File
@@ -114,11 +114,17 @@ 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,
format!("{user_id}|{exp}|{email}"), session_payload(user_id, email, exp),
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));
+3 -26
View File
@@ -1,8 +1,4 @@
use axum::http::{header, HeaderValue};
use tower::ServiceBuilder;
use tower_http::services::fs::ServeFileSystemResponseBody;
use tower_http::services::{ServeDir, ServeFile}; use tower_http::services::{ServeDir, ServeFile};
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -21,30 +17,11 @@ async fn main() -> anyhow::Result<()> {
let state = AppState::new(config).await?; let state = AppState::new(config).await?;
let static_dir = state.config.static_dir.clone(); let static_dir = state.config.static_dir.clone();
let static_root = std::path::Path::new(&static_dir); let index = std::path::Path::new(&static_dir).join("index.html");
// Hashed assets cache forever — except 404s, which would outlive the next deploy. // .fallback (not .not_found_service) so SPA routes get index.html with a 200
let assets = ServiceBuilder::new() let spa = ServeDir::new(&static_dir).fallback(ServeFile::new(index));
.map_response(|mut response: axum::http::Response<ServeFileSystemResponseBody>| {
if response.status().is_success() {
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
}
response
})
.service(ServeDir::new(static_root.join("assets")));
// index.html revalidates every load (it names the asset hashes);
// .fallback (not .not_found_service) so SPA routes get it with a 200.
let spa = ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
))
.service(ServeDir::new(static_root).fallback(ServeFile::new(static_root.join("index.html"))));
let app = photos::routes::router(&state) let app = photos::routes::router(&state)
.nest_service("/assets", assets)
.fallback_service(spa) .fallback_service(spa)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state.clone()); .with_state(state.clone());
+1
View File
@@ -4,6 +4,7 @@ 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;
+3 -78
View File
@@ -1,5 +1,5 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::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,81 +47,6 @@ 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",
}
}
}
/// The one place feedback-aggregation policy lives (UI filters and XMP
/// export must agree): the rating is the MAX across everyone, any accept
/// wins over rejects, and the owner's feedback counts like a client's.
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct FeedbackAggregate {
pub top_rating: Option<i32>,
pub accepts: i32,
pub rejects: i32,
}
impl FeedbackAggregate {
pub fn add_rating(&mut self, rating: i32) {
self.top_rating = Some(self.top_rating.map_or(rating, |top| top.max(rating)));
}
pub fn add_verdict(&mut self, verdict: &str) {
if verdict == "accept" {
self.accepts += 1;
} else {
self.rejects += 1;
}
}
pub fn add_owner(&mut self, rating: Option<i32>, verdict: Option<&str>) {
if let Some(rating) = rating {
self.add_rating(rating);
}
if let Some(verdict) = verdict {
self.add_verdict(verdict);
}
}
pub fn is_empty(&self) -> bool {
self.top_rating.is_none() && self.accepts == 0 && self.rejects == 0
}
pub fn merge(&mut self, other: &FeedbackAggregate) {
if let Some(rating) = other.top_rating {
self.add_rating(rating);
}
self.accepts += other.accepts;
self.rejects += other.rejects;
}
/// XMP color label: any accept wins, otherwise any reject.
pub fn label(&self) -> Option<&'static str> {
if self.accepts > 0 {
Some("Green")
} else if self.rejects > 0 {
Some("Red")
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobKind { pub enum JobKind {
ProcessPhoto, ProcessPhoto,
@@ -171,6 +96,8 @@ 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>,
@@ -190,8 +117,6 @@ pub struct Photo {
pub height: Option<i32>, pub height: Option<i32>,
pub taken_at: Option<DateTime<Utc>>, pub taken_at: Option<DateTime<Utc>>,
pub processed_at: Option<DateTime<Utc>>, pub processed_at: Option<DateTime<Utc>>,
pub owner_rating: Option<i32>,
pub owner_verdict: Option<String>,
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub sha256: Option<String>, pub sha256: Option<String>,
#[serde(skip_serializing)] #[serde(skip_serializing)]
+46
View File
@@ -0,0 +1,46 @@
//! 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)
}
+72 -143
View File
@@ -6,8 +6,10 @@ 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, FeedbackAggregate, 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)]
@@ -23,6 +25,7 @@ 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,
@@ -35,9 +38,11 @@ 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))
@@ -52,104 +57,55 @@ 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 = let album: Album = sqlx::query_as(
sqlx::query_as("insert into albums (name, description) values ($1, $2) returning *") "insert into albums (owner_id, name, description) values ($1, $2, $3) returning *",
.bind(name) )
.bind(body.description.trim()) .bind(user.id)
.fetch_one(&state.db) .bind(name)
.await?; .bind(body.description.trim())
.fetch_one(&state.db)
.await?;
Ok(Json(album)) Ok(Json(album))
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct ShareRating { pub struct ShareRating {
pub share_id: Uuid,
pub share_label: String, pub share_label: String,
pub rating: i32, pub rating: i32,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct ShareTag { pub struct ShareTag {
pub share_id: Uuid,
pub share_label: String, pub share_label: String,
pub tag: String, pub tag: String,
} }
#[derive(Serialize)]
pub struct ShareVerdict {
pub share_id: Uuid,
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>,
} }
pub(super) const RATING_ROWS: &str = "select r.photo_id, s.id, s.label, r.rating
from ratings r join shares s on s.id = r.share_id
where s.album_id = $1 and ($2::uuid[] is null or r.share_id = any($2))";
pub(super) const VERDICT_ROWS: &str = "select v.photo_id, s.id, s.label, v.verdict
from verdicts v join shares s on s.id = v.share_id
where s.album_id = $1 and ($2::uuid[] is null or v.share_id = any($2))";
pub(super) const TAG_ROWS: &str = "select t.photo_id, s.id, s.label, t.tag
from tags t join shares s on s.id = t.share_id
where s.album_id = $1 and ($2::uuid[] is null or t.share_id = any($2))
order by t.created_at";
/// One feedback query: (photo_id, share id, share label, value) rows for an
/// album, optionally narrowed to a single share ($2).
pub(super) async fn feedback_rows<T>(
db: &sqlx::PgPool,
sql: &str,
album_id: Uuid,
shares: Option<&[Uuid]>,
) -> Result<Vec<(Uuid, Uuid, String, T)>, sqlx::Error>
where
(Uuid, Uuid, String, T): for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> + Send + Unpin,
{
sqlx::query_as(sql)
.bind(album_id)
.bind(shares)
.fetch_all(db)
.await
}
fn fold_feedback<T>(
feedback: &mut HashMap<Uuid, PhotoFeedback>,
rows: Vec<(Uuid, Uuid, String, T)>,
push: impl Fn(&mut PhotoFeedback, Uuid, String, T),
) {
for (photo_id, share_id, share_label, value) in rows {
push(feedback.entry(photo_id).or_default(), share_id, share_label, value);
}
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct AlbumDetail { pub struct AlbumDetail {
pub album: Album, pub album: Album,
pub photos: Vec<Photo>, pub photos: Vec<Photo>,
pub feedback: HashMap<Uuid, PhotoFeedback>, pub feedback: HashMap<Uuid, PhotoFeedback>,
pub aggregates: HashMap<Uuid, FeedbackAggregate>,
} }
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: Album = sqlx::query_as("select * from albums where id = $1") let album = owned::album(&state, album_id, user.id).await?;
.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",
@@ -158,85 +114,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, value)
// queries — run them concurrently and fold with one shared shape.
let (ratings, verdicts, tags) = tokio::try_join!(
feedback_rows::<i32>(&state.db, RATING_ROWS, album_id, None),
feedback_rows::<String>(&state.db, VERDICT_ROWS, album_id, None),
feedback_rows::<String>(&state.db, TAG_ROWS, album_id, None),
)?;
let mut aggregates: HashMap<Uuid, FeedbackAggregate> = HashMap::new();
for (photo_id, _, _, rating) in &ratings {
aggregates.entry(*photo_id).or_default().add_rating(*rating);
}
for (photo_id, _, _, verdict) in &verdicts {
aggregates.entry(*photo_id).or_default().add_verdict(verdict);
}
for photo in &photos {
if photo.owner_rating.is_some() || photo.owner_verdict.is_some() {
aggregates
.entry(photo.id)
.or_default()
.add_owner(photo.owner_rating, photo.owner_verdict.as_deref());
}
}
let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new(); let mut feedback: HashMap<Uuid, PhotoFeedback> = HashMap::new();
fold_feedback(&mut feedback, ratings, |f, share_id, share_label, rating| { let ratings: Vec<(Uuid, String, i32)> = sqlx::query_as(
f.ratings.push(ShareRating { "select r.photo_id, s.label, r.rating
share_id, 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_id, share_label, verdict| { .await?;
f.verdicts.push(ShareVerdict { for (photo_id, share_label, rating) in ratings {
share_id, feedback
share_label, .entry(photo_id)
verdict, .or_default()
}) .ratings
}); .push(ShareRating {
fold_feedback(&mut feedback, tags, |f, share_id, share_label, tag| { share_label,
f.tags.push(ShareTag { rating,
share_id, });
share_label, }
tag, 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,
photos, photos,
feedback, feedback,
aggregates,
})) }))
} }
#[derive(Serialize, sqlx::FromRow)]
pub struct PendingPhoto {
pub id: Uuid,
pub filename: String,
pub status: String,
pub error: Option<String>,
}
pub async fn pending(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let pending: Vec<PendingPhoto> = sqlx::query_as(
"select id, filename, status, error from photos
where album_id = $1 and status != $2
order by created_at",
)
.bind(album_id)
.bind(PhotoStatus::Ready.as_str())
.fetch_all(&state.db)
.await?;
Ok(Json(serde_json::json!({ "pending": pending })))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateAlbum { pub struct UpdateAlbum {
name: Option<String>, name: Option<String>,
@@ -245,6 +165,7 @@ 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>> {
@@ -256,12 +177,13 @@ 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 where id = $1 and owner_id = $4
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))
@@ -269,26 +191,33 @@ 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,)> = sqlx::query_as("select id from albums where id = $1 for update") let locked: Option<(Uuid,)> =
.bind(album_id) sqlx::query_as("select id from albums where id = $1 and owner_id = $2 for update")
.fetch_optional(&mut *tx) .bind(album_id)
.await?; .bind(user.id)
.fetch_optional(&mut *tx)
.await?;
if locked.is_none() { if locked.is_none() {
return Err(ApiError::not_found()); return Err(ApiError::not_found());
} }
// Delete photos through the shared path so the S3 cleanup convention // Delete photos and enqueue their S3 cleanup atomically, in one statement.
// lives in one place; the album lock above keeps this set complete. sqlx::query(
let photo_ids: Vec<Uuid> = sqlx::query_scalar("select id from photos where album_id = $1") "with deleted as (delete from photos where album_id = $1 returning id)
.bind(album_id) insert into jobs (kind, payload)
.fetch_all(&mut *tx) select $2, jsonb_build_object('prefix', 'photos/' || id || '/')
.await?; from deleted",
super::photos::delete_with_cleanup(&mut tx, &photo_ids).await?; )
.bind(album_id)
.bind(JobKind::DeleteS3Prefix.as_str())
.execute(&mut *tx)
.await?;
sqlx::query("delete from albums where id = $1") sqlx::query("delete from albums where id = $1")
.bind(album_id) .bind(album_id)
.execute(&mut *tx) .execute(&mut *tx)
+2 -40
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, Verdict}; use crate::models::{PhotoStatus, Share};
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,7 +137,6 @@ 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)]
@@ -186,10 +185,9 @@ 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, v.verdict as my_verdict "select p.id, p.filename, p.size_bytes, p.width, p.height, p.taken_at, p.processed_at, r.rating as my_rating
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",
) )
@@ -354,42 +352,6 @@ 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>,
+13 -2
View File
@@ -28,8 +28,19 @@ async fn authorize_photo(
.await?; .await?;
let photo = photo.ok_or_else(ApiError::not_found)?; let photo = photo.ok_or_else(ApiError::not_found)?;
if user_from_jar(state, jar).is_some() { // Photographers may only reach their OWN photos (scoped by album owner);
return Ok(photo); // otherwise fall through to share-cookie access.
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.
+1 -14
View File
@@ -3,7 +3,6 @@ pub mod client;
pub mod images; pub mod images;
pub mod photos; pub mod photos;
pub mod shares; pub mod shares;
pub mod xmp;
pub mod zip; pub mod zip;
use axum::extract::{DefaultBodyLimit, Request, State}; use axum::extract::{DefaultBodyLimit, Request, State};
@@ -78,18 +77,10 @@ pub fn router(state: &AppState) -> Router<AppState> {
get(shares::list).post(shares::create), get(shares::list).post(shares::create),
) )
.route("/api/albums/{id}/zip", post(zip::album_zip)) .route("/api/albums/{id}/zip", post(zip::album_zip))
.route("/api/albums/{id}/xmp", get(xmp::album_xmp))
.route("/api/albums/{id}/pending", get(albums::pending))
.route("/api/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash)) .route("/api/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash))
.route("/api/photos/delete", post(photos::delete_many))
.route("/api/photos/{id}", delete(photos::delete)) .route("/api/photos/{id}", delete(photos::delete))
.route("/api/photos/{id}/rating", put(photos::set_owner_rating))
.route("/api/photos/{id}/verdict", put(photos::set_owner_verdict))
.route("/api/photos/{id}/reprocess", post(photos::reprocess)) .route("/api/photos/{id}/reprocess", post(photos::reprocess))
.route( .route("/api/shares/{id}", delete(shares::delete))
"/api/shares/{id}",
delete(shares::delete).patch(shares::update),
)
.route("/api/shares/{id}/reset-lock", post(shares::reset_lock)) .route("/api/shares/{id}/reset-lock", post(shares::reset_lock))
.route_layer(middleware::from_fn_with_state( .route_layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
@@ -109,10 +100,6 @@ 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),
+43 -142
View File
@@ -9,9 +9,11 @@ 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::{FeedbackAggregate, JobKind, Photo, PhotoStatus, Verdict}; use crate::models::{JobKind, Photo, PhotoStatus};
use crate::owned;
use crate::s3; use crate::s3;
use crate::state::AppState; use crate::state::AppState;
@@ -39,20 +41,34 @@ 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>> {
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1") owned::album(&state, album_id, user.id).await?;
.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
@@ -97,7 +113,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(existing)); return Ok(Json(heal_if_errored(&state, existing).await?));
} }
// 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
@@ -156,7 +172,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(winner)); return Ok(Json(heal_if_errored(&state, winner).await?));
} }
} }
return Err(e.into()); return Err(e.into());
@@ -178,8 +194,10 @@ 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)
@@ -189,153 +207,36 @@ pub async fn by_hash(
photo.map(Json).ok_or_else(ApiError::not_found) photo.map(Json).ok_or_else(ApiError::not_found)
} }
/// The one deletion path: delete the given photos and enqueue their S3
/// cleanup jobs in the same transaction. Prefixes come from s3::photo_prefix
/// so the key layout has a single source of truth. Returns how many photos
/// were actually deleted.
pub(super) async fn delete_with_cleanup(
tx: &mut sqlx::PgConnection,
ids: &[Uuid],
) -> Result<u64, sqlx::Error> {
let deleted: Vec<Uuid> = sqlx::query_scalar("delete from photos where id = any($1) returning id")
.bind(ids)
.fetch_all(&mut *tx)
.await?;
if deleted.is_empty() {
return Ok(0);
}
let prefixes: Vec<String> = deleted.iter().map(|id| s3::photo_prefix(*id)).collect();
sqlx::query(
"insert into jobs (kind, payload)
select $1, jsonb_build_object('prefix', p)
from unnest($2::text[]) as p",
)
.bind(JobKind::DeleteS3Prefix.as_str())
.bind(&prefixes)
.execute(&mut *tx)
.await?;
Ok(deleted.len() as u64)
}
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?;
if delete_with_cleanup(&mut tx, &[photo_id]).await? == 0 { let deleted = sqlx::query("delete from photos where id = $1")
.bind(photo_id)
.execute(&mut *tx)
.await?;
if deleted.rows_affected() == 0 {
return Err(ApiError::not_found()); return Err(ApiError::not_found());
} }
jobs::enqueue(
&mut *tx,
JobKind::DeleteS3Prefix,
serde_json::json!({ "prefix": s3::photo_prefix(photo_id) }),
)
.await?;
tx.commit().await?; tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true }))) Ok(Json(serde_json::json!({ "ok": true })))
} }
#[derive(Deserialize)]
pub struct DeleteManyBody {
ids: Vec<Uuid>,
}
pub async fn delete_many(
State(state): State<AppState>,
Json(body): Json<DeleteManyBody>,
) -> ApiResult<Json<serde_json::Value>> {
if body.ids.is_empty() {
return Err(ApiError::bad_request("ids must not be empty"));
}
let mut tx = state.db.begin().await?;
let deleted = delete_with_cleanup(&mut tx, &body.ids).await?;
// Consistent with the single-photo route: deleting nothing is an error,
// not a silent success (e.g. a stale tab re-deleting already-gone photos).
if deleted == 0 {
return Err(ApiError::not_found());
}
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
}
async fn feedback_aggregate(
db: &sqlx::PgPool,
photo_id: Uuid,
) -> Result<FeedbackAggregate, sqlx::Error> {
let (top, accepts, rejects, owner_rating, owner_verdict): (
Option<i32>,
i64,
i64,
Option<i32>,
Option<String>,
) = sqlx::query_as(
"select
(select max(r.rating) from ratings r where r.photo_id = p.id),
(select count(*) from verdicts v where v.photo_id = p.id and v.verdict = 'accept'),
(select count(*) from verdicts v where v.photo_id = p.id and v.verdict = 'reject'),
p.owner_rating, p.owner_verdict
from photos p where p.id = $1",
)
.bind(photo_id)
.fetch_one(db)
.await?;
let mut agg = FeedbackAggregate {
top_rating: None,
accepts: accepts as i32,
rejects: rejects as i32,
};
if let Some(top) = top {
agg.add_rating(top);
}
agg.add_owner(owner_rating, owner_verdict.as_deref());
Ok(agg)
}
#[derive(Deserialize)]
pub struct OwnerRatingBody {
rating: i32,
}
pub async fn set_owner_rating(
State(state): State<AppState>,
Path(photo_id): Path<Uuid>,
Json(body): Json<OwnerRatingBody>,
) -> ApiResult<Json<serde_json::Value>> {
if !(0..=5).contains(&body.rating) {
return Err(ApiError::bad_request("rating must be between 0 and 5"));
}
let updated = sqlx::query("update photos set owner_rating = $2 where id = $1")
.bind(photo_id)
.bind((body.rating > 0).then_some(body.rating))
.execute(&state.db)
.await?;
if updated.rows_affected() == 0 {
return Err(ApiError::not_found());
}
let aggregate = feedback_aggregate(&state.db, photo_id).await?;
Ok(Json(serde_json::json!({ "ok": true, "aggregate": aggregate })))
}
#[derive(Deserialize)]
pub struct OwnerVerdictBody {
verdict: Option<Verdict>,
}
pub async fn set_owner_verdict(
State(state): State<AppState>,
Path(photo_id): Path<Uuid>,
Json(body): Json<OwnerVerdictBody>,
) -> ApiResult<Json<serde_json::Value>> {
let updated = sqlx::query("update photos set owner_verdict = $2 where id = $1")
.bind(photo_id)
.bind(body.verdict.map(Verdict::as_str))
.execute(&state.db)
.await?;
if updated.rows_affected() == 0 {
return Err(ApiError::not_found());
}
let aggregate = feedback_aggregate(&state.db, photo_id).await?;
Ok(Json(serde_json::json!({ "ok": true, "aggregate": aggregate })))
}
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)
+19 -71
View File
@@ -7,8 +7,9 @@ use chrono::{DateTime, Utc};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::random_token; use crate::auth::{random_token, AuthUser};
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)]
@@ -23,8 +24,6 @@ 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 {
@@ -40,30 +39,22 @@ 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,
}) })
} }
// Aggregates run as laterals so each feedback table is scanned once per const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_download,
// 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,
rc.rating_count, tc.tag_count, vc.accept_count, vc.reject_count (select count(*) from ratings r where r.share_id = s.id) as rating_count,
from shares s (select count(*) from tags t where t.share_id = s.id) as tag_count";
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!(
"{SHARE_SELECT} where s.album_id = $1 order by s.created_at desc" "select {SHARE_COLUMNS} from shares s 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)
@@ -87,16 +78,11 @@ 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>> {
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1") owned::album(&state, album_id, user.id).await?;
.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() => {
@@ -131,53 +117,11 @@ pub async fn create(
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1")) let row: ShareAdminRow =
.bind(share_id) sqlx::query_as(&format!("select {SHARE_COLUMNS} from shares s where s.id = $1"))
.fetch_one(&state.db) .bind(share_id)
.await?; .fetch_one(&state.db)
Ok(Json(share_json(&state, &row))) .await?;
}
/// Distinguishes an absent JSON field (keep current value) from an explicit
/// null (clear the expiry): absent → None, present → Some(inner).
fn double_option<'de, D>(de: D) -> Result<Option<Option<DateTime<Utc>>>, D::Error>
where
D: serde::Deserializer<'de>,
{
serde::Deserialize::deserialize(de).map(Some)
}
#[derive(Deserialize)]
pub struct UpdateShare {
allow_download: Option<bool>,
#[serde(default, deserialize_with = "double_option")]
expires_at: Option<Option<DateTime<Utc>>>,
}
pub async fn update(
State(state): State<AppState>,
Path(share_id): Path<Uuid>,
Json(body): Json<UpdateShare>,
) -> ApiResult<Json<serde_json::Value>> {
let updated = sqlx::query(
"update shares set
allow_download = coalesce($2, allow_download),
expires_at = case when $3 then $4 else expires_at end
where id = $1",
)
.bind(share_id)
.bind(body.allow_download)
.bind(body.expires_at.is_some())
.bind(body.expires_at.flatten())
.execute(&state.db)
.await?;
if updated.rows_affected() == 0 {
return Err(ApiError::not_found());
}
let row: ShareAdminRow = sqlx::query_as(&format!("{SHARE_SELECT} where s.id = $1"))
.bind(share_id)
.fetch_one(&state.db)
.await?;
Ok(Json(share_json(&state, &row))) Ok(Json(share_json(&state, &row)))
} }
@@ -185,8 +129,10 @@ pub async fn update(
/// 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)
@@ -200,8 +146,10 @@ 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)
-252
View File
@@ -1,252 +0,0 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{FeedbackAggregate, PhotoStatus};
use crate::state::AppState;
#[derive(Deserialize)]
pub struct XmpQuery {
/// Comma-separated share ids; absent = all shares.
shares: Option<String>,
/// Include the photographer's own feedback; defaults to true only when
/// no share filter is given.
own: Option<bool>,
}
#[derive(Default)]
struct Sidecar {
agg: FeedbackAggregate,
tags: BTreeSet<String>,
}
/// ZIP of XMP sidecars, one per photo that has feedback from the selected
/// sources — drop them next to the RAWs and Capture One / Lightroom pick up
/// rating, color label and keywords on sync.
pub async fn album_xmp(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
Query(query): Query<XmpQuery>,
) -> ApiResult<Response> {
let album: Option<(String,)> = sqlx::query_as("select name from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
let Some((album_name,)) = album else {
return Err(ApiError::not_found());
};
let share_filter: Option<Vec<Uuid>> = match &query.shares {
None => None,
Some(raw) => Some(
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
Uuid::parse_str(s)
.map_err(|_| ApiError::bad_request(format!("invalid share id: {s}")))
})
.collect::<Result<Vec<_>, _>>()
.map(|mut ids| {
ids.sort();
ids.dedup();
ids
})?,
),
};
let include_own = query.own.unwrap_or(share_filter.is_none());
if let Some(ids) = &share_filter {
if ids.is_empty() && !include_own {
return Err(ApiError::bad_request("select at least one feedback source"));
}
if !ids.is_empty() {
let (found,): (i64,) =
sqlx::query_as("select count(*) from shares where album_id = $1 and id = any($2)")
.bind(album_id)
.bind(ids)
.fetch_one(&state.db)
.await?;
if found as usize != ids.len() {
return Err(ApiError::not_found());
}
}
}
let photos: Vec<(Uuid, String, Option<i32>, Option<String>)> = sqlx::query_as(
"select id, filename, owner_rating, owner_verdict
from photos where album_id = $1 and status = $2 order by filename",
)
.bind(album_id)
.bind(PhotoStatus::Ready.as_str())
.fetch_all(&state.db)
.await?;
let shares = share_filter.as_deref();
let (ratings, verdicts, tags) = tokio::try_join!(
super::albums::feedback_rows::<i32>(&state.db, super::albums::RATING_ROWS, album_id, shares),
super::albums::feedback_rows::<String>(&state.db, super::albums::VERDICT_ROWS, album_id, shares),
super::albums::feedback_rows::<String>(&state.db, super::albums::TAG_ROWS, album_id, shares),
)?;
let mut feedback: HashMap<Uuid, Sidecar> = HashMap::new();
for (photo_id, _, _, rating) in ratings {
feedback.entry(photo_id).or_default().agg.add_rating(rating);
}
for (photo_id, _, _, verdict) in verdicts {
feedback.entry(photo_id).or_default().agg.add_verdict(&verdict);
}
for (photo_id, _, _, tag) in tags {
feedback.entry(photo_id).or_default().tags.insert(tag);
}
// One sidecar per BASENAME: a RAW+JPEG pair shares its .xmp, so their
// feedback is merged — a disambiguated "name-2.xmp" would never be
// matched by any editor and its feedback silently lost.
let mut sidecars: BTreeMap<String, Sidecar> = BTreeMap::new();
for (photo_id, filename, owner_rating, owner_verdict) in &photos {
let mut f = feedback.remove(photo_id).unwrap_or_default();
if include_own {
f.agg.add_owner(*owner_rating, owner_verdict.as_deref());
}
if f.agg.is_empty() && f.tags.is_empty() {
continue;
}
let base = filename.rsplit_once('.').map(|(b, _)| b).unwrap_or(filename);
let entry = sidecars.entry(format!("{base}.xmp")).or_default();
entry.agg.merge(&f.agg);
entry.tags.extend(f.tags);
}
let entries: Vec<(String, Vec<u8>)> = sidecars
.into_iter()
.map(|(name, f)| {
(
name,
xmp_document(
f.agg.top_rating.map(|r| r.clamp(1, 5)),
f.agg.label(),
&f.tags,
)
.into_bytes(),
)
})
.collect();
let zip = build_stored_zip(&entries);
let safe_name: String = album_name
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
.collect();
Ok((
[
(header::CONTENT_TYPE, "application/zip".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{safe_name}-xmp.zip\""),
),
],
zip,
)
.into_response())
}
fn escape_xml(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn xmp_document(rating: Option<i32>, label: Option<&str>, tags: &BTreeSet<String>) -> String {
let mut attrs = String::new();
if let Some(rating) = rating {
attrs.push_str(&format!(" xmp:Rating=\"{rating}\""));
}
if let Some(label) = label {
attrs.push_str(&format!(" xmp:Label=\"{label}\""));
}
let subject = if tags.is_empty() {
String::new()
} else {
let items: String = tags
.iter()
.map(|t| format!("<rdf:li>{}</rdf:li>", escape_xml(t)))
.collect();
format!("<dc:subject><rdf:Bag>{items}</rdf:Bag></dc:subject>")
};
format!(
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n \
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n \
<rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" \
xmlns:dc=\"http://purl.org/dc/elements/1.1/\"{attrs}>{subject}</rdf:Description>\n \
</rdf:RDF>\n\
</x:xmpmeta>\n\
<?xpacket end=\"w\"?>"
)
}
/// Minimal stored (uncompressed) in-memory ZIP — the entries are a handful
/// of small text files, the streaming writer in zip.rs is for S3 payloads.
fn build_stored_zip(entries: &[(String, Vec<u8>)]) -> Vec<u8> {
let (dos_time, dos_date) = super::zip::dos_datetime(Utc::now());
let mut out: Vec<u8> = Vec::new();
let mut central: Vec<u8> = Vec::new();
for (name, data) in entries {
let offset = out.len() as u32;
let crc = crc32fast::hash(data);
let name_bytes = name.as_bytes();
let size = data.len() as u32;
out.extend_from_slice(&0x04034b50u32.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&0x0800u16.to_le_bytes()); // UTF-8 names
out.extend_from_slice(&0u16.to_le_bytes()); // stored
out.extend_from_slice(&dos_time.to_le_bytes());
out.extend_from_slice(&dos_date.to_le_bytes());
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&size.to_le_bytes());
out.extend_from_slice(&size.to_le_bytes());
out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name_bytes);
out.extend_from_slice(data);
central.extend_from_slice(&0x02014b50u32.to_le_bytes());
central.extend_from_slice(&20u16.to_le_bytes());
central.extend_from_slice(&20u16.to_le_bytes());
central.extend_from_slice(&0x0800u16.to_le_bytes());
central.extend_from_slice(&0u16.to_le_bytes());
central.extend_from_slice(&dos_time.to_le_bytes());
central.extend_from_slice(&dos_date.to_le_bytes());
central.extend_from_slice(&crc.to_le_bytes());
central.extend_from_slice(&size.to_le_bytes());
central.extend_from_slice(&size.to_le_bytes());
central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
central.extend_from_slice(&0u16.to_le_bytes());
central.extend_from_slice(&0u16.to_le_bytes());
central.extend_from_slice(&0u16.to_le_bytes());
central.extend_from_slice(&0u16.to_le_bytes());
central.extend_from_slice(&0u32.to_le_bytes());
central.extend_from_slice(&offset.to_le_bytes());
central.extend_from_slice(name_bytes);
}
let cd_offset = out.len() as u32;
out.extend_from_slice(&central);
let count = entries.len() as u16;
out.extend_from_slice(&0x06054b50u32.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
out.extend_from_slice(&cd_offset.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
+47 -60
View File
@@ -81,12 +81,13 @@ 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 name = album_name(&state, album_id).await?; let album = crate::owned::album(&state, album_id, user.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, &name) stream_zip(state, photos, &album.name)
} }
pub async fn share_zip( pub async fn share_zip(
@@ -122,7 +123,7 @@ fn unique_entry_name(used: &mut HashSet<String>, filename: &str) -> String {
/// MS-DOS timestamp (2-second resolution, no timezone; years 1980+ only — /// MS-DOS timestamp (2-second resolution, no timezone; years 1980+ only —
/// callers clamp earlier dates). /// callers clamp earlier dates).
pub(super) fn dos_datetime(t: DateTime<Utc>) -> (u16, u16) { fn dos_datetime(t: DateTime<Utc>) -> (u16, u16) {
let time = ((t.hour() as u16) << 11) | ((t.minute() as u16) << 5) | (t.second() as u16 / 2); let time = ((t.hour() as u16) << 11) | ((t.minute() as u16) << 5) | (t.second() as u16 / 2);
let date = (((t.year() - 1980) as u16) << 9) | ((t.month() as u16) << 5) | (t.day() as u16); let date = (((t.year() - 1980) as u16) << 9) | ((t.month() as u16) << 5) | (t.day() as u16);
(time, date) (time, date)
@@ -136,9 +137,6 @@ 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 {
@@ -179,7 +177,6 @@ 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;
@@ -281,23 +278,26 @@ 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())
} }
enum Fetched { struct Fetched {
/// CRC already known — the body streams straight into the response. file: tokio::fs::File,
Direct(Box<aws_sdk_s3::operation::get_object::GetObjectOutput>), crc: u32,
/// Pre-hash photo: spooled to a temp file to compute the CRC first. /// sha256 hex — always computed so legacy photos (crc-only) get their
Spooled(tokio::fs::File, u32), /// content hash backfilled, restoring upload dedup for them.
sha256: String,
} }
/// Start fetching an original. With a known CRC this only opens the S3 /// Fetch an original and spool it to an anonymous temp file, computing crc32
/// response (the body is consumed later, straight into the zip stream); /// and sha256 and verifying the byte count the zip plan promised. Spooling
/// otherwise the object is spooled to an anonymous temp file to compute the /// (rather than streaming the live S3 body straight through) is deliberate:
/// CRC, verifying the byte count the zip plan promised. /// the prefetched entry is fully read immediately, so no S3 connection sits
/// 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,12 +308,10 @@ 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 hasher = crc32fast::Hasher::new(); let mut crc = 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 {
@@ -321,7 +319,8 @@ fn fetch_entry(
if n == 0 { if n == 0 {
break; break;
} }
hasher.update(&buf[..n]); crc.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;
} }
@@ -331,7 +330,11 @@ 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::Spooled(file, hasher.finalize())) Ok(Fetched {
file,
crc: crc.finalize(),
sha256: hex::encode(sha.finalize()),
})
}) })
} }
@@ -344,30 +347,23 @@ 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: start fetching the next object while streaming the current one. // Prefetch: spool the next object to a temp file while streaming the
// 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, entry.crc.is_some()), None => fetch_entry(state, entry.s3_key.clone(), entry.size),
}; };
if let Some(next) = plan.entries.get(i + 1) { if let Some(next) = plan.entries.get(i + 1) {
pending = Some(fetch_entry( pending = Some(fetch_entry(state, next.s3_key.clone(), next.size));
state,
next.s3_key.clone(),
next.size,
next.crc.is_some(),
));
} }
let fetched = current let mut 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))?;
let crc = match &fetched { crcs.push(fetched.crc);
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());
@@ -376,35 +372,26 @@ 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(&crc.to_le_bytes()); lfh.extend_from_slice(&fetched.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?;
match fetched { tokio::io::copy(&mut fetched.file, &mut out).await?;
Fetched::Direct(object) => {
let mut reader = object.body.into_async_read(); // Self-heal legacy photos: backfill both hashes so future zips and
let copied = tokio::io::copy(&mut reader, &mut out).await?; // upload dedup both work for them.
anyhow::ensure!( let _ = sqlx::query(
copied == entry.size, "update photos set crc32 = coalesce(crc32, $2), sha256 = coalesce(sha256, $3)
"{} is {copied} bytes in s3 but {} in the database", where id = $1",
entry.s3_key, )
entry.size .bind(entry.photo_id)
); .bind(i64::from(fetched.crc))
} .bind(&fetched.sha256)
Fetched::Spooled(mut file, crc) => { .execute(&state.db)
tokio::io::copy(&mut file, &mut out).await?; .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
@@ -0,0 +1,254 @@
//! 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");
}
}