diff --git a/app/components/ImageCard.vue b/app/components/ImageCard.vue index 2d61ef3..b6790d2 100644 --- a/app/components/ImageCard.vue +++ b/app/components/ImageCard.vue @@ -26,8 +26,48 @@ const emit = defineEmits<{ const { formatFileSize } = useFileSize() const { t, locale } = useI18n() const imageError = ref(false) +const retryCount = ref(0) const copyFormat = ref('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 | 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(() => [ { label: t('copy.url'), value: 'url' as const }, { label: t('copy.markdown'), value: 'markdown' as const }, @@ -78,11 +118,12 @@ function toggleSelected(value: boolean | 'indeterminate') {
{ if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) { + setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL) throw createError({ statusCode: 404, statusMessage: 'Not Found' }) } }) diff --git a/server/utils/serve-image.ts b/server/utils/serve-image.ts index 9a79b23..ff3ad3a 100644 --- a/server/utils/serve-image.ts +++ b/server/utils/serve-image.ts @@ -20,7 +20,7 @@ import { getBackendRowForKey } from './storage/resolver' * - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证 */ -const ERROR_CACHE_CONTROL = 'public, max-age=60' +const ERROR_CACHE_CONTROL = 'private, no-cache, max-age=0' function getAllowedHosts(event: H3Event): Set { const hosts = new Set() diff --git a/server/utils/storage.ts b/server/utils/storage.ts index addf6a3..798bd4f 100644 --- a/server/utils/storage.ts +++ b/server/utils/storage.ts @@ -53,13 +53,22 @@ export async function putImage( } export async function headImage(key: string): Promise { - const indexed = getImageIndexAsStored(key) - if (indexed) return indexed - const backend = await getBackendForKey(key) const stored = await backend.head(key) 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({ key: stored.key, backendId: backend.id,