v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.

세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 11:05:40 +09:00
parent 9bc0f0a90e
commit de767095ba
116 changed files with 8128 additions and 2933 deletions

View File

@@ -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>