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()
|
||||
}
|
||||
|
||||
// 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('')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user