v1.12.21 관리자 미디어 조작 개선 및 CSP 관찰 안내 정리
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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="미디어 페이지"
|
||||
|
||||
Reference in New Issue
Block a user