v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -64,6 +64,8 @@ const selectedMediaUrls = ref([])
|
||||
const lastSelectedIndex = ref(-1)
|
||||
const mediaPage = ref(1)
|
||||
const mediaPageSize = ref(50)
|
||||
/** 그리드 파생 이미지 실패 후 원본으로 되돌린 항목 URL */
|
||||
const mediaGridPreviewFallbackUrls = ref(new Set())
|
||||
const draggingUrls = ref([])
|
||||
const uploadInputRef = ref(null)
|
||||
const uploadingFiles = ref(false)
|
||||
@@ -126,7 +128,10 @@ const { data: mediaResponse, refresh } = await useFetch('/admin/api/media', {
|
||||
unused: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
onResponse () {
|
||||
mediaGridPreviewFallbackUrls.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
const mediaItems = computed(() => Array.isArray(mediaResponse.value)
|
||||
@@ -236,6 +241,12 @@ const getMediaPreviewUrl = (item) => {
|
||||
* @returns {string} 그리드 표시 URL
|
||||
*/
|
||||
const getMediaGridPreviewUrl = (item) => {
|
||||
const itemUrl = String(item?.url || '')
|
||||
|
||||
if (!itemUrl || mediaGridPreviewFallbackUrls.value.has(itemUrl)) {
|
||||
return getMediaPreviewUrl(item)
|
||||
}
|
||||
|
||||
if (getMediaItemKind(item) !== 'image') {
|
||||
return getMediaPreviewUrl(item)
|
||||
}
|
||||
@@ -244,12 +255,27 @@ const getMediaGridPreviewUrl = (item) => {
|
||||
return getMediaPreviewUrl(item)
|
||||
}
|
||||
|
||||
const cacheVersion = `${item?.updatedAt || ''}-${item?.size || ''}`
|
||||
|
||||
if (item?.bodyThumbnailStatus?.hasPreviewThumbnail && item.bodyThumbnailStatus.previewThumbnailUrl) {
|
||||
return withGeneratedImageCacheVersion(item.bodyThumbnailStatus.previewThumbnailUrl, cacheVersion)
|
||||
}
|
||||
|
||||
if (item?.bodyThumbnailStatus?.hasThumbnail) {
|
||||
return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(item.url), `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
const previewUrl = item.bodyThumbnailStatus.previewThumbnailUrl || getPostBodyPreviewThumbnailUrl(item.url)
|
||||
const bodyUrl = item.bodyThumbnailStatus.thumbnailUrl || previewUrl
|
||||
|
||||
if (item.bodyThumbnailStatus.hasPreviewThumbnail === false && bodyUrl) {
|
||||
return withGeneratedImageCacheVersion(bodyUrl, cacheVersion)
|
||||
}
|
||||
|
||||
if (previewUrl) {
|
||||
return withGeneratedImageCacheVersion(previewUrl, cacheVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) {
|
||||
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`)
|
||||
return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, cacheVersion)
|
||||
}
|
||||
|
||||
return getMediaPreviewUrl(item)
|
||||
@@ -257,19 +283,21 @@ const getMediaGridPreviewUrl = (item) => {
|
||||
|
||||
/**
|
||||
* 그리드용 파생 이미지 로드 실패 시 원본 이미지로 되돌린다.
|
||||
* @param {Event} event - 이미지 오류 이벤트
|
||||
* @param {Event} _event - 이미지 오류 이벤트
|
||||
* @param {Object} item - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const onMediaGridImageError = (event, item) => {
|
||||
const image = event.target
|
||||
const onMediaGridImageError = (_event, item) => {
|
||||
const itemUrl = String(item?.url || '')
|
||||
|
||||
if (!(image instanceof HTMLImageElement) || image.dataset.fallbackApplied === 'true') {
|
||||
if (!itemUrl || mediaGridPreviewFallbackUrls.value.has(itemUrl)) {
|
||||
return
|
||||
}
|
||||
|
||||
image.dataset.fallbackApplied = 'true'
|
||||
image.src = getMediaPreviewUrl(item)
|
||||
mediaGridPreviewFallbackUrls.value = new Set([
|
||||
...mediaGridPreviewFallbackUrls.value,
|
||||
itemUrl
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1419,6 +1447,7 @@ watch(() => mediaPagination.value.page, (page) => {
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-media__filters flex flex-wrap gap-1.5">
|
||||
<template v-if="activeTab === 'library'">
|
||||
<button
|
||||
@@ -1624,45 +1653,47 @@ watch(() => mediaPagination.value.page, (page) => {
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="admin-media__thumb relative flex w-full flex-col text-left outline-none"
|
||||
class="admin-media__thumb flex w-full flex-col text-left outline-none"
|
||||
type="button"
|
||||
@click="openMediaDetail(item)"
|
||||
>
|
||||
<img
|
||||
v-if="getMediaItemKind(item) === 'image'"
|
||||
class="admin-media__image aspect-square w-full bg-surface object-cover"
|
||||
:src="getMediaGridPreviewUrl(item)"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onMediaGridImageError($event, item)"
|
||||
>
|
||||
<AdminMediaVideoThumbnail
|
||||
v-else-if="getMediaItemKind(item) === 'video'"
|
||||
:src="item.url"
|
||||
:alt="item.title"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"
|
||||
>
|
||||
{{ getMediaItemKind(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getMediaUsageBadgeCount(item)"
|
||||
class="admin-media__usage-count-badge pointer-events-none absolute right-1.5 top-1.5 rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{{ getMediaUsageBadgeCount(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getMediaBadgeLabel(item)"
|
||||
class="admin-media__usage-badge pointer-events-none absolute right-1.5 rounded px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="[
|
||||
getMediaBadgeClass(item),
|
||||
getMediaUsageBadgeCount(item) ? 'top-8' : 'top-1.5'
|
||||
]"
|
||||
>
|
||||
{{ getMediaBadgeLabel(item) }}
|
||||
<span class="admin-media__thumb-frame relative block w-full overflow-hidden">
|
||||
<img
|
||||
v-if="getMediaItemKind(item) === 'image'"
|
||||
class="admin-media__image aspect-square w-full bg-surface object-cover"
|
||||
:src="getMediaGridPreviewUrl(item)"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onMediaGridImageError($event, item)"
|
||||
>
|
||||
<AdminMediaVideoThumbnail
|
||||
v-else-if="getMediaItemKind(item) === 'video'"
|
||||
:src="item.url"
|
||||
:alt="item.title"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="admin-media__image flex aspect-square w-full items-center justify-center bg-surface text-xs font-bold uppercase tracking-[0.18em] text-muted"
|
||||
>
|
||||
{{ getMediaItemKind(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getMediaUsageBadgeCount(item)"
|
||||
class="admin-media__usage-count-badge pointer-events-none absolute right-1.5 top-1.5 z-[1] rounded bg-[#15171a] px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
{{ getMediaUsageBadgeCount(item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getMediaBadgeLabel(item)"
|
||||
class="admin-media__usage-badge pointer-events-none absolute right-1.5 z-[1] rounded px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="[
|
||||
getMediaBadgeClass(item),
|
||||
getMediaUsageBadgeCount(item) ? 'top-8' : 'top-1.5'
|
||||
]"
|
||||
>
|
||||
{{ getMediaBadgeLabel(item) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="admin-media__name block truncate px-2 py-1.5 text-xs font-semibold text-ink">
|
||||
{{ item.name }}
|
||||
@@ -1980,7 +2011,7 @@ watch(() => mediaPagination.value.page, (page) => {
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedMedia.avatarOwner" class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||
<div class="admin-media__usage rounded bg-surface p-3 text-xs">
|
||||
<strong class="admin-media__usage-title text-ink">
|
||||
사용 현황 {{ selectedMedia.usage.length }}곳
|
||||
</strong>
|
||||
@@ -1998,7 +2029,7 @@ watch(() => mediaPagination.value.page, (page) => {
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="admin-media__usage-empty mt-2 text-muted">
|
||||
사용 중인 곳이 없습니다.
|
||||
{{ selectedMedia.avatarOwner ? '프로필 연결 외에 다른 사용처는 없습니다.' : '사용 중인 곳이 없습니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user