From de767095bab35a60f2a1f18983bbb7a38478210d Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 22 Jul 2026 11:05:40 +0900 Subject: [PATCH] =?UTF-8?q?v1.10.88=20=ED=92=88=EC=A7=88=C2=B7=EB=B3=B4?= =?UTF-8?q?=EC=95=88=20=EA=B0=9C=EC=84=A0=EA=B3=BC=20=EB=AF=B8=EB=94=94?= =?UTF-8?q?=EC=96=B4=20=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20database=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EC=9D=84=20=EB=B0=98=EC=98=81=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor --- .env.example | 3 + .gitignore | 2 + components/admin/AdminPageForm.vue | 30 +- components/admin/AdminPostForm.vue | 4 +- components/comments/PostComments.vue | 87 +- components/site/LeftSidebar.vue | 3 +- components/site/RightSidebar.vue | 10 +- db/migrations/067_users_session_version.sql | 9 + .../068_csp_violation_aggregates.sql | 22 + db/migrations/069_media_catalog.sql | 148 + docker-compose.test.yml | 27 + docker-compose.yml | 2 +- docs/changelog.md | 17 + docs/convention.md | 16 +- docs/deploy.md | 51 +- docs/history.md | 46 + docs/map.md | 106 +- docs/spec.md | 90 +- docs/todo.md | 13 +- docs/update-log/v1.0.md | 64 +- docs/update-log/v1.1.md | 43 + docs/update-log/v1.10.md | 69 + docs/update-log/v1.2.md | 60 + docs/update-log/v1.5.md | 61 + docs/update-log/v1.6.md | 72 + docs/update.md | 84 + lib/csp-report.js | 160 + lib/markdown-html-block.js | 118 +- lib/media-catalog.js | 151 + lib/post-card-thumbnail-url.js | 14 + package-lock.json | 3953 ++++++++--------- package.json | 23 +- pages/admin/media/index.vue | 125 +- pages/admin/pages/[id].vue | 3 +- pages/admin/pages/new.vue | 10 +- pages/admin/posts/[id].vue | 11 +- pages/admin/posts/new.vue | 11 +- pages/admin/settings/index.vue | 159 +- pages/index.vue | 2 +- pages/posts/index.vue | 2 +- pages/tag/[slug].vue | 21 +- playwright.config.js | 42 + scripts/migrate-database.js | 21 +- scripts/playwright-test-environment.js | 90 + scripts/reconcile-media-catalog.js | 40 + scripts/run-playwright-tests.js | 37 + server/api/auth/account.delete.js | 2 +- server/api/auth/avatar.delete.js | 2 +- server/api/auth/avatar.post.js | 10 +- server/api/auth/check-username.get.js | 2 +- server/api/auth/login.post.js | 24 +- server/api/auth/me.get.js | 6 +- server/api/auth/password.put.js | 7 +- server/api/auth/profile.get.js | 2 +- server/api/auth/profile.put.js | 2 +- server/api/auth/signup.post.js | 9 +- server/api/csp-report.post.js | 118 + server/api/posts.get.js | 12 +- server/api/posts/[slug].get.js | 4 +- server/api/posts/[slug]/comments.get.js | 8 +- server/api/posts/[slug]/comments.post.js | 11 +- .../[slug]/comments/[commentId]/like.post.js | 11 +- server/middleware/admin-api-session.js | 13 +- server/middleware/html-page-renderer.js | 2 + server/middleware/security-headers.js | 14 + server/plugins/post-export-worker.js | 29 + server/repositories/comment-repository.js | 24 +- server/repositories/content-repository.js | 234 +- server/repositories/csp-report-repository.js | 50 + .../repositories/media-catalog-repository.js | 1166 +++++ server/repositories/member-repository.js | 36 +- server/repositories/post-export-repository.js | 85 +- server/repositories/post-import-repository.js | 99 +- server/repositories/postgres-client.js | 2 +- server/routes/admin/api/auth/login.post.js | 24 +- server/routes/admin/api/auth/me.get.js | 4 +- server/routes/admin/api/media.get.js | 66 +- .../routes/admin/api/media/reconcile.post.js | 14 + .../routes/admin/api/media/thumbnail.post.js | 3 + .../admin/api/members/[id]/avatar.post.js | 8 + .../export-jobs/[fileId]/download.get.js | 3 +- server/routes/admin/api/posts/import.post.js | 107 +- server/routes/admin/api/settings.patch.js | 93 + .../admin/api/settings/home-cover.post.js | 3 +- server/routes/admin/api/settings/logo.post.js | 3 +- server/routes/admin/api/tags/cover.post.js | 3 +- server/routes/admin/api/uploads.post.js | 178 +- server/routes/uploads/[...path].get.js | 3 +- server/utils/admin-auth.js | 16 +- server/utils/analytics-heartbeat-input.js | 5 + server/utils/analytics-pageview-input.js | 5 + server/utils/analytics-request-limit.js | 71 + server/utils/backup-storage-summary.js | 3 +- server/utils/csp-report-request-limit.js | 77 + server/utils/login-attempt-limit.js | 135 + server/utils/media-catalog-mode.js | 22 + server/utils/media-library.js | 227 +- server/utils/member-auth.js | 42 +- server/utils/member-avatar-upload.js | 3 +- server/utils/member-avatar.js | 27 +- server/utils/multipart-file-upload.js | 275 ++ server/utils/post-thumbnail-image.js | 43 +- server/utils/recommended-site-favicon.js | 3 +- server/utils/rss-feed.js | 6 +- server/utils/security-headers.js | 70 + server/utils/sitemap.js | 5 +- server/utils/upload-root.js | 45 + server/utils/zip-reader.js | 176 +- server/utils/zip-writer.js | 226 +- tests/csp-report.test.js | 43 + tests/markdown-html-block.test.js | 41 + tests/media-catalog.test.js | 98 + tests/multipart-file-upload.test.js | 158 + tests/playwright/api/core-api.spec.js | 473 ++ .../e2e/admin-guard-and-settings.spec.js | 100 + tests/playwright/e2e/admin-publish.spec.js | 38 + 116 files changed, 8128 insertions(+), 2933 deletions(-) create mode 100644 db/migrations/067_users_session_version.sql create mode 100644 db/migrations/068_csp_violation_aggregates.sql create mode 100644 db/migrations/069_media_catalog.sql create mode 100644 docker-compose.test.yml create mode 100644 docs/update-log/v1.1.md create mode 100644 docs/update-log/v1.10.md create mode 100644 docs/update-log/v1.2.md create mode 100644 docs/update-log/v1.5.md create mode 100644 docs/update-log/v1.6.md create mode 100644 lib/csp-report.js create mode 100644 lib/media-catalog.js create mode 100644 playwright.config.js create mode 100644 scripts/playwright-test-environment.js create mode 100644 scripts/reconcile-media-catalog.js create mode 100644 scripts/run-playwright-tests.js create mode 100644 server/api/csp-report.post.js create mode 100644 server/middleware/security-headers.js create mode 100644 server/plugins/post-export-worker.js create mode 100644 server/repositories/csp-report-repository.js create mode 100644 server/repositories/media-catalog-repository.js create mode 100644 server/routes/admin/api/media/reconcile.post.js create mode 100644 server/routes/admin/api/settings.patch.js create mode 100644 server/utils/analytics-request-limit.js create mode 100644 server/utils/csp-report-request-limit.js create mode 100644 server/utils/login-attempt-limit.js create mode 100644 server/utils/media-catalog-mode.js create mode 100644 server/utils/multipart-file-upload.js create mode 100644 server/utils/security-headers.js create mode 100644 server/utils/upload-root.js create mode 100644 tests/csp-report.test.js create mode 100644 tests/markdown-html-block.test.js create mode 100644 tests/media-catalog.test.js create mode 100644 tests/multipart-file-upload.test.js create mode 100644 tests/playwright/api/core-api.spec.js create mode 100644 tests/playwright/e2e/admin-guard-and-settings.spec.js create mode 100644 tests/playwright/e2e/admin-publish.spec.js diff --git a/.env.example b/.env.example index 23decc2..c682ff5 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ DATABASE_NAME=sori_studio POSTGRES_DB=sori_studio POSTGRES_USER=sori_studio POSTGRES_PASSWORD=replace-with-random-password +DB_BIND_ADDRESS=127.0.0.1 DB_PORT=43081 # Auth @@ -15,6 +16,8 @@ MEMBER_SESSION_SECRET=replace-with-random-password # Upload UPLOAD_DIR=/uploads +# 마이그레이션·reconcile 완료 후 database. 신규 설치는 filesystem으로 시작한 뒤 reconcile → database. +MEDIA_CATALOG_MODE=database MAX_FILE_SIZE=10485760 MAX_VIDEO_FILE_SIZE=209715200 MAX_AUDIO_FILE_SIZE=52428800 diff --git a/.gitignore b/.gitignore index 8674649..f9160b8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ npm-debug.log* # Test coverage/ +playwright-report/ +test-results/ # Reference theme sample (do not commit) ZCF-v1.0.5/ diff --git a/components/admin/AdminPageForm.vue b/components/admin/AdminPageForm.vue index ddf677d..5705a4b 100644 --- a/components/admin/AdminPageForm.vue +++ b/components/admin/AdminPageForm.vue @@ -266,25 +266,33 @@ const handlePageSectionKeydown = (event) => { /** * 페이지 폼의 저장 비교용 문자열을 생성한다. + * @param {Object|null} [payload] - 서버에 저장한 페이지 입력값 * @returns {string} 직렬화된 폼 상태 */ -const serializePageForm = () => JSON.stringify({ +const serializePageForm = (payload = null) => JSON.stringify(payload || { title: form.title.trim(), slug: pageSlug.value, status: form.status, renderMode: form.renderMode, - content: form.content, - isListed: form.isListed, - isFeatured: form.isFeatured, + content: form.renderMode === 'html_document' ? normalizeHtmlTagQuotes(form.content) : form.content, + featuredImage: form.listingImage.trim() || null, + isListed: Boolean(form.isListed), + isFeatured: Boolean(form.isListed && form.isFeatured), listingSection: form.listingSection.trim(), listingTitle: form.listingTitle.trim(), listingDescription: form.listingDescription.trim(), - listingImage: form.listingImage.trim(), listingSortOrder: Number(form.listingSortOrder || 0) }) const hasUnsavedPageChanges = computed(() => serializePageForm() !== savedPageSnapshot.value) +const { + isUnsavedModalOpen, + stayOnUnsavedPage, + leaveUnsavedPage, + allowNextRouteLeave +} = useAdminUnsavedChangesGuard(hasUnsavedPageChanges) + const headerStatusText = computed(() => { if (props.saving) { return 'Saving...' @@ -546,15 +554,17 @@ const submitPage = () => { /** * 현재 폼 상태를 저장 완료 기준점으로 표시한다. + * @param {Object|null} [savedPayload] - 실제 서버에 저장된 입력값 * @returns {void} */ -const markSaved = () => { - savedPageSnapshot.value = serializePageForm() +const markSaved = (savedPayload = null) => { + savedPageSnapshot.value = serializePageForm(savedPayload) } onMounted(markSaved) defineExpose({ + allowNextRouteLeave, markSaved }) @@ -924,5 +934,11 @@ defineExpose({ + + diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index 517b03f..cbb2f93 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -771,14 +771,14 @@ const shouldServerAutosaveDraft = computed(() => { }) /** - * 발행·예약 글만 미저장 이탈을 막는다(초안은 서버 자동 저장·이탈 직전 플러시로 처리) + * 자동 저장할 수 없는 초안과 발행 상태 글의 미저장 이탈을 막는다. * @returns {boolean} */ const isRouteLeaveDirty = computed(() => { if (!hasUnsavedPostChanges.value) { return false } - if (props.saving) { + if (props.saving || props.autoSaving || isFailedDraftAutosavePayload.value || !canPersistDraftPayload.value) { return true } if (['publishedLive', 'scheduled', 'members', 'private'].includes(persistedPublishKind.value)) { diff --git a/components/comments/PostComments.vue b/components/comments/PostComments.vue index 16c959f..b985250 100644 --- a/components/comments/PostComments.vue +++ b/components/comments/PostComments.vue @@ -19,6 +19,7 @@ const replyBody = ref('') const activeReplyTargetId = ref('') const sortOption = ref('latest') const brokenAvatarCommentIds = ref([]) +let commentsRequestId = 0 const canSubmitComment = computed(() => Boolean(newCommentBody.value.trim()) && !submitting.value) const canSubmitReply = computed(() => Boolean(replyBody.value.trim()) && !submittingReplyId.value) @@ -119,22 +120,53 @@ const fetchMember = async () => { } } +/** + * 게시물 변경 시 댓글 입력·답글·오류 상태를 초기화한다. + * @returns {void} + */ +const resetCommentState = () => { + commentsRequestId += 1 + comments.value = [] + loadingComments.value = false + submitting.value = false + submittingReplyId.value = '' + likingCommentIds.value = [] + errorMessage.value = '' + replyErrorMessage.value = '' + newCommentBody.value = '' + replyBody.value = '' + activeReplyTargetId.value = '' + sortOption.value = 'latest' + brokenAvatarCommentIds.value = [] +} + /** * 댓글 목록을 조회한다. * @returns {Promise} */ const fetchComments = async () => { + const requestId = ++commentsRequestId + const requestedSlug = props.slug loadingComments.value = true errorMessage.value = '' try { - const response = await $fetch(`/api/posts/${props.slug}/comments`) + const response = await $fetch(`/api/posts/${requestedSlug}/comments`) + + if (requestId !== commentsRequestId || requestedSlug !== props.slug) { + return + } comments.value = response.comments || [] } catch (error) { + if (requestId !== commentsRequestId || requestedSlug !== props.slug) { + return + } comments.value = [] errorMessage.value = error?.data?.message || '댓글을 불러오지 못했습니다.' } finally { - loadingComments.value = false + if (requestId === commentsRequestId && requestedSlug === props.slug) { + loadingComments.value = false + } } } @@ -148,22 +180,30 @@ const submitComment = async () => { return } + const requestedSlug = props.slug submitting.value = true errorMessage.value = '' try { - await $fetch(`/api/posts/${props.slug}/comments`, { + await $fetch(`/api/posts/${requestedSlug}/comments`, { method: 'POST', body: { body } }) + if (requestedSlug !== props.slug) { + return + } newCommentBody.value = '' await fetchComments() } catch (error) { - errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.' + if (requestedSlug === props.slug) { + errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.' + } } finally { - submitting.value = false + if (requestedSlug === props.slug) { + submitting.value = false + } } } @@ -199,23 +239,31 @@ const submitReply = async (parentId) => { return } + const requestedSlug = props.slug submittingReplyId.value = parentId replyErrorMessage.value = '' try { - await $fetch(`/api/posts/${props.slug}/comments`, { + await $fetch(`/api/posts/${requestedSlug}/comments`, { method: 'POST', body: { body, parentId } }) + if (requestedSlug !== props.slug) { + return + } closeReplyForm() await fetchComments() } catch (error) { - replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.' + if (requestedSlug === props.slug) { + replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.' + } } finally { - submittingReplyId.value = '' + if (requestedSlug === props.slug) { + submittingReplyId.value = '' + } } } @@ -236,14 +284,18 @@ const toggleLike = async (commentId) => { return } + const requestedSlug = props.slug likingCommentIds.value = [...likingCommentIds.value, commentId] errorMessage.value = '' try { - const result = await $fetch(`/api/posts/${props.slug}/comments/${commentId}/like`, { + const result = await $fetch(`/api/posts/${requestedSlug}/comments/${commentId}/like`, { method: 'POST' }) + if (requestedSlug !== props.slug) { + return + } comments.value = comments.value.map((item) => { if (item.id !== commentId) { return item @@ -255,9 +307,13 @@ const toggleLike = async (commentId) => { } }) } catch (error) { - errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.' + if (requestedSlug === props.slug) { + errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.' + } } finally { - likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId) + if (requestedSlug === props.slug) { + likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId) + } } } @@ -305,6 +361,15 @@ const repliesByParent = computed(() => { onMounted(async () => { await Promise.all([fetchMember(), fetchComments()]) }) + +watch(() => props.slug, async (nextSlug, previousSlug) => { + if (!nextSlug || nextSlug === previousSlug) { + return + } + + resetCommentState() + await fetchComments() +})