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
This commit is contained in:
+8
-2
@@ -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('')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { imgUrl } from '../api'
|
import { imgUrl } from '../api'
|
||||||
|
|
||||||
// True justified layout: pack photos greedily into rows at their real aspect
|
// True justified layout: pack photos greedily into rows at their real aspect
|
||||||
@@ -32,15 +32,19 @@ function layoutRows(photos, containerWidth, targetHeight, gap) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
|
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
|
||||||
const containerRef = useRef(null)
|
|
||||||
const [width, setWidth] = useState(0)
|
const [width, setWidth] = useState(0)
|
||||||
|
const observerRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
// Callback ref: (re)attaches the observer whenever the container node
|
||||||
const el = containerRef.current
|
// mounts. A plain mount-effect misses the case where Gallery first renders
|
||||||
if (!el) return
|
// 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))
|
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
|
||||||
observer.observe(el)
|
observer.observe(node)
|
||||||
return () => observer.disconnect()
|
observerRef.current = observer
|
||||||
|
setWidth(node.getBoundingClientRect().width)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (photos.length === 0) return null
|
if (photos.length === 0) return null
|
||||||
|
|||||||
@@ -71,13 +71,16 @@ function UploadZone({ albumId, onUploaded }) {
|
|||||||
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, ...patch } : x)))
|
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, ...patch } : x)))
|
||||||
const transfer = async () => {
|
const transfer = async () => {
|
||||||
// Hash locally first: content the album already has is skipped
|
// Hash locally first: content the album already has is skipped
|
||||||
// without transferring a single byte.
|
// without transferring a single byte. Files too large to hash in the
|
||||||
|
// browser (null) fall straight through to a normal upload.
|
||||||
update({ status: 'checking' })
|
update({ status: 'checking' })
|
||||||
try {
|
try {
|
||||||
const hash = await sha256Hex(item.file)
|
const hash = await sha256Hex(item.file)
|
||||||
await api(`/api/albums/${albumId}/photos/by-hash/${hash}`)
|
if (hash) {
|
||||||
update({ status: 'skipped', progress: 1 })
|
await api(`/api/albums/${albumId}/photos/by-hash/${hash}`)
|
||||||
return
|
update({ status: 'skipped', progress: 1 })
|
||||||
|
return
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 404 (not there yet) or hashing unavailable — upload normally.
|
// 404 (not there yet) or hashing unavailable — upload normally.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,25 @@
|
|||||||
-- all hang off albums, so this single column scopes everything.
|
-- all hang off albums, so this single column scopes everything.
|
||||||
alter table albums add column owner_id uuid references users(id);
|
alter table albums add column owner_id uuid references users(id);
|
||||||
|
|
||||||
-- Existing albums belong to the instance's original photographer.
|
-- Backfill: pre-tenancy albums had no owner. Assigning them to "the" original
|
||||||
update albums set owner_id = (select id from users order by created_at limit 1);
|
-- 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;
|
alter table albums alter column owner_id set not null;
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -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));
|
||||||
|
|||||||
+13
-2
@@ -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.
|
||||||
|
|||||||
+21
-2
@@ -41,6 +41,25 @@ 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,
|
user: AuthUser,
|
||||||
@@ -94,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
|
||||||
@@ -153,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());
|
||||||
|
|||||||
+43
-57
@@ -137,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 {
|
||||||
@@ -180,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;
|
||||||
@@ -282,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
|
||||||
@@ -309,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 {
|
||||||
@@ -322,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;
|
||||||
}
|
}
|
||||||
@@ -332,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()),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,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());
|
||||||
@@ -377,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.
|
||||||
|
|||||||
+20
-1
@@ -44,10 +44,12 @@ fn test_config(database_url: String) -> Config {
|
|||||||
fn session_for(user_id: Uuid, email: &str) -> String {
|
fn session_for(user_id: Uuid, email: &str) -> String {
|
||||||
let key = Key::from(&Sha512::digest(SECRET.as_bytes()));
|
let key = Key::from(&Sha512::digest(SECRET.as_bytes()));
|
||||||
let exp = chrono::Utc::now().timestamp() + 3600;
|
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();
|
let mut jar = CookieJar::new();
|
||||||
jar.signed_mut(&key).add(Cookie::new(
|
jar.signed_mut(&key).add(Cookie::new(
|
||||||
"photos_session",
|
"photos_session",
|
||||||
format!("{user_id}|{exp}|{email}"),
|
photos::auth::session_payload(user_id, email, exp),
|
||||||
));
|
));
|
||||||
format!("photos_session={}", jar.get("photos_session").unwrap().value())
|
format!("photos_session={}", jar.get("photos_session").unwrap().value())
|
||||||
}
|
}
|
||||||
@@ -184,6 +186,23 @@ async fn tenant_isolation_matrix() {
|
|||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
assert_eq!(list.as_array().unwrap().len(), 0, "bob must see no albums");
|
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. ----
|
// ---- Alice keeps full access to her own resources. ----
|
||||||
let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_a), None).await;
|
let (status, list) = request(&router, "GET", "/api/albums", Some(&cookie_a), None).await;
|
||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
|||||||
Reference in New Issue
Block a user