fix: gallery preview retries and avoid caching failed image responses
Verify storage files before serving from the index, disable CDN caching on image 404/403 and host-isolation blocks, and retry gallery thumbnails after transient load failures.
This commit is contained in:
1 parent
b0a5fbfca5
commit
f25c89855f
4 files changed
+59
-6
No files matched your search
@@ -26,8 +26,48 @@ const emit = defineEmits<{
|
|||||||
const { formatFileSize } = useFileSize()
|
const { formatFileSize } = useFileSize()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const imageError = ref(false)
|
const imageError = ref(false)
|
||||||
|
const retryCount = ref(0)
|
||||||
const copyFormat = ref<CopyFormat>('url')
|
const copyFormat = ref<CopyFormat>('url')
|
||||||
|
|
||||||
|
const MAX_PREVIEW_RETRIES = 3
|
||||||
|
const PREVIEW_RETRY_DELAYS_MS = [1000, 2000, 4000]
|
||||||
|
|
||||||
|
const previewSrc = computed(() => {
|
||||||
|
if (retryCount.value === 0) return props.image.url
|
||||||
|
const separator = props.image.url.includes('?') ? '&' : '?'
|
||||||
|
return `${props.image.url}${separator}retry=${retryCount.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.image.key, () => {
|
||||||
|
imageError.value = false
|
||||||
|
retryCount.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function clearRetryTimer() {
|
||||||
|
if (retryTimer !== undefined) {
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
retryTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPreviewError() {
|
||||||
|
if (retryCount.value >= MAX_PREVIEW_RETRIES) {
|
||||||
|
imageError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = PREVIEW_RETRY_DELAYS_MS[retryCount.value] ?? PREVIEW_RETRY_DELAYS_MS.at(-1)!
|
||||||
|
clearRetryTimer()
|
||||||
|
retryTimer = setTimeout(() => {
|
||||||
|
retryCount.value += 1
|
||||||
|
retryTimer = undefined
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(clearRetryTimer)
|
||||||
|
|
||||||
const copyFormatItems = computed(() => [
|
const copyFormatItems = computed(() => [
|
||||||
{ label: t('copy.url'), value: 'url' as const },
|
{ label: t('copy.url'), value: 'url' as const },
|
||||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||||
@@ -78,11 +118,12 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
|||||||
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
|
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
|
||||||
<img
|
<img
|
||||||
v-if="!imageError"
|
v-if="!imageError"
|
||||||
:src="image.url"
|
:key="`${image.key}-${retryCount}`"
|
||||||
|
:src="previewSrc"
|
||||||
:alt="image.originalName"
|
:alt="image.originalName"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="absolute inset-0 size-full object-cover object-center"
|
class="absolute inset-0 size-full object-cover object-center"
|
||||||
@error="imageError = true"
|
@error="onPreviewError"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
|
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
|
||||||
|
|
||||||
|
const HOST_ISOLATION_CACHE_CONTROL = 'private, no-cache, max-age=0'
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
|
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
|
||||||
|
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -20,7 +20,7 @@ import { getBackendRowForKey } from './storage/resolver'
|
|||||||
* - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证
|
* - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ERROR_CACHE_CONTROL = 'public, max-age=60'
|
const ERROR_CACHE_CONTROL = 'private, no-cache, max-age=0'
|
||||||
|
|
||||||
function getAllowedHosts(event: H3Event): Set<string> {
|
function getAllowedHosts(event: H3Event): Set<string> {
|
||||||
const hosts = new Set<string>()
|
const hosts = new Set<string>()
|
||||||
|
|||||||
+12
-3
@@ -53,13 +53,22 @@ export async function putImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function headImage(key: string): Promise<StoredImage | null> {
|
export async function headImage(key: string): Promise<StoredImage | null> {
|
||||||
const indexed = getImageIndexAsStored(key)
|
|
||||||
if (indexed) return indexed
|
|
||||||
|
|
||||||
const backend = await getBackendForKey(key)
|
const backend = await getBackendForKey(key)
|
||||||
const stored = await backend.head(key)
|
const stored = await backend.head(key)
|
||||||
if (!stored) return null
|
if (!stored) return null
|
||||||
|
|
||||||
|
const indexed = getImageIndexAsStored(key)
|
||||||
|
if (indexed) {
|
||||||
|
return {
|
||||||
|
...stored,
|
||||||
|
backendId: backend.id,
|
||||||
|
originalName: indexed.originalName,
|
||||||
|
contentType: indexed.contentType,
|
||||||
|
uploadedAt: indexed.uploadedAt,
|
||||||
|
userId: indexed.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
insertImageIndex({
|
insertImageIndex({
|
||||||
key: stored.key,
|
key: stored.key,
|
||||||
backendId: backend.id,
|
backendId: backend.id,
|
||||||
|
|||||||
Reference in new issue
Block a user