v1.12.21 관리자 미디어 조작 개선 및 CSP 관찰 안내 정리

This commit is contained in:
2026-09-28 16:52:14 +09:00
parent 5482318b84
commit 6886c57d04
18 changed files with 488 additions and 60 deletions

View File

@@ -788,12 +788,12 @@ watch(trendRows, () => {
CSP 관찰
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
최근 {{ cspViolationSummary.days }}일 Report-Only 위반 상위 항목
최근 {{ cspViolationSummary.days }}일 내 보고된 항목 · 횟수는 최초 수집부터 누적
</p>
</div>
<div class="admin-dashboard__csp-summary text-right text-xs text-muted">
<p>
최근 {{ formatNumber(cspViolationSummary.recentViolations) }}회
최근 관찰 항목 누적 {{ formatNumber(cspViolationSummary.recentViolations) }}회
</p>
<p class="mt-1">
전체 {{ formatNumber(cspViolationSummary.totalViolations) }}회 · 마지막 {{ formatDashboardDateTime(cspViolationSummary.latestSeenAt) }}
@@ -801,6 +801,19 @@ watch(trendRows, () => {
</div>
</div>
<div class="admin-dashboard__csp-explanation mt-4 rounded border border-line bg-paper p-3 text-xs leading-relaxed text-muted">
<p class="font-semibold text-ink">보안 정책을 강화하기 전, 사이트 기능과 충돌하는지 확인하는 관찰 기록입니다.</p>
<p class="mt-1">Report-Only는 차단 없이 보고만 수집합니다. 이 횟수가 공격 수나 실제 차단 수를 뜻하지는 않습니다.</p>
<details class="admin-dashboard__csp-help mt-2">
<summary class="cursor-pointer font-semibold text-ink">기록 해석과 관찰의 한계</summary>
<ul class="mt-2 list-disc space-y-1 pl-4">
<li>frame-src는 iframe 같은 삽입 화면에 관한 규칙입니다. empty는 보고에 출처가 없고, unknown은 출처를 식별하지 못했다는 뜻입니다. 이 값만으로 광고·확장 프로그램 등 원인을 확정할 수 없습니다.</li>
<li>현재 관찰 정책은 HTTPS 출처를 폭넓게 허용합니다. 보고가 없거나 허용 후보가 비어 있어도 모든 외부 출처를 확인한 것은 아닙니다.</li>
<li>최근 관찰 항목 누적은 최근 {{ cspViolationSummary.days }}일에 보고된 항목들의 전체 누적 합계입니다. 해당 기간에만 발생한 횟수는 현재 집계로 알 수 없습니다.</li>
</ul>
</details>
</div>
<div
v-if="cspAllowlistCandidates.length"
class="admin-dashboard__csp-candidates mt-4 border border-line bg-paper p-3"
@@ -820,6 +833,9 @@ watch(trendRows, () => {
</span>
</div>
</div>
<p v-else class="admin-dashboard__csp-no-candidates mt-3 text-xs text-muted">
조회한 상위 보고에서 외부 출처 허용 후보를 찾지 못했습니다. 이 결과만으로 차단 정책을 강화하지 않습니다.
</p>
<div
v-if="cspViolations.length"
@@ -832,7 +848,7 @@ watch(trendRows, () => {
지시어
</th>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
차단 출처
보고된 출처
</th>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
문서
@@ -841,7 +857,7 @@ watch(trendRows, () => {
브라우저
</th>
<th class="whitespace-nowrap px-2 py-2 text-right font-semibold">
횟수
누적 횟수
</th>
<th class="whitespace-nowrap px-2 py-2 text-right font-semibold">
최근
@@ -858,7 +874,7 @@ watch(trendRows, () => {
{{ row.effectiveDirective }}
</td>
<td class="max-w-[260px] truncate px-2 py-2 text-muted">
{{ row.blockedOrigin }}
{{ row.blockedOrigin === 'empty' ? '출처 없음 (empty)' : row.blockedOrigin === 'unknown' ? '식별 불가 (unknown)' : row.blockedOrigin }}
</td>
<td class="max-w-[240px] truncate px-2 py-2 text-muted">
{{ row.documentPath }}
@@ -880,7 +896,7 @@ watch(trendRows, () => {
v-else
class="admin-dashboard__csp-empty mt-4 border border-line bg-paper px-3 py-2 text-xs text-muted"
>
최근 {{ cspViolationSummary.days }}일에 집계된 CSP 위반은 없습니다.
최근 {{ cspViolationSummary.days }}일에 보고된 CSP 관찰 항목은 없습니다.
</p>
</section>

View File

@@ -61,6 +61,8 @@ const mediaPageSize = ref(50)
/** 그리드 파생 이미지 실패 후 원본으로 되돌린 항목 URL */
const mediaGridPreviewFallbackUrls = ref(new Set())
const draggingUrls = ref([])
const movingMedia = ref(null)
let mediaDragPreview = null
const uploadInputRef = ref(null)
const uploadingFiles = ref(false)
const deletingSelected = ref(false)
@@ -85,8 +87,11 @@ const mediaQuery = computed(() => ({
unused: showUnusedOnly.value ? '1' : undefined
}))
const { data: mediaResponse, refresh } = await useFetch('/admin/api/media', {
const { data: mediaResponse, status: mediaStatus, error: mediaError, refresh } = await useFetch('/admin/api/media', {
key: 'admin-media-library',
query: mediaQuery,
watch: false,
dedupe: 'cancel',
default: () => ({
items: [],
pagination: {
@@ -121,6 +126,9 @@ const { data: mediaResponse, refresh } = await useFetch('/admin/api/media', {
}
})
const isMediaLoading = computed(() => mediaStatus.value === 'pending')
const isMediaInteractionDisabled = computed(() => isMediaLoading.value || Boolean(mediaError.value) || Boolean(movingMedia.value) || deletingSelected.value)
const {
canRegeneratePageCoverThumbnail,
canRegeneratePostBodyThumbnail,
@@ -303,7 +311,7 @@ const onMediaGridImageError = (_event, item) => {
}
/**
* 파일명 변경·삭제·드래그 이동이 제한되는지 여부
* 파일명 변경·삭제가 제한되는지 여부
* @param {Object} item - 미디어 항목
* @returns {boolean} 잠금이면 true
*/
@@ -601,7 +609,7 @@ const mediaPageEndIndex = computed(() => Math.min(mediaPageStartIndex.value + fi
const paginatedMediaItems = computed(() => filteredMediaItems.value)
const filteredSelectableMediaItems = computed(() => paginatedMediaItems.value.filter((item) => !isMediaItemLocked(item)))
const filteredSelectableMediaItems = computed(() => paginatedMediaItems.value)
const filteredSelectableMediaUrls = computed(() => filteredSelectableMediaItems.value.map((item) => item.url))
@@ -670,6 +678,10 @@ const selectFolder = (folder) => {
* @returns {void}
*/
const toggleMediaSelection = (item, index, event) => {
if (isMediaInteractionDisabled.value) {
return
}
if (event.shiftKey && lastSelectedIndex.value >= 0) {
const startIndex = Math.min(lastSelectedIndex.value, index)
const endIndex = Math.max(lastSelectedIndex.value, index)
@@ -700,7 +712,7 @@ const clearMediaSelection = () => {
* @returns {void}
*/
const toggleFilteredMediaSelection = () => {
if (!filteredSelectableMediaUrls.value.length) {
if (isMediaInteractionDisabled.value || !filteredSelectableMediaUrls.value.length) {
return
}
@@ -874,8 +886,11 @@ const submitCreateFolderModal = async () => {
})
closeCreateFolderModal()
activeFolder.value = createdFolder.path
if (!selectedMediaUrls.value.length) {
selectFolder(createdFolder.path)
}
await refreshMediaFolders()
showToast('success', `「${createdFolder.path}」 폴더를 만들었습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '폴더를 만들지 못했습니다.')
}
@@ -929,35 +944,54 @@ const removeMediaFolder = async (folder) => {
* @returns {Promise<void>}
*/
const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
if (activeTab.value !== 'library') {
if (activeTab.value !== 'library' || isMediaInteractionDisabled.value) {
return
}
const targetUrls = [...new Set(urls.filter(Boolean))]
const targetFolder = folder || '미분류'
const targetUrls = [...new Set(urls)].filter((url) => {
const item = mediaItems.value.find((mediaItem) => mediaItem.url === url)
return item && canEditMediaCategory(item) && item.category !== targetFolder
})
if (!targetUrls.length) {
showToast('info', '이 폴더로 이동할 항목이 없습니다.')
return
}
movingMedia.value = { count: targetUrls.length, folder: targetFolder }
try {
await $fetch('/admin/api/media', {
method: 'PUT',
body: {
urls: targetUrls,
category: folder || '미분류'
category: targetFolder
}
})
await Promise.all([
refresh(),
refreshMediaFolders()
])
activeFolder.value = folder || '미분류'
clearMediaSelection()
showToast('success', `${targetUrls.length}개 미디어를 「${targetFolder}」 폴더로 이동했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '미디어 폴더를 변경하지 못했습니다.')
} finally {
movingMedia.value = null
}
}
/**
* 드래그 종료·취소 시 이동 대상과 미리보기를 정리한다.
* @returns {void}
*/
const endMediaDrag = () => {
draggingUrls.value = []
mediaDragPreview?.remove()
mediaDragPreview = null
}
/**
* 미디어 드래그 시작
* @param {DragEvent} event - 드래그 이벤트
@@ -965,7 +999,10 @@ const moveMediaToFolder = async (folder, urls = selectedMediaUrls.value) => {
* @returns {void}
*/
const startMediaDrag = (event, item) => {
if (activeTab.value !== 'library') {
endMediaDrag()
if (activeTab.value !== 'library' || isMediaInteractionDisabled.value || !canEditMediaCategory(item)) {
event.preventDefault()
return
}
@@ -973,11 +1010,20 @@ const startMediaDrag = (event, item) => {
selectedMediaUrls.value = [item.url]
}
draggingUrls.value = [...selectedMediaUrls.value]
draggingUrls.value = mediaItems.value
.filter((mediaItem) => isMediaSelected(mediaItem) && canEditMediaCategory(mediaItem))
.map((mediaItem) => mediaItem.url)
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('application/x-sori-media', JSON.stringify(draggingUrls.value))
event.dataTransfer.setData('text/plain', draggingUrls.value.join('\n'))
mediaDragPreview = document.createElement('div')
mediaDragPreview.className = 'admin-media__drag-preview fixed -left-[10000px] top-0 rounded-lg border-2 border-white bg-sky-700 px-5 py-3 text-sm font-semibold text-white shadow-xl'
mediaDragPreview.textContent = `미디어 ${draggingUrls.value.length}개 이동`
mediaDragPreview.setAttribute('aria-hidden', 'true')
document.body.appendChild(mediaDragPreview)
event.dataTransfer.setDragImage(mediaDragPreview, 24, 24)
}
}
@@ -987,13 +1033,11 @@ const startMediaDrag = (event, item) => {
* @returns {Promise<void>}
*/
const dropMediaOnFolder = async (folder) => {
if (activeTab.value !== 'library') {
draggingUrls.value = []
const urls = [...draggingUrls.value]
endMediaDrag()
if (!urls.length) {
return
}
const urls = draggingUrls.value.length ? draggingUrls.value : selectedMediaUrls.value
draggingUrls.value = []
await moveMediaToFolder(folder, urls)
}
@@ -1142,6 +1186,7 @@ watch(filteredMediaUrls, (urls) => {
watch([activeTab, activeFolder, activeMediaKind, showUnusedOnly, searchText], () => {
mediaPage.value = 1
endMediaDrag()
clearMediaSelection()
closeMediaDetail()
})
@@ -1161,6 +1206,11 @@ watch(() => mediaPagination.value.page, (page) => {
mediaPage.value = normalizedPage
}
})
/** 필터·페이지 초기화를 반영한 최종 조건으로 조회하고 진행 중인 이전 요청을 취소한다. */
watch(mediaQuery, () => refresh(), { flush: 'post' })
onBeforeUnmount(endMediaDrag)
</script>
<template>
@@ -1267,6 +1317,8 @@ watch(() => mediaPagination.value.page, (page) => {
:active-system-media-hint="activeSystemMediaHint"
:active-system-media-title="activeSystemMediaTitle"
:deleting-folder="deletingFolder"
:dragging-count="draggingUrls.length"
:busy="Boolean(movingMedia) || deletingSelected"
:folder-media-counts="folderMediaCounts"
:folders="normalizedFolders"
:library-count="mediaCounts.tabs?.library || 0"
@@ -1277,18 +1329,18 @@ watch(() => mediaPagination.value.page, (page) => {
@select-folder="selectFolder"
/>
<div class="admin-media__content min-w-0">
<div class="admin-media__content min-w-0" :aria-busy="isMediaLoading || Boolean(movingMedia)">
<div class="admin-media__toolbar flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="admin-media__folder-title text-lg font-semibold text-ink">
{{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }}
</h2>
<p class="admin-media__folder-summary mt-1 text-xs text-muted">
{{ mediaPageTotal }}개 중 {{ mediaPageDisplayStart }}-{{ mediaPageEndIndex }}개 표시
{{ isMediaLoading ? '미디어를 불러오는 중…' : mediaError ? '목록을 불러오지 못했습니다.' : `${mediaPageTotal}개 중 ${mediaPageDisplayStart}-${mediaPageEndIndex}개 표시` }}
</p>
</div>
<div class="admin-media__selection-actions flex flex-wrap items-center justify-end gap-2">
<div v-if="filteredMediaItems.length" class="admin-media__page-size flex items-center gap-2 text-xs font-semibold text-muted">
<div class="admin-media__page-size flex items-center gap-2 text-xs font-semibold text-muted">
<span>표시</span>
<AdminSelectInput
:model-value="mediaPageSize"
@@ -1300,34 +1352,68 @@ watch(() => mediaPagination.value.page, (page) => {
/>
</div>
<button
v-if="filteredMediaItems.length"
class="admin-media__select-all rounded border border-line bg-white px-3 py-2 text-xs font-semibold text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-50"
type="button"
title="현재 페이지에 표시된 미디어를 모두 선택합니다."
:aria-pressed="isAllFilteredMediaSelected"
:disabled="!filteredSelectableMediaUrls.length"
:disabled="isMediaInteractionDisabled || !filteredSelectableMediaUrls.length"
@click="toggleFilteredMediaSelection"
>
{{ isAllFilteredMediaSelected ? '현재 페이지 해제' : '현재 페이지 선택' }}
{{ isAllFilteredMediaSelected ? '전체 선택 해제' : '전체 선택' }}
</button>
<div v-if="selectedMediaUrls.length" class="admin-media__selection flex flex-wrap items-center gap-2 rounded border border-line bg-white px-3 py-2 text-xs">
<strong class="admin-media__selection-count text-ink">{{ selectedMediaUrls.length }}개 선택됨</strong>
<button
class="admin-media__selection-delete font-semibold text-red-600 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="deletingSelected"
:disabled="isMediaInteractionDisabled || !selectedDeletableMediaItems.length"
@click="deleteSelectedMedia"
>
{{ deletingSelected ? '삭제 중' : '선택 삭제' }}
</button>
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink" type="button" @click="clearMediaSelection">
<button class="admin-media__selection-clear font-semibold text-muted hover:text-ink disabled:opacity-50" type="button" :disabled="isMediaInteractionDisabled" @click="clearMediaSelection">
선택 해제
</button>
</div>
</div>
</div>
<p class="admin-media__selection-hint mt-3 text-xs text-muted">
전체 선택은 현재 페이지에 적용됩니다.{{ activeTab === 'library' ? ' 선택한 항목을 왼쪽 폴더에 놓으면 함께 이동합니다.' : '' }} 사용 중인 항목은 삭제에서 제외됩니다.
</p>
<p
v-if="movingMedia || draggingUrls.length"
class="admin-media__move-status mt-3 flex items-center gap-2 rounded border border-sky-200 bg-sky-50 px-3 py-2 text-sm font-semibold text-sky-800"
role="status"
aria-live="polite"
>
<span v-if="movingMedia" class="admin-media__move-spinner size-4 animate-spin rounded-full border-2 border-sky-200 border-t-sky-700 motion-reduce:animate-none" aria-hidden="true" />
{{ movingMedia ? `${movingMedia.count}개 미디어를 「${movingMedia.folder}」 폴더로 이동하는 중…` : `${draggingUrls.length}개 미디어를 이동합니다. 원하는 폴더에 놓으세요.` }}
</p>
<div v-if="isMediaLoading" class="admin-media__loading mt-5" role="status" aria-live="polite">
<p class="admin-media__loading-label flex items-center gap-2 text-sm text-muted">
<span class="admin-media__loading-spinner size-4 animate-spin rounded-full border-2 border-line border-t-sky-700 motion-reduce:animate-none" aria-hidden="true" />
{{ systemManagedTab ? activeSystemMediaTitle : (activeFolder || '전체 미디어') }} 불러오는 중…
</p>
<div class="admin-media__skeleton-grid mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10" aria-hidden="true">
<div v-for="index in 12" :key="index" class="admin-media__skeleton animate-pulse border border-line bg-white motion-reduce:animate-none">
<div class="aspect-square bg-surface" />
<div class="m-2 h-3 w-2/3 rounded bg-surface" />
</div>
</div>
</div>
<div v-else-if="mediaError" class="admin-media__load-error mt-5 rounded border border-red-200 bg-red-50 p-8 text-center" role="alert">
<p class="text-sm text-red-700">미디어를 불러오지 못했습니다. 다시 시도해 주세요.</p>
<button class="admin-media__retry mt-3 rounded border border-red-200 bg-white px-3 py-2 text-xs font-semibold text-red-700" type="button" @click="refresh()">
다시 시도
</button>
</div>
<AdminMediaGrid
v-if="filteredMediaItems.length"
v-else-if="filteredMediaItems.length"
:active-tab="activeTab"
:get-media-badge-class="getMediaBadgeClass"
:get-media-badge-label="getMediaBadgeLabel"
@@ -1336,7 +1422,10 @@ watch(() => mediaPagination.value.page, (page) => {
:get-media-usage-badge-count="getMediaUsageBadgeCount"
:items="paginatedMediaItems"
:selected-urls="selectedMediaUrls"
:dragging-urls="draggingUrls"
:disabled="isMediaInteractionDisabled"
@drag-start="startMediaDrag"
@drag-end="endMediaDrag"
@image-error="onMediaGridImageError"
@open-detail="openMediaDetail"
@toggle-selection="toggleMediaSelection"
@@ -1347,6 +1436,7 @@ watch(() => mediaPagination.value.page, (page) => {
</p>
<AdminPagination
v-if="!isMediaLoading && !mediaError"
:page="mediaPage"
:page-count="mediaPageCount"
label="미디어 페이지"