4 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
nils 6259ca84d8 Content dedup on both sides, stored checksums, spool-free zips, upload stats
ci / docker (push) Successful in 12s
- sha256+crc32 hashed during upload streaming; unique index per album
- duplicate content returns the existing photo (race-safe via 23505)
- client hashes locally (WebCrypto) and skips the transfer entirely for
  content the album already has
- zip downloads stream S3->response directly using the stored crc32;
  pre-hash photos spool once and self-heal (crc via zip, sha via reprocess)
- upload UI: overall progress bar, bytes, live speed, ETA
2026-07-17 14:41:07 +02:00
nils a6819809a7 Gallery: true justified layout (row-height scaling, no crop, no spacer) 2026-07-17 14:12:57 +02:00
21 changed files with 813 additions and 148 deletions
Generated
+2
View File
@@ -2562,6 +2562,7 @@ dependencies = [
"cookie",
"crc32fast",
"futures",
"hex",
"image",
"rand 0.8.7",
"reqwest",
@@ -2573,6 +2574,7 @@ dependencies = [
"time",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
+2
View File
@@ -25,6 +25,7 @@ axum = { version = "0.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie", "cookie-signed"] }
chrono = { version = "0.4", features = ["serde"] }
futures = "0.3"
hex = "0.4"
image = "0.25"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
@@ -44,6 +45,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
[dev-dependencies]
cookie = { version = "0.18", features = ["signed"] }
tower = { version = "0.5", features = ["util"] }
[profile.release]
lto = "thin"
+12 -4
View File
@@ -30,10 +30,13 @@ collect ratings and tags, and let clients download originals.
`photos/<photo_id>/preview.jpg`, `photos/<photo_id>/thumb.jpg`. The bucket
stays fully private; all image traffic is streamed through the API with
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
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
15 minutes).
- **Frontend**: React + Vite SPA — justified gallery, lightbox with rating
@@ -55,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
```
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`
(locally: `http://localhost:5173/api/auth/callback`). Any standard OIDC
provider works (Authentik, Keycloak, Zitadel, Dex, ...); the app uses
@@ -158,8 +168,6 @@ Notes:
- Full RAW develop fallback for files whose embedded preview is tiny
(exceedingly rare on modern cameras; `darktable-cli` in the worker image
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
put and its DB commit can leave an unreferenced original in the bucket
(never data loss — just unclaimed storage).
+13
View File
@@ -73,6 +73,19 @@ export function postDownload(url, ids = '') {
form.remove()
}
// Above this, hashing whole-file in memory (WebCrypto has no streaming digest)
// 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) {
if (file.size > CLIENT_HASH_LIMIT) return null
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer())
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
export function uploadFile(url, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
+80 -32
View File
@@ -1,42 +1,90 @@
import { useCallback, useRef, useState } from 'react'
import { imgUrl } from '../api'
// Justified gallery: rows are built with flexbox, each tile's flex-grow is
// proportional to its aspect ratio so rows fill the container edge to edge.
// When `selected`/`onToggleSelect` are provided, tiles get a select checkmark;
// selection state lives in the parent so it survives lightbox open/close.
// True justified layout: pack photos greedily into rows at their real aspect
// ratios, then scale each row's height so it fills the container width
// exactly. No cropping, no stretch, and the last row simply renders at the
// target height instead of being padded by a spacer.
function layoutRows(photos, containerWidth, targetHeight, gap) {
const rows = []
let row = []
let arSum = 0
let index = 0
for (const photo of photos) {
const ar = photo.width && photo.height ? photo.width / photo.height : 1.5
row.push({ photo, ar, index: index++ })
arSum += ar
const gaps = (row.length - 1) * gap
if (arSum * targetHeight + gaps >= containerWidth) {
rows.push({ items: row, height: (containerWidth - gaps) / arSum })
row = []
arSum = 0
}
}
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
return (
<div className={`gallery${selecting ? ' selecting' : ''}`}>
{photos.map((p, i) => {
const ar = p.width && p.height ? p.width / p.height : 1.5
const isSelected = selected ? selected.has(p.id) : false
return (
<div
key={p.id}
className={`g-item${isSelected ? ' selected' : ''}`}
style={{ '--ar': ar }}
onClick={() => onOpen && onOpen(i)}
>
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
{onToggleSelect && (
<button
className="g-check"
title={isSelected ? 'Deselect' : 'Select'}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(p.id)
}}
<div ref={containerRef} className={`gallery${selecting ? ' selecting' : ''}`}>
{rows.map((row) => (
<div key={row.items[0].photo.id} className="g-row" style={{ height: row.height }}>
{row.items.map(({ photo: p, ar, index }) => {
const isSelected = selected ? selected.has(p.id) : false
return (
<div
key={p.id}
className={`g-item${isSelected ? ' selected' : ''}`}
style={{ width: ar * row.height }}
onClick={() => onOpen && onOpen(index)}
>
</button>
)}
{overlay && overlay(p)}
</div>
)
})}
<div className="g-spacer" />
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
{onToggleSelect && (
<button
className="g-check"
title={isSelected ? 'Deselect' : 'Select'}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(p.id)
}}
>
</button>
)}
{overlay && overlay(p)}
</div>
)
})}
</div>
))}
</div>
)
}
+82 -19
View File
@@ -1,21 +1,57 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, postDownload, uploadFile } from '../api'
import { api, postDownload, sha256Hex, uploadFile } from '../api'
import Gallery from '../components/Gallery'
import Lightbox from '../components/Lightbox'
import SelectionBar from '../components/SelectionBar'
import SelectionBar, { fmtBytes } from '../components/SelectionBar'
import Stars from '../components/Stars'
import useSelection from '../useSelection'
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`
}
const UPLOAD_CONCURRENCY = 3
function UploadZone({ albumId, onUploaded }) {
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.
@@ -31,23 +67,30 @@ function UploadZone({ albumId, onUploaded }) {
while (running.current < UPLOAD_CONCURRENCY && pending.current.length > 0) {
const item = pending.current.shift()
running.current += 1
setQueue((q) =>
q.map((x) => (x.key === item.key ? { ...x, status: 'uploading' } : x)),
)
const url = `/api/albums/${albumId}/photos?filename=${encodeURIComponent(item.file.name)}`
uploadFile(url, item.file, (p) =>
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, progress: p } : x))),
)
.then(() =>
setQueue((q) =>
q.map((x) => (x.key === item.key ? { ...x, status: 'done', progress: 1 } : x)),
),
)
.catch((e) =>
setQueue((q) =>
q.map((x) => (x.key === item.key ? { ...x, status: 'error', error: e.message } : x)),
),
)
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()
@@ -95,6 +138,22 @@ function UploadZone({ albumId, onUploaded }) {
}}
/>
<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) => (
@@ -102,6 +161,10 @@ function UploadZone({ albumId, onUploaded }) {
<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" />
)}
+18 -13
View File
@@ -191,15 +191,17 @@ input:focus {
/* justified gallery */
.gallery {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 6px;
margin: 1rem 0;
}
.g-row {
display: flex;
gap: 6px;
}
.g-item {
position: relative;
height: 240px;
flex-grow: calc(var(--ar) * 100);
flex-basis: calc(var(--ar) * 240px);
flex: none;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
@@ -211,11 +213,6 @@ input:focus {
object-fit: cover;
display: block;
}
.g-spacer {
flex-grow: 1000000;
flex-basis: 0;
height: 0;
}
.g-overlay {
position: absolute;
bottom: 0;
@@ -310,6 +307,18 @@ input:focus {
.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 {
list-style: none;
margin: 1rem 0 0;
@@ -556,10 +565,6 @@ progress {
}
@media (max-width: 700px) {
.g-item {
height: 160px;
flex-basis: calc(var(--ar) * 160px);
}
.lb-stage {
padding: 0 0.5rem;
}
+10
View File
@@ -0,0 +1,10 @@
-- Content hashes: sha256 powers duplicate-upload detection (same content in
-- the same album is returned instead of copied); crc32 lets zip downloads
-- stream originals straight from S3 without a local spool pass.
-- Both are null for photos uploaded before this migration; they self-heal on
-- reprocess (sha256 + crc32) and on zip download (crc32).
alter table photos add column sha256 text;
alter table photos add column crc32 bigint;
create unique index photos_album_sha_uidx on photos (album_id, sha256)
where sha256 is not null;
+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);
+7 -1
View File
@@ -114,11 +114,17 @@ pub(crate) fn base_cookie(name: &'static str, value: String, secure: bool) -> Co
.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> {
let exp = Utc::now().timestamp() + SESSION_DAYS * 86400;
let mut cookie = base_cookie(
SESSION_COOKIE,
format!("{user_id}|{exp}|{email}"),
session_payload(user_id, email, exp),
state.config.cookie_secure(),
);
cookie.set_max_age(time::Duration::days(SESSION_DAYS));
+37 -4
View File
@@ -62,7 +62,24 @@ pub async fn process_photo(state: &AppState, photo_id: Uuid) -> anyhow::Result<(
.unwrap_or("bin")
.to_lowercase();
let src_path = dir.path().join(format!("original.{extension}"));
download(state, &s3::original_key(photo_id, &photo.filename), &src_path).await?;
let (sha256, crc32) =
download(state, &s3::original_key(photo_id, &photo.filename), &src_path).await?;
if photo.sha256.is_none() || photo.crc32.is_none() {
// Best-effort backfill for pre-hash photos; a duplicate in the same
// album trips the unique index, which is fine — skip silently.
if let Err(e) = sqlx::query(
"update photos set sha256 = coalesce(sha256, $2), crc32 = coalesce(crc32, $3)
where id = $1",
)
.bind(photo_id)
.bind(&sha256)
.bind(crc32)
.execute(&state.db)
.await
{
tracing::debug!("hash backfill skipped for {photo_id}: {e}");
}
}
let meta = exif_metadata(&src_path).await?;
@@ -103,7 +120,11 @@ pub async fn process_photo(state: &AppState, photo_id: Uuid) -> anyhow::Result<(
Ok(())
}
async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<()> {
/// Download the original, hashing along the way so legacy photos (uploaded
/// before hashes existed) can be backfilled.
async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<(String, i64)> {
use sha2::Digest;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let object = state
.s3
.get_object()
@@ -114,8 +135,20 @@ async fn download(state: &AppState, key: &str, path: &Path) -> anyhow::Result<()
.with_context(|| format!("fetching s3://{}/{key}", state.config.s3_bucket))?;
let mut reader = object.body.into_async_read();
let mut file = tokio::fs::File::create(path).await?;
tokio::io::copy(&mut reader, &mut file).await?;
Ok(())
let mut sha = sha2::Sha256::new();
let mut crc = crc32fast::Hasher::new();
let mut buf = vec![0u8; 128 * 1024];
loop {
let n = reader.read(&mut buf).await?;
if n == 0 {
break;
}
sha.update(&buf[..n]);
crc.update(&buf[..n]);
file.write_all(&buf[..n]).await?;
}
file.flush().await?;
Ok((hex::encode(sha.finalize()), i64::from(crc.finalize())))
}
#[derive(Default)]
+1
View File
@@ -4,6 +4,7 @@ pub mod error;
pub mod imaging;
pub mod jobs;
pub mod models;
pub mod owned;
pub mod routes;
pub mod s3;
pub mod state;
+6
View File
@@ -96,6 +96,8 @@ impl JobStatus {
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
pub struct Album {
pub id: Uuid,
#[serde(skip_serializing)]
pub owner_id: Uuid,
pub name: String,
pub description: String,
pub created_at: DateTime<Utc>,
@@ -115,6 +117,10 @@ pub struct Photo {
pub height: Option<i32>,
pub taken_at: Option<DateTime<Utc>>,
pub processed_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub sha256: Option<String>,
#[serde(skip_serializing)]
pub crc32: Option<i64>,
pub created_at: DateTime<Utc>,
}
+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)
}
+26 -15
View File
@@ -6,8 +6,10 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::{ApiError, ApiResult};
use crate::models::{Album, JobKind, Photo, PhotoStatus};
use crate::owned;
use crate::state::AppState;
#[derive(Serialize, sqlx::FromRow)]
@@ -23,6 +25,7 @@ pub struct AlbumListItem {
pub async fn list(
State(state): State<AppState>,
user: AuthUser,
) -> ApiResult<Json<Vec<AlbumListItem>>> {
let albums: Vec<AlbumListItem> = sqlx::query_as(
"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
limit 1
) c on true
where a.owner_id = $2
order by a.created_at desc",
)
.bind(PhotoStatus::Ready.as_str())
.bind(user.id)
.fetch_all(&state.db)
.await?;
Ok(Json(albums))
@@ -52,18 +57,21 @@ pub struct CreateAlbum {
pub async fn create(
State(state): State<AppState>,
user: AuthUser,
Json(body): Json<CreateAlbum>,
) -> ApiResult<Json<Album>> {
let name = body.name.trim();
if name.is_empty() {
return Err(ApiError::bad_request("album name is required"));
}
let album: Album =
sqlx::query_as("insert into albums (name, description) values ($1, $2) returning *")
.bind(name)
.bind(body.description.trim())
.fetch_one(&state.db)
.await?;
let album: Album = sqlx::query_as(
"insert into albums (owner_id, name, description) values ($1, $2, $3) returning *",
)
.bind(user.id)
.bind(name)
.bind(body.description.trim())
.fetch_one(&state.db)
.await?;
Ok(Json(album))
}
@@ -94,12 +102,10 @@ pub struct AlbumDetail {
pub async fn get_one(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<AlbumDetail>> {
let album: Album = sqlx::query_as("select * from albums where id = $1")
.bind(album_id)
.fetch_one(&state.db)
.await?;
let album = owned::album(&state, album_id, user.id).await?;
let photos: Vec<Photo> = sqlx::query_as(
"select * from photos where album_id = $1
order by coalesce(taken_at, created_at), filename",
@@ -159,6 +165,7 @@ pub struct UpdateAlbum {
pub async fn update(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
Json(body): Json<UpdateAlbum>,
) -> ApiResult<Json<Album>> {
@@ -170,12 +177,13 @@ pub async fn update(
let album: Album = sqlx::query_as(
"update albums
set name = coalesce($2, name), description = coalesce($3, description)
where id = $1
where id = $1 and owner_id = $4
returning *",
)
.bind(album_id)
.bind(body.name.as_deref().map(str::trim))
.bind(body.description.as_deref().map(str::trim))
.bind(user.id)
.fetch_one(&state.db)
.await?;
Ok(Json(album))
@@ -183,16 +191,19 @@ pub async fn update(
pub async fn delete(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
let mut tx = state.db.begin().await?;
// 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
// 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")
.bind(album_id)
.fetch_optional(&mut *tx)
.await?;
let locked: Option<(Uuid,)> =
sqlx::query_as("select id from albums where id = $1 and owner_id = $2 for update")
.bind(album_id)
.bind(user.id)
.fetch_optional(&mut *tx)
.await?;
if locked.is_none() {
return Err(ApiError::not_found());
}
+13 -2
View File
@@ -28,8 +28,19 @@ async fn authorize_photo(
.await?;
let photo = photo.ok_or_else(ApiError::not_found)?;
if user_from_jar(state, jar).is_some() {
return Ok(photo);
// Photographers may only reach their OWN photos (scoped by album owner);
// 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?;
// Clients may only reach photos the share listing exposes.
+1
View File
@@ -77,6 +77,7 @@ pub fn router(state: &AppState) -> Router<AppState> {
get(shares::list).post(shares::create),
)
.route("/api/albums/{id}/zip", post(zip::album_zip))
.route("/api/albums/{id}/photos/by-hash/{sha256}", get(photos::by_hash))
.route("/api/photos/{id}", delete(photos::delete))
.route("/api/photos/{id}/reprocess", post(photos::reprocess))
.route("/api/shares/{id}", delete(shares::delete))
+111 -33
View File
@@ -5,12 +5,15 @@ use axum::http::HeaderMap;
use axum::Json;
use futures::StreamExt;
use serde::Deserialize;
use sha2::Digest;
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::{ApiError, ApiResult};
use crate::jobs;
use crate::models::{JobKind, Photo, PhotoStatus};
use crate::owned;
use crate::s3;
use crate::state::AppState;
@@ -38,20 +41,34 @@ fn sanitize_filename(raw: &str) -> Result<String, ApiError> {
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(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
Query(query): Query<UploadQuery>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Json<Photo>> {
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
if album_exists.is_none() {
return Err(ApiError::not_found());
}
owned::album(&state, album_id, user.id).await?;
let filename = sanitize_filename(&query.filename)?;
let content_type = headers
@@ -60,17 +77,23 @@ pub async fn upload(
.unwrap_or("application/octet-stream")
.to_string();
// Stream the request body to a temp file so large raws never sit in memory.
// Stream the request body to a temp file so large raws never sit in
// memory, hashing as it flows: sha256 for duplicate detection, crc32 for
// spool-free zip downloads.
let dir = tempfile::tempdir().map_err(anyhow::Error::from)?;
let path = dir.path().join("upload.bin");
let mut file = tokio::fs::File::create(&path)
.await
.map_err(anyhow::Error::from)?;
let mut stream = body.into_data_stream();
let mut sha = sha2::Sha256::new();
let mut crc = crc32fast::Hasher::new();
let mut size: i64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| ApiError::bad_request(format!("upload aborted: {e}")))?;
size += chunk.len() as i64;
sha.update(&chunk);
crc.update(&chunk);
file.write_all(&chunk).await.map_err(anyhow::Error::from)?;
}
file.flush().await.map_err(anyhow::Error::from)?;
@@ -78,6 +101,20 @@ pub async fn upload(
if size == 0 {
return Err(ApiError::bad_request("empty upload"));
}
let sha256 = hex::encode(sha.finalize());
let crc32 = i64::from(crc.finalize());
// Same content already in this album? Return it — re-dragging a folder
// after a partial upload just fills the gaps instead of duplicating.
let existing: Option<Photo> =
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
.bind(album_id)
.bind(&sha256)
.fetch_optional(&state.db)
.await?;
if let Some(existing) = existing {
return Ok(Json(heal_if_errored(&state, existing).await?));
}
// Upload to S3 first, then create the row and enqueue processing in one
// transaction — a photo row can never exist without its job, and a failed
@@ -86,30 +123,33 @@ pub async fn upload(
let key = s3::original_key(photo_id, &filename);
s3::put_file(&state, &key, &path, &content_type).await?;
let result: ApiResult<(sqlx::Transaction<'static, sqlx::Postgres>, Photo)> = async {
let mut tx = state.db.begin().await?;
let photo: Photo = sqlx::query_as(
"insert into photos (id, album_id, filename, content_type, size_bytes, status)
values ($1, $2, $3, $4, $5, $6)
returning *",
)
.bind(photo_id)
.bind(album_id)
.bind(&filename)
.bind(&content_type)
.bind(size)
.bind(PhotoStatus::Uploaded.as_str())
.fetch_one(&mut *tx)
.await?;
jobs::enqueue(
&mut *tx,
JobKind::ProcessPhoto,
serde_json::json!({ "photo_id": photo_id }),
)
.await?;
Ok((tx, photo))
}
.await;
let result: Result<(sqlx::Transaction<'static, sqlx::Postgres>, Photo), sqlx::Error> =
async {
let mut tx = state.db.begin().await?;
let photo: Photo = sqlx::query_as(
"insert into photos (id, album_id, filename, content_type, size_bytes, status, sha256, crc32)
values ($1, $2, $3, $4, $5, $6, $7, $8)
returning *",
)
.bind(photo_id)
.bind(album_id)
.bind(&filename)
.bind(&content_type)
.bind(size)
.bind(PhotoStatus::Uploaded.as_str())
.bind(&sha256)
.bind(crc32)
.fetch_one(&mut *tx)
.await?;
jobs::enqueue(
&mut *tx,
JobKind::ProcessPhoto,
serde_json::json!({ "photo_id": photo_id }),
)
.await?;
Ok((tx, photo))
}
.await;
let (tx, photo) = match result {
Ok(pair) => pair,
@@ -118,7 +158,24 @@ pub async fn upload(
if let Err(cleanup) = s3::delete_prefix(&state, &s3::photo_prefix(photo_id)).await {
tracing::error!("failed to clean up s3 after aborted upload: {cleanup:#}");
}
return Err(e);
// Concurrent identical upload beat us to the unique index — hand
// back the winner instead of an error.
let unique_violation = matches!(
&e,
sqlx::Error::Database(db) if db.code().as_deref() == Some("23505")
);
if unique_violation {
let winner: Option<Photo> =
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
.bind(album_id)
.bind(&sha256)
.fetch_optional(&state.db)
.await?;
if let Some(winner) = winner {
return Ok(Json(heal_if_errored(&state, winner).await?));
}
}
return Err(e.into());
}
};
if let Err(e) = tx.commit().await {
@@ -133,10 +190,29 @@ pub async fn upload(
Ok(Json(photo))
}
/// Client-side dedup support: lets the uploader skip transferring files whose
/// content already exists in the album.
pub async fn by_hash(
State(state): State<AppState>,
user: AuthUser,
Path((album_id, sha256)): Path<(Uuid, String)>,
) -> ApiResult<Json<Photo>> {
owned::album(&state, album_id, user.id).await?;
let photo: Option<Photo> =
sqlx::query_as("select * from photos where album_id = $1 and sha256 = $2")
.bind(album_id)
.bind(&sha256)
.fetch_optional(&state.db)
.await?;
photo.map(Json).ok_or_else(ApiError::not_found)
}
pub async fn delete(
State(state): State<AppState>,
user: AuthUser,
Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
owned::photo(&state, photo_id, user.id).await?;
let mut tx = state.db.begin().await?;
let deleted = sqlx::query("delete from photos where id = $1")
.bind(photo_id)
@@ -157,8 +233,10 @@ pub async fn delete(
pub async fn reprocess(
State(state): State<AppState>,
user: AuthUser,
Path(photo_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
owned::photo(&state, photo_id, user.id).await?;
let mut tx = state.db.begin().await?;
let updated = sqlx::query("update photos set status = $2, error = null where id = $1")
.bind(photo_id)
+10 -8
View File
@@ -7,8 +7,9 @@ use chrono::{DateTime, Utc};
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::random_token;
use crate::auth::{random_token, AuthUser};
use crate::error::{ApiError, ApiResult};
use crate::owned;
use crate::state::AppState;
#[derive(sqlx::FromRow)]
@@ -48,8 +49,10 @@ const SHARE_COLUMNS: &str = "s.id, s.token, s.label, s.password_hash, s.allow_do
pub async fn list(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
) -> ApiResult<Json<Vec<serde_json::Value>>> {
owned::album(&state, album_id, user.id).await?;
let rows: Vec<ShareAdminRow> = sqlx::query_as(&format!(
"select {SHARE_COLUMNS} from shares s where s.album_id = $1 order by s.created_at desc"
))
@@ -75,16 +78,11 @@ fn default_true() -> bool {
pub async fn create(
State(state): State<AppState>,
user: AuthUser,
Path(album_id): Path<Uuid>,
Json(body): Json<CreateShare>,
) -> ApiResult<Json<serde_json::Value>> {
let album_exists: Option<(Uuid,)> = sqlx::query_as("select id from albums where id = $1")
.bind(album_id)
.fetch_optional(&state.db)
.await?;
if album_exists.is_none() {
return Err(ApiError::not_found());
}
owned::album(&state, album_id, user.id).await?;
let password_hash = match body.password.as_deref().map(str::trim) {
Some(pw) if !pw.is_empty() => {
@@ -131,8 +129,10 @@ pub async fn create(
/// their way into the 15-minute lock).
pub async fn reset_lock(
State(state): State<AppState>,
user: AuthUser,
Path(share_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
owned::share(&state, share_id, user.id).await?;
let updated =
sqlx::query("update shares set failed_attempts = 0, locked_until = null where id = $1")
.bind(share_id)
@@ -146,8 +146,10 @@ pub async fn reset_lock(
pub async fn delete(
State(state): State<AppState>,
user: AuthUser,
Path(share_id): Path<Uuid>,
) -> ApiResult<Json<serde_json::Value>> {
owned::share(&state, share_id, user.id).await?;
let deleted = sqlx::query("delete from shares where id = $1")
.bind(share_id)
.execute(&state.db)
+55 -17
View File
@@ -81,12 +81,13 @@ async fn album_name(state: &AppState, album_id: Uuid) -> Result<String, ApiError
pub async fn album_zip(
State(state): State<AppState>,
user: crate::auth::AuthUser,
Path(album_id): Path<Uuid>,
Form(request): Form<ZipRequest>,
) -> 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?;
stream_zip(state, photos, &name)
stream_zip(state, photos, &album.name)
}
pub async fn share_zip(
@@ -277,9 +278,26 @@ fn stream_zip(state: AppState, photos: Vec<Photo>, album_name: &str) -> ApiResul
.map_err(|e| anyhow::anyhow!("building response: {e}").into())
}
/// Download an original into an anonymous temp file, computing its CRC-32 and
/// verifying the byte count matches what the zip plan promised.
fn spool(state: &AppState, key: String, expected_size: u64) -> JoinHandle<anyhow::Result<(tokio::fs::File, u32)>> {
struct Fetched {
file: tokio::fs::File,
crc: u32,
/// sha256 hex — always computed so legacy photos (crc-only) get their
/// content hash backfilled, restoring upload dedup for them.
sha256: String,
}
/// Fetch an original and spool it to an anonymous temp file, computing crc32
/// and sha256 and verifying the byte count the zip plan promised. Spooling
/// (rather than streaming the live S3 body straight through) is deliberate:
/// 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(
state: &AppState,
key: String,
expected_size: u64,
) -> JoinHandle<anyhow::Result<Fetched>> {
use sha2::Digest;
let state = state.clone();
tokio::spawn(async move {
let object = state
@@ -292,7 +310,8 @@ fn spool(state: &AppState, key: String, expected_size: u64) -> JoinHandle<anyhow
.map_err(|e| anyhow::anyhow!("fetching {key}: {e}"))?;
let mut file = tokio::fs::File::from_std(tempfile::tempfile()?);
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 buf = vec![0u8; 128 * 1024];
loop {
@@ -300,7 +319,8 @@ fn spool(state: &AppState, key: String, expected_size: u64) -> JoinHandle<anyhow
if n == 0 {
break;
}
hasher.update(&buf[..n]);
crc.update(&buf[..n]);
sha.update(&buf[..n]);
file.write_all(&buf[..n]).await?;
written += n as u64;
}
@@ -310,7 +330,11 @@ fn spool(state: &AppState, key: String, expected_size: u64) -> JoinHandle<anyhow
);
file.flush().await?;
file.seek(std::io::SeekFrom::Start(0)).await?;
Ok((file, hasher.finalize()))
Ok(Fetched {
file,
crc: crc.finalize(),
sha256: hex::encode(sha.finalize()),
})
})
}
@@ -323,21 +347,23 @@ async fn write_zip(
const FLAGS: u16 = 0x0800;
let mut crcs = Vec::with_capacity(plan.entries.len());
// Prefetch: spool the next object from S3 while streaming the current one.
let mut pending: Option<JoinHandle<anyhow::Result<(tokio::fs::File, u32)>>> = None;
// 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;
for (i, entry) in plan.entries.iter().enumerate() {
let current = match pending.take() {
Some(handle) => handle,
None => spool(state, entry.s3_key.clone(), entry.size),
None => fetch_entry(state, entry.s3_key.clone(), entry.size),
};
if let Some(next) = plan.entries.get(i + 1) {
pending = Some(spool(state, next.s3_key.clone(), next.size));
pending = Some(fetch_entry(state, next.s3_key.clone(), next.size));
}
let (mut file, crc) = current
let mut fetched = current
.await
.map_err(|e| anyhow::anyhow!("spool task failed: {e}"))?
.map_err(|e| anyhow::anyhow!("fetch task failed: {e}"))?
.map_err(|e| anyhow::anyhow!("photo {}: {e:#}", entry.photo_id))?;
crcs.push(crc);
crcs.push(fetched.crc);
let mut lfh = Vec::with_capacity(30 + entry.name.len());
lfh.extend_from_slice(&0x04034b50u32.to_le_bytes());
@@ -346,14 +372,26 @@ async fn write_zip(
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_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()); // uncompressed
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(&entry.name);
out.write_all(&lfh).await?;
tokio::io::copy(&mut file, &mut out).await?;
tokio::io::copy(&mut fetched.file, &mut out).await?;
// Self-heal legacy photos: backfill both hashes so future zips and
// upload dedup both work for them.
let _ = sqlx::query(
"update photos set crc32 = coalesce(crc32, $2), sha256 = coalesce(sha256, $3)
where id = $1",
)
.bind(entry.photo_id)
.bind(i64::from(fetched.crc))
.bind(&fetched.sha256)
.execute(&state.db)
.await;
}
// 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");
}
}