fix(immich): replace ephemeral token auth with blob fetch for Safari compatibility (#381)
Safari blocks SameSite=Lax cookies on <img> subresource requests, causing 401 errors when loading Immich thumbnails and originals. Replaced the token-based <img src> approach with direct fetch() using credentials: 'include', which reliably sends cookies across all browsers. Images are now loaded as blobs with ObjectURLs. Added a concurrency limiter (max 6 parallel fetches) to prevent ERR_INSUFFICIENT_RESOURCES when many photos load simultaneously. Queue is cleared when the photo picker closes so gallery images load immediately.
This commit is contained in:
@@ -14,3 +14,45 @@ export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): P
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ────
|
||||||
|
|
||||||
|
const MAX_CONCURRENT = 6
|
||||||
|
let active = 0
|
||||||
|
const queue: Array<() => void> = []
|
||||||
|
|
||||||
|
function dequeue() {
|
||||||
|
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||||
|
active++
|
||||||
|
queue.shift()!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearImageQueue() {
|
||||||
|
queue.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImageAsBlob(url: string): Promise<string> {
|
||||||
|
if (!url) return ''
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, { credentials: 'include' })
|
||||||
|
if (!resp.ok) { resolve(''); return }
|
||||||
|
const blob = await resp.blob()
|
||||||
|
resolve(URL.createObjectURL(blob))
|
||||||
|
} catch {
|
||||||
|
resolve('')
|
||||||
|
} finally {
|
||||||
|
active--
|
||||||
|
dequeue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active < MAX_CONCURRENT) {
|
||||||
|
active++
|
||||||
|
run()
|
||||||
|
} else {
|
||||||
|
queue.push(run)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPi
|
|||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { getAuthUrl } from '../../api/authUrl'
|
import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
|
|
||||||
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
||||||
const [src, setSrc] = useState('')
|
const [src, setSrc] = useState('')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAuthUrl(baseUrl, 'immich').then(setSrc)
|
let revoke = ''
|
||||||
|
fetchImageAsBlob(baseUrl).then(blobUrl => {
|
||||||
|
revoke = blobUrl
|
||||||
|
setSrc(blobUrl)
|
||||||
|
})
|
||||||
|
return () => { if (revoke) URL.revokeObjectURL(revoke) }
|
||||||
}, [baseUrl])
|
}, [baseUrl])
|
||||||
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
|
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
|
||||||
}
|
}
|
||||||
@@ -208,6 +213,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
shared: true,
|
shared: true,
|
||||||
})
|
})
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
|
clearImageQueue()
|
||||||
loadInitial()
|
loadInitial()
|
||||||
} catch { toast.error(t('memories.error.addPhotos')) }
|
} catch { toast.error(t('memories.error.addPhotos')) }
|
||||||
}
|
}
|
||||||
@@ -365,7 +371,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
{t('memories.selectPhotos')}
|
{t('memories.selectPhotos')}
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button onClick={() => setShowPicker(false)}
|
<button onClick={() => { clearImageQueue(); setShowPicker(false) }}
|
||||||
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
@@ -634,8 +640,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||||
|
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||||
setLightboxOriginalSrc('')
|
setLightboxOriginalSrc('')
|
||||||
getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc)
|
fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc)
|
||||||
setLightboxInfoLoading(true)
|
setLightboxInfoLoading(true)
|
||||||
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||||
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||||
@@ -743,12 +750,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
|
|
||||||
{/* Lightbox */}
|
{/* Lightbox */}
|
||||||
{lightboxId && lightboxUserId && (
|
{lightboxId && lightboxUserId && (
|
||||||
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
<div onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', inset: 0, zIndex: 100,
|
position: 'absolute', inset: 0, zIndex: 100,
|
||||||
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}>
|
}}>
|
||||||
<button onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
<button onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
|
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
|
||||||
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
|
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
|
||||||
|
|||||||
Reference in New Issue
Block a user