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:
2026-07-17 15:16:25 +02:00
parent 40f7f2fb5e
commit 84323ab637
9 changed files with 149 additions and 78 deletions
+8 -2
View File
@@ -73,9 +73,15 @@ export function postDownload(url, ids = '') {
form.remove()
}
// SHA-256 of a File, matching the server's content hash — used to skip
// uploading bytes the album already has.
// 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('')
}
+11 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { imgUrl } from '../api'
// 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 }) {
const containerRef = useRef(null)
const [width, setWidth] = useState(0)
const observerRef = useRef(null)
useEffect(() => {
const el = containerRef.current
if (!el) return
// 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(el)
return () => observer.disconnect()
observer.observe(node)
observerRef.current = observer
setWidth(node.getBoundingClientRect().width)
}, [])
if (photos.length === 0) return null
+7 -4
View File
@@ -71,13 +71,16 @@ function UploadZone({ albumId, onUploaded }) {
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.
// 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)
await api(`/api/albums/${albumId}/photos/by-hash/${hash}`)
update({ status: 'skipped', progress: 1 })
return
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.
}