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>

View File

@@ -73,7 +73,7 @@ const savePage = async (payload) => {
})
page.value = updatedPage
pageForm.value?.markSaved()
pageForm.value?.markSaved(payload)
showToast('success', '변경 내용이 저장되었습니다.')
} catch (error) {
errorMessage.value = error?.data?.message || '페이지를 저장하지 못했습니다.'
@@ -100,6 +100,7 @@ const deletePage = async () => {
await $fetch(`/admin/api/pages/${id.value}`, {
method: 'DELETE'
})
pageForm.value?.allowNextRouteLeave()
await navigateTo('/admin/pages')
} catch (error) {
errorMessage.value = error?.data?.message || '페이지를 삭제하지 못했습니다.'

View File

@@ -6,6 +6,7 @@ definePageMeta({
const saving = ref(false)
const errorMessage = ref('')
const pageForm = ref(null)
const createdPageId = ref('')
const toast = ref(null)
let toastTimer = null
@@ -34,17 +35,18 @@ const savePage = async (payload) => {
showToast('info', '페이지를 저장하는 중입니다.')
try {
const page = await $fetch('/admin/api/pages', {
method: 'POST',
const page = await $fetch(createdPageId.value ? `/admin/api/pages/${createdPageId.value}` : '/admin/api/pages', {
method: createdPageId.value ? 'PUT' : 'POST',
body: payload
})
createdPageId.value = String(page.id)
pageForm.value?.markSaved()
pageForm.value?.markSaved(payload)
sessionStorage.setItem('SORI_ADMIN_PAGE_TOAST', JSON.stringify({
type: 'success',
message: '페이지가 저장되었습니다.'
}))
await navigateTo(`/admin/pages/${page.id}`)
await navigateTo(`/admin/pages/${createdPageId.value}`)
} catch (error) {
errorMessage.value = error?.data?.message || '페이지를 저장하지 못했습니다.'
showToast('error', errorMessage.value)

View File

@@ -125,11 +125,11 @@ const savePost = async (payload) => {
/**
* 초안 자동 저장(디바운스 PUT) — 발행·예약 글에는 사용하지 않는다.
* @param {Object} payload - `createPostPayload`와 동일 구조
* @returns {Promise<void>}
* @returns {Promise<boolean>} 저장 성공 여부
*/
const autosaveDraftPost = async (payload) => {
if (!id.value || autoSaving.value) {
return
return false
}
autoSaving.value = true
@@ -141,6 +141,7 @@ const autosaveDraftPost = async (payload) => {
post.value = updatedPost
postForm.value?.markSaved(payload)
return true
} catch (error) {
const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.'
postForm.value?.markAutosaveFailed(payload)
@@ -149,9 +150,10 @@ const autosaveDraftPost = async (payload) => {
toast.value = null
errorMessage.value = ''
postForm.value?.markSlugError('이미 사용 중인 슬러그입니다.')
return
return false
}
showToast('error', msg)
return false
} finally {
autoSaving.value = false
}
@@ -163,8 +165,7 @@ onBeforeRouteLeave(async () => {
if (!payload) {
return true
}
await autosaveDraftPost(payload)
return true
return autosaveDraftPost(payload)
})
/**

View File

@@ -137,12 +137,12 @@ const savePost = async (payload) => {
* 신규 초안 서버 자동 저장(POST). 성공 시 기본은 편집 URL로 교체 이동, 이탈 플러시 시에는 이동하지 않는다.
* @param {Object} payload - 게시물 입력값
* @param {{ skipNavigate?: boolean }} options - `skipNavigate`가 true면 생성만 하고 URL은 바꾸지 않는다.
* @returns {Promise<void>}
* @returns {Promise<boolean>} 저장 성공 여부
*/
const createDraftAutosave = async (payload, options = {}) => {
const skipNavigate = Boolean(options.skipNavigate)
if (autoSaving.value) {
return
return false
}
autoSaving.value = true
@@ -161,6 +161,7 @@ const createDraftAutosave = async (payload, options = {}) => {
if (!skipNavigate) {
await navigateTo(`/admin/posts/${post.id}`, { replace: true })
}
return true
} catch (error) {
const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.'
postForm.value?.markAutosaveFailed(payload)
@@ -169,10 +170,11 @@ const createDraftAutosave = async (payload, options = {}) => {
toast.value = null
errorMessage.value = ''
postForm.value?.markSlugError('이미 사용 중인 슬러그입니다.')
return
return false
}
errorMessage.value = msg
showToast('error', msg)
return false
} finally {
autoSaving.value = false
}
@@ -184,8 +186,7 @@ onBeforeRouteLeave(async () => {
if (!payload) {
return true
}
await createDraftAutosave(payload, { skipNavigate: true })
return true
return createDraftAutosave(payload, { skipNavigate: true })
})
onBeforeUnmount(() => {

View File

@@ -1340,52 +1340,61 @@ const uploadLogo = async (event) => {
/**
* 사이트 설정 PUT 요청 본문을 구성한다.
* @param {Array<string>|null} [fields] - 포함할 필드 목록
* @returns {Object} API 본문
*/
const buildSiteSettingsPayload = () => ({
title: form.title,
description: form.description,
siteUrl: form.siteUrl,
logoText: form.logoText || '井',
logoUrl: form.logoUrl,
faviconUrl: form.faviconUrl,
copyrightText: form.copyrightText,
socialLinks: normalizeSocialLinks(form.socialLinks),
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
postTagLimit: normalizePostTagLimit(form.postTagLimit),
homeLatestPostLimit: normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT),
postListPageSize: normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE),
tagPostListPageSize: normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
featuredPostLimit: normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
pageSectionItemLimit: normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
uploadMaxFileCount: normalizeUploadMaxFileCount(form.uploadMaxFileCount),
uploadMaxTotalSize: normalizeUploadMaxTotalSize(form.uploadMaxTotalSize),
homeCoverImageUrl: form.homeCoverImageUrl || '',
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
homeCoverTitle: form.homeCoverTitle || '',
homeCoverText: form.homeCoverText || '',
brandColor: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR),
announcementEnabled: Boolean(form.announcementEnabled),
announcementText: form.announcementText || '',
announcementUrl: form.announcementUrl || '',
announcementBackgroundColor: normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor || DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR),
announcementAlignment: normalizeAnnouncementAlignment(form.announcementAlignment || DEFAULT_ANNOUNCEMENT_ALIGNMENT),
signupBlockedUsernames: normalizeSignupBlockedUsernames(form.signupBlockedUsernames),
adsTxt: form.adsTxt || '',
customHeadCode: form.customHeadCode || '',
customFooterCode: form.customFooterCode || '',
adHomeFeedCode: form.adHomeFeedCode || '',
adHomeInfeedCode: form.adHomeInfeedCode || '',
adSidebarCode: form.adSidebarCode || '',
adPostSidebarCode: form.adPostSidebarCode || '',
adPostTopCode: form.adPostTopCode || '',
adPostInArticleCode: form.adPostInArticleCode || '',
adPostBottomCode: form.adPostBottomCode || ''
})
const buildSiteSettingsPayload = (fields = null) => {
const payload = {
title: form.title,
description: form.description,
siteUrl: form.siteUrl,
logoText: form.logoText || '井',
logoUrl: form.logoUrl,
faviconUrl: form.faviconUrl,
copyrightText: form.copyrightText,
socialLinks: normalizeSocialLinks(form.socialLinks),
showPostUpdatedAt: Boolean(form.showPostUpdatedAt),
postTagLimit: normalizePostTagLimit(form.postTagLimit),
homeLatestPostLimit: normalizeDisplayCount(form.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT),
postListPageSize: normalizeDisplayCount(form.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE),
tagPostListPageSize: normalizeDisplayCount(form.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE),
featuredPostLimit: normalizeDisplayCount(form.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT),
pageSectionItemLimit: normalizeDisplayCount(form.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT),
uploadMaxFileCount: normalizeUploadMaxFileCount(form.uploadMaxFileCount),
uploadMaxTotalSize: normalizeUploadMaxTotalSize(form.uploadMaxTotalSize),
homeCoverImageUrl: form.homeCoverImageUrl || '',
homeCoverDarkImageUrl: form.homeCoverDarkImageUrl || '',
homeCoverTitle: form.homeCoverTitle || '',
homeCoverText: form.homeCoverText || '',
brandColor: normalizeBrandColor(form.brandColor || DEFAULT_BRAND_COLOR),
announcementEnabled: Boolean(form.announcementEnabled),
announcementText: form.announcementText || '',
announcementUrl: form.announcementUrl || '',
announcementBackgroundColor: normalizeAnnouncementBackgroundColor(form.announcementBackgroundColor || DEFAULT_ANNOUNCEMENT_BACKGROUND_COLOR),
announcementAlignment: normalizeAnnouncementAlignment(form.announcementAlignment || DEFAULT_ANNOUNCEMENT_ALIGNMENT),
signupBlockedUsernames: normalizeSignupBlockedUsernames(form.signupBlockedUsernames),
adsTxt: form.adsTxt || '',
customHeadCode: form.customHeadCode || '',
customFooterCode: form.customFooterCode || '',
adHomeFeedCode: form.adHomeFeedCode || '',
adHomeInfeedCode: form.adHomeInfeedCode || '',
adSidebarCode: form.adSidebarCode || '',
adPostSidebarCode: form.adPostSidebarCode || '',
adPostTopCode: form.adPostTopCode || '',
adPostInArticleCode: form.adPostInArticleCode || '',
adPostBottomCode: form.adPostBottomCode || ''
}
if (!Array.isArray(fields)) {
return payload
}
return Object.fromEntries(fields.map((field) => [field, payload[field]]))
}
/**
* 현재 폼 값으로 사이트 설정을 서버에 저장한다.
* @param {{ successToast?: string, savingFlag: import('vue').Ref<boolean> }} options - 저장 옵션
* @param {{ successToast?: string, savingFlag: import('vue').Ref<boolean>, fields: Array<string> }} options - 저장 옵션
* @returns {Promise<boolean>} 성공 여부
*/
const persistSiteSettings = async (options) => {
@@ -1395,11 +1404,14 @@ const persistSiteSettings = async (options) => {
showToast('info', '설정을 저장하는 중입니다.')
try {
const payload = buildSiteSettingsPayload(options.fields)
const updatedSettings = await $fetch('/admin/api/settings', {
method: 'PUT',
body: buildSiteSettingsPayload()
method: 'PATCH',
body: payload
})
options.fields.forEach((field) => {
form[field] = updatedSettings[field]
})
Object.assign(form, updatedSettings)
showToast('success', successToast)
return true
} catch (error) {
@@ -1442,7 +1454,8 @@ const saveTitleDescSection = async () => {
const ok = await persistSiteSettings({
successToast: '블로그 제목·설명이 저장되었습니다.',
savingFlag: savingTitleDesc
savingFlag: savingTitleDesc,
fields: ['title', 'description']
})
if (ok) {
@@ -1487,7 +1500,8 @@ const saveMiscSection = async () => {
const ok = await persistSiteSettings({
successToast: '사이트 정보가 저장되었습니다.',
savingFlag: savingMisc
savingFlag: savingMisc,
fields: ['siteUrl', 'logoText', 'logoUrl', 'faviconUrl', 'copyrightText']
})
if (ok) {
@@ -1574,7 +1588,8 @@ const saveSocialSection = async () => {
const ok = await persistSiteSettings({
successToast: 'SNS 정보가 저장되었습니다.',
savingFlag: savingSocial
savingFlag: savingSocial,
fields: ['socialLinks']
})
if (ok) {
@@ -1636,7 +1651,18 @@ const savePostSection = async () => {
const ok = await persistSiteSettings({
successToast: 'POST 설정이 저장되었습니다.',
savingFlag: savingPost
savingFlag: savingPost,
fields: [
'showPostUpdatedAt',
'postTagLimit',
'homeLatestPostLimit',
'postListPageSize',
'tagPostListPageSize',
'featuredPostLimit',
'pageSectionItemLimit',
'uploadMaxFileCount',
'uploadMaxTotalSize'
]
})
if (ok) {
@@ -1811,7 +1837,13 @@ const saveHomeCoverSection = async () => {
const ok = await persistSiteSettings({
successToast: '메인 화면 설정이 저장되었습니다.',
savingFlag: savingHomeCover
savingFlag: savingHomeCover,
fields: [
'homeCoverImageUrl',
'homeCoverDarkImageUrl',
'homeCoverTitle',
'homeCoverText'
]
})
if (ok) {
@@ -1863,7 +1895,8 @@ const saveBrandSection = async () => {
const ok = await persistSiteSettings({
successToast: '브랜드 컬러가 저장되었습니다.',
savingFlag: savingBrand
savingFlag: savingBrand,
fields: ['brandColor']
})
if (ok) {
@@ -1909,7 +1942,14 @@ const saveAnnouncementSection = async () => {
const ok = await persistSiteSettings({
successToast: '어나운스 바 설정이 저장되었습니다.',
savingFlag: savingAnnouncement
savingFlag: savingAnnouncement,
fields: [
'announcementEnabled',
'announcementText',
'announcementUrl',
'announcementBackgroundColor',
'announcementAlignment'
]
})
if (ok) {
@@ -1951,7 +1991,8 @@ const saveSpamSection = async () => {
const ok = await persistSiteSettings({
successToast: '스팸 필터 설정이 저장되었습니다.',
savingFlag: savingSpam
savingFlag: savingSpam,
fields: ['signupBlockedUsernames']
})
if (ok) {
@@ -1993,7 +2034,8 @@ const saveSiteCodeSection = async () => {
const ok = await persistSiteSettings({
successToast: '사이트 코드 설정이 저장되었습니다.',
savingFlag: savingSiteCode
savingFlag: savingSiteCode,
fields: ['adsTxt', 'customHeadCode', 'customFooterCode']
})
if (ok) {
@@ -2045,7 +2087,16 @@ const saveAdsSection = async () => {
const ok = await persistSiteSettings({
successToast: '광고 슬롯 설정이 저장되었습니다.',
savingFlag: savingAds
savingFlag: savingAds,
fields: [
'adHomeFeedCode',
'adHomeInfeedCode',
'adSidebarCode',
'adPostSidebarCode',
'adPostTopCode',
'adPostInArticleCode',
'adPostBottomCode'
]
})
if (ok) {