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

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

2
.gitignore vendored
View File

@@ -28,6 +28,8 @@ npm-debug.log*
# Test
coverage/
playwright-report/
test-results/
# Reference theme sample (do not commit)
ZCF-v1.0.5/

View File

@@ -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
})
</script>
@@ -924,5 +934,11 @@ defineExpose({
</div>
</aside>
<AdminUnsavedChangesModal
:open="isUnsavedModalOpen"
@stay="stayOnUnsavedPage"
@leave="leaveUnsavedPage"
/>
</form>
</template>

View File

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

View File

@@ -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<void>}
*/
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()
})
</script>
<template>

View File

@@ -232,7 +232,8 @@ onMounted(() => {
<template>
<aside
id="menu"
class="left-sidebar site-sidebar flex flex-col overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,transform,border-color] duration-300 ease-out max-lg:fixed max-lg:left-0 max-lg:top-[57px] max-lg:z-[60] max-lg:h-[calc(100dvh-57px)] max-lg:max-h-[calc(100dvh-57px)] max-lg:w-[min(287px,calc(100vw-24px))] max-lg:shadow-[0_16px_48px_rgba(0,0,0,0.18)] lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:self-start"
class="left-sidebar site-sidebar flex flex-col overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,transform,border-color] duration-300 ease-out max-lg:fixed max-lg:left-0 max-lg:z-[60] max-lg:w-[min(287px,calc(100vw-24px))] max-lg:shadow-[0_16px_48px_rgba(0,0,0,0.18)] lg:sticky lg:z-10 lg:self-start"
style="top: var(--site-top-chrome-height, 57px); height: calc(100dvh - var(--site-top-chrome-height, 57px)); max-height: calc(100dvh - var(--site-top-chrome-height, 57px))"
:class="[
menuOpen
? 'lg:w-[287px] lg:opacity-100'

View File

@@ -197,7 +197,7 @@ watch([postTocItems, () => route.fullPath], async () => {
</script>
<template>
<aside class="right-sidebar site-sidebar flex w-full flex-col overflow-hidden border-[var(--site-line)] max-lg:border-l-0 max-lg:border-t max-lg:px-4 max-lg:pb-10 lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:w-[287px] lg:self-start lg:border-l-0 lg:border-t-0 lg:px-0">
<aside class="right-sidebar site-sidebar flex w-full flex-col overflow-hidden border-[var(--site-line)] max-lg:border-l-0 max-lg:border-t max-lg:px-4 max-lg:pb-10 lg:sticky lg:z-10 lg:w-[287px] lg:self-start lg:border-l-0 lg:border-t-0 lg:px-0">
<div class="right-sidebar__scroll site-sidebar-scroll flex min-h-0 flex-1 flex-col">
<div v-if="!isPostDetailRoute" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-5 sm:pr-0 max-lg:px-0">
<div class="right-sidebar__profile flex items-center gap-3">
@@ -426,6 +426,14 @@ watch([postTocItems, () => route.fullPath], async () => {
</template>
<style scoped>
@media (min-width: 1024px) {
.right-sidebar {
top: var(--site-top-chrome-height, 57px);
height: calc(100dvh - var(--site-top-chrome-height, 57px));
max-height: calc(100dvh - var(--site-top-chrome-height, 57px));
}
}
.right-sidebar__custom-social-icon :deep(svg) {
display: block;
width: 1rem !important;

View File

@@ -0,0 +1,9 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS session_version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_session_version_check;
ALTER TABLE users
ADD CONSTRAINT users_session_version_check
CHECK (session_version > 0);

View File

@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS csp_violation_aggregates (
policy_version TEXT NOT NULL,
document_path TEXT NOT NULL,
effective_directive TEXT NOT NULL,
blocked_origin TEXT NOT NULL,
user_agent_family TEXT NOT NULL,
violation_count BIGINT NOT NULL DEFAULT 1,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (
policy_version,
document_path,
effective_directive,
blocked_origin,
user_agent_family
),
CONSTRAINT csp_violation_aggregates_count_check
CHECK (violation_count > 0)
);
CREATE INDEX IF NOT EXISTS csp_violation_aggregates_last_seen_idx
ON csp_violation_aggregates (last_seen_at DESC);

View File

@@ -0,0 +1,148 @@
ALTER TABLE media_metadata
ADD COLUMN IF NOT EXISTS id BIGINT GENERATED BY DEFAULT AS IDENTITY,
ADD COLUMN IF NOT EXISTS file_name TEXT,
ADD COLUMN IF NOT EXISTS storage_key TEXT,
ADD COLUMN IF NOT EXISTS extension TEXT,
ADD COLUMN IF NOT EXISTS kind TEXT,
ADD COLUMN IF NOT EXISTS role TEXT,
ADD COLUMN IF NOT EXISTS size_bytes BIGINT,
ADD COLUMN IF NOT EXISTS modified_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS parent_id BIGINT,
ADD COLUMN IF NOT EXISTS origin TEXT,
ADD COLUMN IF NOT EXISTS status TEXT,
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS missing_since TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS error_detail TEXT;
UPDATE media_metadata
SET
file_name = COALESCE(file_name, regexp_replace(url, '^.*/', '')),
storage_key = COALESCE(storage_key, regexp_replace(url, '^/uploads/', '')),
extension = COALESCE(extension, lower(substring(url from '\.[^.?#/]+$'))),
kind = COALESCE(kind, 'file'),
role = COALESCE(role, 'original'),
origin = COALESCE(origin, 'external'),
status = COALESCE(status, 'staging')
WHERE file_name IS NULL
OR storage_key IS NULL
OR kind IS NULL
OR role IS NULL
OR origin IS NULL
OR status IS NULL;
ALTER TABLE media_metadata
ALTER COLUMN file_name SET NOT NULL,
ALTER COLUMN storage_key SET NOT NULL,
ALTER COLUMN kind SET NOT NULL,
ALTER COLUMN role SET NOT NULL,
ALTER COLUMN origin SET NOT NULL,
ALTER COLUMN status SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS media_metadata_id_unique_idx
ON media_metadata (id);
CREATE UNIQUE INDEX IF NOT EXISTS media_metadata_storage_key_unique_idx
ON media_metadata (storage_key);
CREATE INDEX IF NOT EXISTS media_metadata_status_updated_idx
ON media_metadata (status, modified_at DESC);
CREATE INDEX IF NOT EXISTS media_metadata_kind_role_idx
ON media_metadata (kind, role);
CREATE INDEX IF NOT EXISTS media_metadata_parent_idx
ON media_metadata (parent_id)
WHERE parent_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS media_metadata_last_seen_idx
ON media_metadata (last_seen_at DESC);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'media_metadata_kind_check'
) THEN
ALTER TABLE media_metadata
ADD CONSTRAINT media_metadata_kind_check
CHECK (kind IN ('image', 'video', 'audio', 'file'));
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'media_metadata_status_check'
) THEN
ALTER TABLE media_metadata
ADD CONSTRAINT media_metadata_status_check
CHECK (status IN ('staging', 'active', 'missing', 'deleting', 'error'));
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'media_metadata_origin_check'
) THEN
ALTER TABLE media_metadata
ADD CONSTRAINT media_metadata_origin_check
CHECK (origin IN ('upload', 'import', 'external', 'derived', 'system'));
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'media_metadata_parent_fk'
) THEN
ALTER TABLE media_metadata
ADD CONSTRAINT media_metadata_parent_fk
FOREIGN KEY (parent_id)
REFERENCES media_metadata (id)
ON DELETE SET NULL;
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS media_references (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
media_id BIGINT REFERENCES media_metadata (id) ON DELETE CASCADE,
media_url TEXT NOT NULL,
reference_type TEXT NOT NULL,
reference_id TEXT NOT NULL,
reference_field TEXT NOT NULL,
reference_title TEXT NOT NULL DEFAULT '',
reference_slug TEXT NOT NULL DEFAULT '',
reference_status TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT media_references_target_unique
UNIQUE (media_url, reference_type, reference_id, reference_field)
);
CREATE INDEX IF NOT EXISTS media_references_media_id_idx
ON media_references (media_id)
WHERE media_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS media_references_url_idx
ON media_references (media_url);
CREATE INDEX IF NOT EXISTS media_references_target_idx
ON media_references (reference_type, reference_id);
CREATE TABLE IF NOT EXISTS media_catalog_state (
singleton BOOLEAN PRIMARY KEY DEFAULT true,
ready BOOLEAN NOT NULL DEFAULT false,
reconciled_at TIMESTAMPTZ,
filesystem_count BIGINT NOT NULL DEFAULT 0,
filesystem_bytes BIGINT NOT NULL DEFAULT 0,
catalog_count BIGINT NOT NULL DEFAULT 0,
catalog_bytes BIGINT NOT NULL DEFAULT 0,
error_detail TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT media_catalog_state_singleton_check
CHECK (singleton = true)
);
INSERT INTO media_catalog_state (singleton, ready)
VALUES (true, false)
ON CONFLICT (singleton) DO NOTHING;

27
docker-compose.test.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
sori-studio-test-db:
image: postgres:16-alpine
container_name: sori-studio-test-db
environment:
POSTGRES_DB: sori_studio_test
POSTGRES_USER: sori_studio_test
POSTGRES_PASSWORD: sori_studio_test_password
ports:
- "127.0.0.1:${TEST_DB_PORT:-43091}:5432"
volumes:
- sori-studio-test-postgres:/var/lib/postgresql/data
- ./db/migrations:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sori_studio_test -d sori_studio_test"]
interval: 1s
timeout: 3s
retries: 30
networks:
- sori-studio-test-network
volumes:
sori-studio-test-postgres:
networks:
sori-studio-test-network:
driver: bridge

View File

@@ -26,7 +26,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${DB_PORT:-43081}:5432"
- "${DB_BIND_ADDRESS:-127.0.0.1}:${DB_PORT:-43081}:5432"
volumes:
- sori-studio-postgres:/var/lib/postgresql/data
# NAS 등: 호스트 db/migrations 가 다른 UID만 읽을 수 있으면 컨테이너에서 Permission denied → DB 재시작 루프. 프로젝트 루트에서 chmod -R a+rX db/migrations 및 상위 경로 통과 권한 확인.

View File

@@ -1,5 +1,22 @@
# 업데이트 요약
## v1.10.88
- 격리된 PostgreSQL과 임시 업로드 공간에서 API·브라우저 주요 흐름을 자동 검증하도록 테스트를 확장했다.
- 기존 광고·사용자 코드를 차단하지 않으면서 최소 CSP를 강제하고 호환성 위반을 개인정보 최소화 형태로 수집하도록 보강했다.
- 미디어 파일이 많아져도 빠르게 검색·필터링할 수 있는 DB 카탈로그와 안전한 단계별 전환·fallback 절차를 추가했다.
## v1.10.86
- 게시물 내보내기 ZIP이 원본 이미지와 첨부 파일을 그대로 유지하면서 서버 메모리를 과도하게 사용하지 않도록 개선했다.
- 게시물 가져오기 ZIP을 안전한 범위에서 순차 처리하도록 파일 개수·압축률·압축 해제 크기 제한을 추가했다.
## v1.10.85
- 로그인 반복 시도를 제한하고 비밀번호 변경 후 기존 세션이 더 이상 유지되지 않도록 보강했다.
- 공개 게시물 목록이 필요한 개수와 요약 정보만 전달하도록 가볍게 정리했다.
- Nuxt와 관련 의존성의 알려진 보안 취약점을 업데이트했다.
## v1.10.83
- AdSense 잔고 카드에 지급 계정 통화 기준 안내를 추가했다.

View File

@@ -61,5 +61,17 @@
## 검증
- `npm run lint`: JavaScript 파일 문법 점검
- `npm run test`: Nuxt 프로덕션 빌드 기반 회귀 검증
- `npm run verify`: 문법 점검과 빌드를 함께 실행
- `npm test`: `npm run test:unit` 별칭
- `npm run test:unit`: Node 내장 테스트 러너로 `tests/*.test.js` 단위·회귀 테스트 실행
- `npm run test:api`: Playwright API 통합 테스트 실행
- `npm run test:e2e`: Playwright Desktop Chrome E2E 테스트 실행
- `npm run verify`: `lint` → `test:unit` → 프로덕션 `build` 실행
- `npm run verify:ci`: `lint` → `test:unit` → `test:api` → `test:e2e` → 프로덕션 `build` 실행
### 통합 테스트 격리
- API/E2E 테스트는 `scripts/run-playwright-tests.js`를 통해 실행하며 `playwright test`를 직접 실행해 개발 DB나 일반 업로드 경로를 사용하지 않는다.
- 테스트 DB 이름은 반드시 `_test`로 끝나야 하며 `docker-compose.test.yml`의 별도 PostgreSQL 컨테이너·일회성 볼륨만 사용한다.
- 테스트 업로드는 `PLAYWRIGHT_TEST=1`과 `SORI_TEST_UPLOAD_ROOT`가 지정한 시스템 임시 디렉터리만 사용하고 종료 시 정리한다.
- DB 상태를 공유하는 통합 시나리오는 Playwright worker 1개와 serial 모드를 유지한다.
- 테스트 중 비결정적 백그라운드 변경을 막기 위해 Export worker와 추천 사이트 파비콘 cron을 비활성화한다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.10.83에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> v1.10.88은 단위 테스트와 프로덕션 빌드 외에 격리 PostgreSQL 기반 Playwright API 10건·Chromium E2E 3건을 제공한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile → (선택) shadow 관찰 → `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports` 쓰기 권한을 확인한다.
## 빌드 유형
@@ -8,10 +8,51 @@
|------|--------|------|
| 개발 | `npm run dev` | 로컬 테스트, 개발 서버 |
| 프로덕션 | `npm run build` | NAS 배포, 운영 서버 |
| 검증 | `npm run verify` | JavaScript 문법 점검 + 프로덕션 빌드 |
| 단위 검증 | `npm run verify` | JavaScript 문법 점검 + Node 단위 테스트 + 프로덕션 빌드 |
| CI 전체 검증 | `npm run verify:ci` | 문법 + 단위 + Playwright API/E2E + 프로덕션 빌드 |
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
## v1.10.88 배포 전 검증
Playwright API 테스트도 브라우저 바이너리는 필요하지 않지만 `verify:ci`에는 Chromium E2E가 포함된다. 최초 1회 의존성과 Chromium을 설치하고 Docker daemon이 실행 중인지 확인한다.
```bash
npm install
npx playwright install chromium
npm run test:unit
npm run test:api
npm run test:e2e
npm run verify:ci
```
Linux CI 이미지에서 브라우저 시스템 의존성도 함께 준비해야 하면 `npx playwright install --with-deps chromium`을 사용한다. API/E2E 명령은 `docker-compose.test.yml`의 `sori_studio_test` DB와 임시 업로드 루트만 사용하고 종료 시 제거한다. 테스트 중 Export worker와 추천 사이트 파비콘 cron은 비활성화된다.
### 운영 마이그레이션과 미디어 카탈로그 전환
```bash
sh scripts/migrate-production-db.sh status
sh scripts/migrate-production-db.sh migrate
sh scripts/migrate-production-db.sh status
```
- `068_csp_violation_aggregates.sql`은 CSP 보고 집계 테이블을 만든다.
- `069_media_catalog.sql`은 미디어 카탈로그·참조·상태 계약을 만든다. 최초 상태는 `ready=false`이므로 마이그레이션 직후 곧바로 `MEDIA_CATALOG_MODE=database`만 켠 채 운영하지 않는다. 반드시 reconcile을 먼저 끝낸다.
- 배포 직후(또는 `filesystem`) 상태에서 관리자 인증 후 `POST /admin/api/media/reconcile`을 1회 실행한다. 이 API는 파일을 삭제하거나 이동하지 않고 발견 파일 upsert, 참조 백필, 사라진 파일 `missing` 표시만 수행한다.
- `media_catalog_state`의 `ready=true`, 파일시스템/카탈로그 `count`와 `bytes` 일치, `staging`·`error` 행 0을 확인한다.
- 필요하면 짧게 `MEDIA_CATALOG_MODE=shadow`로 재기동해 관리자 미디어를 탐색하고 로그의 `count`, `bytes`, 종류별 차이가 0인지 본다.
- 확인이 끝나면 운영 env를 `MEDIA_CATALOG_MODE=database`로 고정하고 재기동한다. 개발 환경도 동일하게 `database`로 고정한다. 준비 미완료·불일치·DB 조회 오류가 발생하면 애플리케이션은 자동으로 파일시스템 목록으로 fallback한다.
- 관리자 화면의 임시 전환 버튼·런타임 모드 덮어쓰기 API는 제거했다. 모드 변경은 환경 변수만 사용한다.
- `npm run media:reconcile:dev`는 loopback 비운영 DB 전용 안전장치가 있으므로 개발 DB 확인에만 사용한다. 운영에서는 관리자 API를 사용한다.
- 개발 DB에서는 124개·77,019,754 bytes, 개수·용량 차이 0으로 reconcile을 확인했다. 운영 볼륨은 운영 수치로 별도 확인해야 한다.
### CSP 관찰
v1.10.88은 `base-uri`, `object-src`, `frame-ancestors`만 강제한다. 광고 코드, 사용자 사이트 코드, 외부 이미지·미디어·iframe에 영향을 줄 수 있는 출처 정책은 계속 Report-Only이므로 기존 광고와 사용자 코드 실행을 차단하지 않는다. 전체 HTML 문서 `/pages/**`는 강제 CSP에서 제외하고 Report-Only 보고만 유지한다.
배포 후 `csp_violation_aggregates`의 `policy_version='compat-v1'`, `effective_directive`, `blocked_origin`, `document_path`, `user_agent_family`, `violation_count`, `last_seen_at`을 관찰한다. URL 쿼리·fragment·차단 자산 경로와 User-Agent 원문은 저장되지 않는다. 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다.
---
## 로컬 개발
@@ -68,7 +109,7 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
```
> 지난 버전 마이그레이션을 아직 적용하지 않았다면 `062_site_settings_display_counts.sql`부터 `065_public_search_index.sql`까지 순차 적용되어야 한다. 일반 배포에서는 `npm run db:migrate:prod` 또는 NAS 컨테이너 내부 마이그레이션 명령이 이 순서를 자동 처리한다.
> 지난 버전 마이그레이션을 아직 적용하지 않았다면 `062_site_settings_display_counts.sql`부터 `069_media_catalog.sql`까지 순차 적용되어야 한다. 앱 재기동 전에 운영 DB 마이그레이션을 먼저 완료한다. 일반 배포에서는 `npm run db:migrate:prod` 또는 NAS 컨테이너 내부 마이그레이션 명령이 이 순서를 자동 처리한다.
### 확인 주소
@@ -213,7 +254,7 @@ docker compose --env-file .env.production up -d --build
- 로컬 개발: 43080
- NAS Docker 외부: 43080
- 컨테이너 내부: 3000
- PostgreSQL 외부: 43081
- PostgreSQL 호스트 로컬 전용: `127.0.0.1:43081`
- Docker 내부 네트워크 기본값: `10.250.50.0/24`
- HTTPS: 3001 (SSL 설정 시)
@@ -232,6 +273,7 @@ docker compose --env-file .env.production up -d --build
- NAS 운영: `.env.production`의 `DATABASE_URL`
- 로컬 개발 예시: `postgres://sori_studio:비밀번호@127.0.0.1:43081/sori_studio`
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
- `DB_BIND_ADDRESS` 기본값은 `127.0.0.1`이다. 특별한 관리망 요구가 없다면 `0.0.0.0`으로 변경하지 않는다.
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
- `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB에 owner/admin이 없는 최초 관리자 생성에만 사용한다. 같은 이메일의 일반 회원이 이미 있으면 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준으로 갱신한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다.
@@ -246,6 +288,7 @@ docker compose --env-file .env.production up -d --build
- 스키마 변경은 `db/migrations/`의 SQL 파일로 관리한다. 버전별 변경 내역은 `docs/update.md`를 참고한다.
- 로컬: `npm run db:migrate:dev`
- NAS 운영: `sh scripts/migrate-production-db.sh status` → `migrate`
- 각 SQL 파일 실행과 `schema_migrations` 이력 기록은 같은 `psql --single-transaction`에서 처리되며 오류 시 함께 롤백된다.
- `schema_migrations`가 없는 기존 운영 DB에서 `posts` 테이블이 감지되면 `migrate`는 001부터 자동 실행하지 않는다. 현재 코드 기준 최신 DB라면 최초 1회 `sh scripts/migrate-production-db.sh baseline`으로 기존 파일을 적용 완료로 기록한다. 특정 번호까지만 기록하려면 예: `sh scripts/migrate-production-db.sh baseline 031`.
### 이메일 인증(Resend, 선택)

View File

@@ -1,3 +1,49 @@
## 2026-07-22 v1.10.88 — 개발 미디어 카탈로그를 database로 고정하고 임시 전환 UI를 제거한다
개발에서 reconcile·shadow·database 비교가 끝난 뒤에는 런타임 버튼으로 모드를 바꾸지 않는다. 임시 전환 패널과 `catalog-mode`·`catalog-status` API, 프로세스 메모리 덮어쓰기를 제거하고 `MEDIA_CATALOG_MODE` 환경 변수만 사용한다. 개발 env는 `database`로 고정한다. 운영도 같은 계약으로 가되, 배포 시 마이그레이션과 안전 reconcile을 먼저 끝내고 나서 `database`로 고정한다. DB 조회가 준비되지 않으면 파일시스템 목록으로 대체하는 fallback은 유지한다.
## 2026-07-21 v1.10.88 — 검증·브라우저 정책·미디어 인덱스는 운영 데이터와 단계적으로 분리한다
API와 브라우저 회귀 테스트가 개발 DB와 실제 업로드 디렉터리를 공유하면 테스트 게시물·파일이 남거나 운영에 가까운 데이터를 훼손할 수 있다. Playwright는 실행마다 이름이 `_test`로 끝나는 별도 PostgreSQL 컨테이너와 임시 업로드 루트만 사용하고 종료 시 볼륨과 파일을 제거한다. 테스트 결과를 비결정적으로 만드는 Export worker와 추천 사이트 파비콘 cron은 테스트 환경에서 끄고, DB 상태를 순서대로 검증하는 현재 시나리오는 worker 1개로 직렬 실행한다.
CSP는 안전한 지시어를 즉시 강제하되 기존 사용자 사이트 코드, 광고, 외부 미디어를 한 번에 차단하지 않는다. `base-uri`, `object-src`, `frame-ancestors`만 최소 강제 정책으로 분리하고 실행·스타일·이미지·프레임 출처는 Report-Only에서 관찰한다. 완전한 HTML 문서를 의도적으로 제공하는 `/pages/**`는 기존 콘텐츠의 `<base>`와 독립 자산 구성을 보존하기 위해 강제 정책에서 제외하고 보고만 수집한다. 보고 API는 인증 없는 브라우저 입력이므로 크기·형식·IP별 빈도를 제한하고, URL 원문 대신 문서 경로·차단 origin·지시어·브라우저 계열만 집계해 호환성 판단에 필요한 정보만 남긴다.
미디어 파일의 실체와 최종 제공 책임은 계속 파일시스템에 두고, PostgreSQL 카탈로그는 검색·필터·사용처·페이지네이션을 빠르게 제공하는 인덱스로 둔다. `filesystem`을 기본값으로 유지한 뒤 안전 reconcile로 파일을 변경하지 않고 카탈로그와 참조를 채우며, `shadow`에서 두 결과의 개수·용량 차이를 관찰한 후에만 `database`로 전환한다. DB 모드도 reconcile 미실행, 미완료 행, 개수·용량 불일치 또는 조회 오류가 있으면 파일시스템으로 되돌아가므로 카탈로그 장애가 미디어 관리 중단으로 이어지지 않는다. 앱이 관리하는 업로드·생성·변경·삭제와 콘텐츠 참조에는 최선 노력 dual-write를 적용하고, 외부에서 사라진 파일은 reconcile 시 삭제하지 않고 `missing`으로 표시해 운영자가 상태를 확인할 수 있게 한다.
## 2026-07-21 v1.10.87 — 독립 편집 영역은 저장 성공 범위를 서로 침범하지 않는다
사이트 설정은 여러 카드를 동시에 편집할 수 있지만 카드 하나를 저장할 때 폼 전체를 PUT하면, 다른 카드에 아직 저장하지 않은 입력이나 다른 요청이 먼저 반영한 값을 오래된 화면 값으로 되돌릴 수 있다. 각 카드는 자신이 담당하는 필드만 PATCH하고 서버도 전달된 DB 컬럼만 원자적으로 갱신하도록 변경한다. 성공 응답도 해당 카드 필드에만 반영해 화면 안의 다른 미저장 입력을 보존한다.
게시물 초안 자동 저장은 편의를 위한 기능이므로 실패했는데도 이탈을 허용하면 안 된다. 이탈 직전 저장이 실패하거나 이미 저장 중이거나 유효한 payload를 만들 수 없으면 현재 화면에 남기고, 페이지 편집도 같은 미저장 이탈 확인을 사용한다. 저장 요청 중 사용자가 추가로 입력한 페이지 값은 요청에 실제로 보낸 payload와 비교해 남은 변경으로 유지한다.
게시물 HTML 블록은 관리자 입력이라도 Import 파일이나 탈취된 관리자 세션을 통해 실행성 코드가 저장될 수 있다. 정규식으로 위험 문자열 몇 개를 지우는 방식은 깨진 태그, 문자 참조로 우회한 URL, SVG 계열 속성을 HTML parser처럼 해석할 수 없다. 따라서 표·문단·목록·기본 이미지처럼 본문에 필요한 태그와 속성만 허용하는 `sanitize-html` parser를 사용하고, 완전한 HTML 문서를 의도적으로 제공하는 고정 페이지 렌더링과는 정책을 분리한다.
일반 미디어 업로드도 Import와 같은 원칙으로 요청 본문 전체를 메모리에 올리지 않는다. 파일은 업로드 디렉터리 안의 요청별 임시 폴더로 스트리밍하고 파일 수·전체 용량·종류별 용량을 읽는 동안 제한한 뒤, 검증된 파일만 hard link의 배타 생성으로 최종 이름에 옮긴다. 이렇게 하면 같은 이름의 동시 업로드가 기존 파일을 덮어쓰지 않고 실패한 요청의 부분 파일도 한곳에서 정리할 수 있다.
미디어 목록은 디스크 파일과 모든 콘텐츠 사용처를 결합하므로 페이지 번호만 바뀌어도 동일한 전체 스캔을 반복할 이유가 없다. 한 프로세스 안에서 30초 결과 캐시와 진행 중 Promise를 공유하고 파일 변경 API가 캐시를 즉시 무효화한다. Export 작업은 프로세스 메모리 Set만으로 중복을 막지 않고 작업 ID별 PostgreSQL advisory lock을 실행 연결이 끝날 때까지 유지한다. 서버 시작과 30초 주기 worker가 `queued`·중단된 `processing` 작업을 다시 발견하므로 재시작 복구와 다중 인스턴스 단일 실행을 함께 보장한다.
공개 URL의 존재 여부와 화면 상태는 같은 콘텐츠 정책을 따라야 한다. 등록되지 않은 태그는 게시물이 0개인 유효 태그와 다르므로 404로 응답하고, 멤버십 본문을 볼 수 있는 회원은 그 글의 댓글도 같은 권한으로 조회·작성·좋아요할 수 있게 한다. 라우터가 같은 댓글 컴포넌트를 재사용할 때는 slug 변경을 요청 경계로 삼아 이전 입력과 지연 응답을 폐기한다.
어나운스 바 높이는 문구 줄바꿈과 화면 폭에 따라 달라질 수 있으므로 57px 고정값으로 사이드바 위치를 잡을 수 없다. `SiteTopChrome`이 ResizeObserver로 계산한 `--site-top-chrome-height`를 모바일 왼쪽 메뉴와 데스크톱 양쪽 사이드바의 top·뷰포트 높이에 공통 적용한다.
PostgreSQL은 앱 컨테이너가 Docker 내부 네트워크로 접근하므로 호스트 전체 인터페이스에 포트를 공개할 필요가 없다. 로컬 관리 도구 접근은 유지하되 기본 바인딩을 `127.0.0.1`로 제한한다. 마이그레이션은 SQL 파일만 적용되고 이력 INSERT가 실패하거나 그 반대가 되지 않도록 두 작업을 같은 psql 단일 트랜잭션으로 실행한다.
빌드 성공은 실행 코드의 문법과 번들 가능성만 확인할 뿐 보안 정책의 입력·출력은 검증하지 않는다. Node 내장 테스트 러너를 별도 `npm test`로 두고 HTML sanitizer 우회 사례와 multipart 스트리밍 제한부터 회귀 테스트를 시작한다. 브라우저 방어는 기존 사용자 코드·광고 호환성을 깨지 않도록 CSP를 Report-Only로 먼저 관찰하고, `nosniff`, referrer, frame, 권한 정책은 즉시 적용한다. 공개 통계 수집은 정상 브라우저 빈도보다 충분히 높은 IP별 분당 상한을 두어 단일 출처의 DB 쓰기 증폭을 줄인다.
## 2026-07-21 v1.10.86 — Export·Import ZIP은 원본 품질과 서버 메모리를 분리해 다룬다
게시물 Export의 목적은 이미지 품질을 다시 낮추는 것이 아니라 Markdown과 원본 자산을 하나의 이동 가능한 ZIP으로 묶는 것이다. 기존 방식은 모든 자산을 Buffer로 읽고 동기 deflate 결과와 최종 ZIP까지 메모리에 함께 보관해, 설정 가능한 2GB 한도가 실제 서버 메모리 한도로 이어질 수 있었다. Export는 디스크 출력 스트림으로 직접 생성하고 이미 압축된 이미지·영상·첨부 파일은 ZIP `store` 방식으로 담아 원본 바이트를 그대로 유지한다.
Import도 multipart 전체와 압축 해제된 모든 엔트리를 메모리에 보관하지 않는다. 업로드 ZIP은 크기 제한을 적용하며 임시 파일로 스트리밍하고, 중앙 디렉터리의 엔트리 수·개별 크기·총 비압축 크기·압축률을 먼저 검증한다. Markdown은 총 50MB 안에서만 읽고 자산은 필요한 엔트리만 업로드 경로로 스트리밍해 ZIP 폭탄과 메모리 급증을 함께 제한한다.
## 2026-07-21 v1.10.85 — 로그인 세션은 비밀번호와 함께 폐기하고 공개 목록은 요약만 전달한다
서명된 쿠키의 만료 시각만 확인하면 비밀번호를 바꾼 뒤에도 탈취된 기존 세션이 남을 수 있다. 회원 행에 `session_version`을 두고 로그인 토큰에 발급 당시 버전을 포함해, 비밀번호 변경·재설정·관리자 직접 변경 시 버전을 증가시키고 이후 요청에서 DB 값과 비교한다. 배포 시점의 기존 세션은 버전 1로 호환하되 비밀번호가 바뀐 계정의 이전 토큰만 무효화한다.
로그인 실패 제한은 현재 단일 NAS 앱 프로세스 운영을 기준으로 회원·관리자 범위를 분리하고, 계정과 IP를 함께 본다. 계정 원문과 IP 원문은 메모리 키에 남기지 않고 해시하며 15분 동안 계정 5회 또는 IP 20회 실패 시 429를 반환한다. 다중 인스턴스로 확장할 때는 공유 저장소 기반 제한으로 교체한다.
공개 게시물 목록은 화면에 제목·썸네일·요약만 필요하므로 전체 본문을 응답하지 않는다. 서버가 최대 4,000자의 본문 앞부분에서 표시용 요약을 만들고 기본 목록 개수 상한을 항상 적용한다. RSS처럼 본문 기반 요약이 필요한 내부 호출만 명시적으로 본문 조회를 허용한다.
## 2026-07-20 v1.10.81 — AdSense 홈 요약은 API totals와 응답 통화를 기준으로 본다
AdSense Management API의 보고서 응답은 차원 없이 요약을 요청할 때 `rows`보다 `totals`가 실제 합계를 담는 기준이 된다. 관리자 대시보드가 `rows[0]`만 읽으면 공식 AdSense 화면에 수익이 있어도 0으로 보일 수 있으므로, 보고서 합계는 `ReportResult.totals`를 먼저 읽고 필요한 경우에만 첫 row로 보완한다.

View File

@@ -35,7 +35,7 @@
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
| lib/markdown-iframe-embed.js | 게시물 본문 iframe 블록의 코드 판별과 안전 속성 추출 |
| lib/markdown-html-block.js | 게시물 본문 HTML 블록의 실행성 태그·속성 제거 |
| lib/markdown-html-block.js | 게시물 본문 HTML 블록의 parser 기반 허용 태그·속성 sanitizer, 실행 URL·인라인 style 제거와 새 창 링크 rel 보정 |
| lib/markdown-callout.js | 인용 막대 색상·콜아웃 배경색 옵션, 선언 줄 파싱·직렬화 |
| lib/brand-color.js | 사이트 브랜드 컬러 기본값·hex 검증·정규화 |
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
@@ -46,6 +46,8 @@
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
| lib/analytics-traffic.js | referrer·User-Agent 기반 유입원·디바이스·검색 키워드 축약 분류 |
| lib/csp-report.js | CSP 보고 Content-Type·32KB/20건 제한 상수, payload 검증, 문서 경로·차단 origin·브라우저 계열 최소화 |
| lib/media-catalog.js | 업로드 URL·저장 키 정규화, 미디어 종류·원본/파생 역할 판별, 콘텐츠 미디어 참조 추출, PostgreSQL LIKE 리터럴 이스케이프 |
| lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·사용자 SVG 정리·URL 자동 보정·레거시 JSON 문자열 복구·공개 노출 목록 생성 |
| server/utils/backup-storage-summary.js | 관리자 사이트 설정 백업 현황용 업로드 파일 스캔·원본/자동 생성 이미지/프로필/내보내기 파일 용량 집계 |
@@ -55,6 +57,11 @@
|------|------|
| docs/shortcuts.md | 사이트 단축키, 작성 약어, 슬래시 명령, Markdown 약식 문법 정리 |
| docs/update-log/v1.0.md | 블로그 업데이트 공지 초안(v0.0~v1.0 회고 기록) |
| docs/update-log/v1.1.md | 블로그 업데이트 공지 초안(v1.0 개발 구간 기록) |
| docs/update-log/v1.2.md | 블로그 업데이트 공지 초안(v1.1 개발 구간 기록) |
| docs/update-log/v1.5.md | 블로그 업데이트 공지 초안(v1.2~v1.4 개발 구간 기록) |
| docs/update-log/v1.6.md | 블로그 업데이트 공지 초안(v1.5 개발 구간 기록) |
| docs/update-log/v1.10.md | 블로그 업데이트 공지 초안(v1.6~v1.9 개발 구간 기록) |
## Nuxt 모듈
@@ -74,6 +81,23 @@
| scripts/backfill-post-body-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 본문 표시용 WebP 백필 실행 |
| scripts/backfill-page-cover-thumbnails-production.sh | npm 없는 NAS 호스트에서 앱 컨테이너 내부 Node로 Pages 커버 썸네일 백필 실행 |
| scripts/generate-adsense-token.js | AdSense Management API refresh token 발급과 접근 가능한 계정 이름 확인 |
| scripts/run-playwright-tests.js | API/E2E 프로젝트별 격리 환경 준비·Playwright 실행·정리 |
| scripts/playwright-test-environment.js | `_test` PostgreSQL 컨테이너·임시 업로드 루트·worker 비활성화 환경 구성 |
| scripts/reconcile-media-catalog.js | loopback 비운영 DB만 허용하는 파일 비파괴 미디어 카탈로그 수동 reconcile |
## 테스트
| 파일 | 용도 |
|------|------|
| playwright.config.js | Playwright API/E2E 프로젝트, 단일 worker, 전용 Nuxt 서버·trace 설정 |
| docker-compose.test.yml | 실행마다 생성·폐기하는 PostgreSQL 16 테스트 DB·볼륨·네트워크 |
| tests/markdown-html-block.test.js | HTML sanitizer 단위 회귀 테스트 |
| tests/multipart-file-upload.test.js | multipart 스트리밍·제한 단위 회귀 테스트 |
| tests/csp-report.test.js | CSP URL 최소화·payload 형식 단위 회귀 테스트 |
| tests/media-catalog.test.js | 미디어 URL·역할·참조·LIKE 이스케이프 단위 회귀 테스트 |
| tests/playwright/api/core-api.spec.js | 인증·부분 설정 저장·공개 목록·CSP·미디어 reconcile/fallback·세션 무효화 API 통합 10건 |
| tests/playwright/e2e/admin-guard-and-settings.spec.js | 설정 카드 독립 저장과 미저장 이탈 취소 브라우저 E2E 2건 |
| tests/playwright/e2e/admin-publish.spec.js | 관리자 로그인·게시물 발행·공개 화면 확인 브라우저 E2E 1건 |
## DB 마이그레이션
@@ -90,6 +114,9 @@
| db/migrations/064_posts_admin_pinned.sql | 관리자 글 목록 전용 상단 고정 컬럼·정렬 인덱스 추가 |
| db/migrations/065_public_search_index.sql | 공개 통합 검색용 `search_text` 컬럼·자동 갱신 트리거·`pg_trgm` GIN 인덱스 추가 |
| db/migrations/066_analytics_active_session_ip.sql | 실시간 접속 세션 IP 표시용 `analytics_active_sessions.ip_address` 컬럼 추가 |
| db/migrations/067_users_session_version.sql | 비밀번호 변경 후 기존 로그인 세션 무효화용 `users.session_version` 컬럼 추가 |
| db/migrations/068_csp_violation_aggregates.sql | CSP 정책 버전·문서 경로·지시어·차단 origin·브라우저 계열 위반 집계 테이블 추가 |
| db/migrations/069_media_catalog.sql | `media_metadata` 파일 카탈로그 필드·상태·관계, `media_references`, `media_catalog_state` 추가 |
## 서버 미들웨어
@@ -97,6 +124,10 @@
|------|------|
| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 |
| server/middleware/html-page-renderer.js | HTML 문서 모드 고정 페이지(`/pages/:slug`)를 Nuxt 렌더링 대신 `text/html` 원문으로 응답 |
| server/middleware/security-headers.js | 전역 보안 헤더 적용, `/pages/**` 강제 CSP 제외 결정 |
| server/utils/security-headers.js | 최소 강제 CSP와 사용자 코드·광고 호환 Report-Only·Reporting API 헤더 구성 |
| server/utils/csp-report-request-limit.js | CSP 보고 IP 해시 기준 분당 60회·버킷 상한 제한 |
| server/utils/upload-root.js | 일반 `public/uploads`와 Playwright 전용 임시 업로드 루트 분리 |
| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍, 원본 장기 캐시·파생 이미지 재검증 캐시·조건부 요청 304 지원) |
| server/routes/sitemap.xml.get.js | 공개 sitemap XML(`/sitemap.xml`) |
| server/routes/robots.txt.get.js | 공개 robots.txt(`/robots.txt`, sitemap 위치 안내) |
@@ -105,10 +136,18 @@
| server/routes/rss.get.js | 공개 RSS 2.0 피드 별칭(`/rss`) |
| server/plugins/site-custom-code.js | 공개 Nuxt HTML 응답에 사이트 설정 헤더·푸터 코드 삽입(`/admin`, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 제외) |
| server/utils/post-thumbnail-image.js | 게시물 업로드 이미지의 카드 썸네일·Pages 3:4 커버 썸네일·본문 표시용 1600px/720px WebP·라이트박스용 2560px WebP 이미지 URL·디스크 경로·생성 가능 여부·본문 이미지 추출 규칙 |
| server/utils/media-library.js | 관리자 미디어 파일 스캔·폴더 메타·사용처 계산·삭제/이름 변경, 미디어 참조 전용 게시물·페이지 조회 기반 원본/파생 이미지 사용처 연결 |
| server/utils/media-library.js | 관리자 미디어 파일 스캔·폴더 메타·사용처 계산·삭제/이름 변경, 30초 전체 목록 공유 캐시·동시 요청 단일 실행·파일 변경 시 무효화, 미디어 참조 전용 게시물·페이지 조회 기반 원본/파생 이미지 사용처 연결 |
| server/utils/sitemap.js | 공개 sitemap XML·robots.txt 생성 |
| server/utils/rss-feed.js | 공개 발행글 기반 RSS 2.0 XML 생성, 게시물 이미지 Media RSS 썸네일 출력 |
## 서버 저장소
| 파일 | 용도 |
|------|------|
| server/repositories/csp-report-repository.js | 민감 원문 없이 정규화된 CSP 위반 차원을 PostgreSQL에 누적 집계 |
| server/repositories/media-catalog-repository.js | 미디어 파일·참조 dual-write, DB SQL 목록, 준비 상태 검사, 파일 비파괴 reconcile·missing 표시 |
| server/repositories/content-repository.js | 게시물·페이지·태그·사이트 설정 저장 후 미디어 참조 최선 노력 동기화 |
## 사이트 컴포넌트
| 파일 | 화면 위치 |
@@ -118,9 +157,9 @@
| components/site/SiteAnnouncementBar.vue | 공개 사이트 상단 어나운스 배너(문구·선택 링크·hex 배경색·텍스트 정렬·닫기) |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 사이트 이름 텍스트 브랜드, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운(아바타 없거나 비로그인 시 사람 아이콘), `/`·`SiteSearchModal` |
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금, IME 조합 중 표시값 디바운스 반영·조합 종료 후 최종 검색어 즉시 반영과 공백·Unicode 정규화 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 태그 Categories 아래 공개 `GET /api/navigation`의 `recommended` 카드 목록(수동 썸네일 > 서버 캐시 파비콘 > 외부 프록시 fallback), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+상단 크롬 CSS 변수 기준 높이+내부 무스크롤바 스크롤, `lg` 미만은 같은 CSS 변수 아래의 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 태그 Categories 아래 공개 `GET /api/navigation`의 `recommended` 카드 목록(수동 썸네일 > 서버 캐시 파비콘 > 외부 프록시 fallback), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 설정 기반 사이트 로고 48px 정사각형 고정·SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·오른쪽 사이드 광고를 표시, 게시글 상세 데스크톱은 블로그 소개·Follow 대신 최상단 H1~H3 TOC와 TOC 아래 게시물 사이드 광고(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤, 세로 기준선), About 영역은 비공개, `lg+` 스티키이며 데스크톱 외곽 구분선은 레이아웃 오른쪽 컬럼이 담당 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 설정 기반 사이트 로고 48px 정사각형 고정·SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·오른쪽 사이드 광고를 표시, 게시글 상세 데스크톱은 블로그 소개·Follow 대신 최상단 H1~H3 TOC와 TOC 아래 게시물 사이드 광고(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤, 세로 기준선), About 영역은 비공개, `lg+` 스티키 top·높이는 상단 크롬 CSS 변수를 사용하며 데스크톱 외곽 구분선은 레이아웃 오른쪽 컬럼이 담당 |
| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단 광고 슬롯 |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
@@ -128,7 +167,7 @@
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(`featuredImageThumbnail` 우선, 없으면 대표 이미지 원본 사용. 대표 이미지 없으면 모바일 3줄·`sm` 이상 4줄 제목 placeholder 표시, 이미지 유무와 관계없이 썸네일 영역 크기 고정) |
| components/site/SitePagination.vue | 공개 목록 공용 번호형 페이지네이션, 이전·다음 화살표와 첫/끝/현재 주변 페이지 직접 이동, 많은 페이지의 `...` 접기, 모바일 가로 스크롤 |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 기본 정렬 최신순(최신순·인기순·오래된순 선택), 입력값 기반 등록 버튼 활성화, 작성자 썸네일/좋아요/상대시간 표시 |
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, slug 변경 시 입력·답글·오류·좋아요 상태 초기화와 지연 응답 폐기, 기본 정렬 최신순(최신순·인기순·오래된순 선택), 입력값 기반 등록 버튼 활성화, 작성자 썸네일/좋아요/상대시간 표시 |
## 관리자 컴포넌트
@@ -139,8 +178,8 @@
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·자동 저장 실패 payload 반복 재시도 차단·자동 저장 실패/진행 중 이탈 차단·신규 임시 슬러그·중복 슬러그 필드 하단 오류 표시·중복 슬러그 중 Publish/Update/최종 저장 비활성화·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택(서버 페이지네이션·파일명/사용처 검색·번호형 페이지 이동·작은 파생 이미지 우선 미리보기·고정 높이 모달), 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 저장 payload 기준 미저장 비교·라우트/브라우저 이탈 확인, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 라이브러리 탭은 `/admin/api/media?paged=1` 서버 페이지 응답·파일명/사용처 제목 검색·번호형 페이지 이동, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·갤러리 선택 순서 배지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 검색 결과 수와 무관한 고정 높이 패널, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/HTML 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
@@ -186,7 +225,7 @@
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
| components/content/ProseHeaderCard.vue | 헤더 카드 |
| components/content/ProseEmbed.vue | 상단 외부 여백 없이 하단 여백만 두는 YouTube 영상 iframe, X/Twitter 소셜 iframe, Mastodon `/embed` iframe, 제한된 사용자 iframe HTML, 기타 `http(s)` 외부 링크 |
| components/content/ProseHtml.vue | `:::html` 본문 HTML 렌더링, 표 기본 스타일과 실행성 HTML 제거 |
| components/content/ProseHtml.vue | `:::html` 본문 HTML 렌더링, 표 기본 스타일과 허용 목록 sanitizer 적용 |
## 관리자 페이지
@@ -209,7 +248,7 @@
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명 읽기 행, **사이트 정보**(로고 미등록 점선 박스·URL·저작권 읽기 행), **SNS 정보**(아이콘 프리셋+주소 목록형 관리), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·hex 배경색·텍스트 정렬·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), **백업 현황** 텍스트 카드(콘텐츠 수량·미디어 용량·자동 생성 이미지·내보내기 파일·큰 파일), **게시물 내보내기** 독립 카드와 펼침형 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 작업이 있을 때만 표시되는 **최근 내보내기 작업** 별도 카드(준비 완료 배지 숨김·진행 중 진행도·만료일·파일 체크 선택·전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제), **게시물 가져오기** 독립 카드와 펼침형 ZIP 드롭존·적용 버튼·완료 요약·누락 자산 경고 표시, 진행 중 요청 버튼 잠금 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, 카드별 필드만 PATCH하고 성공 필드만 폼에 반영하는 독립 저장, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명 읽기 행, **사이트 정보**(로고 미등록 점선 박스·URL·저작권 읽기 행), **SNS 정보**(아이콘 프리셋+주소 목록형 관리), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·hex 배경색·텍스트 정렬·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), **백업 현황** 텍스트 카드(콘텐츠 수량·미디어 용량·자동 생성 이미지·내보내기 파일·큰 파일), **게시물 내보내기** 독립 카드와 펼침형 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 작업이 있을 때만 표시되는 **최근 내보내기 작업** 별도 카드(준비 완료 배지 숨김·진행 중 진행도·만료일·파일 체크 선택·전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제), **게시물 가져오기** 독립 카드와 펼침형 ZIP 드롭존·적용 버튼·완료 요약·누락 자산 경고 표시, 진행 중 요청 버튼 잠금 |
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
@@ -225,7 +264,7 @@
| pages/post/[slug].vue | 블로그 글 상세, 본문 블로킹 조회 후 완성 화면 진입, 첫 태그가 있을 때만 메타 행에 태그 링크, H1~H3 본문 제목을 오른쪽 TOC 상태로 제공, 공개 회원 로그인 세션의 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록, 사이트 설정 태그 게시물 표시개수 기준 서버 태그 필터·공용 번호형 페이지네이션, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드, 카드 태그 배지와 오른쪽 아래 화살표 링크 |
| pages/tag/[slug].vue | 태그별 글 목록, 존재하지 않는 태그 404, 사이트 설정 태그 게시물 표시개수 기준 서버 태그 필터·공용 번호형 페이지네이션, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드, 카드 태그 배지와 오른쪽 아래 화살표 링크 |
| pages/pages/[slug].vue | 고정 페이지 상세, HTML 문서 모드는 직접 진입 시 서버 미들웨어 원문 응답·클라이언트 내부 이동 시 현재 URL reload로 서버 원문 응답 재진입 |
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, 모바일 OTP 입력창 높이 보정, `POST /api/auth/email-otp/request` |
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
@@ -236,7 +275,7 @@
| 파일 | 기능 |
|------|------|
| server/api/posts.get.js | 공개 게시물 목록 API(`published`, VIP 이상 등급 시 `members`, `limit`·`tag`·`featured`·`paginated` 조회 옵션) |
| server/api/posts.get.js | 공개 게시물 목록 API(`published`, VIP 이상 등급 시 `members`, 기본 개수 상한, 본문 제외 요약 응답, `limit`·`tag`·`featured`·`paginated` 조회 옵션) |
| server/api/posts/[slug].get.js | 공개 게시물 상세 API(`published`, VIP 이상 등급 시 `members`) |
| server/api/pages.get.js | 공개 고정 페이지 목록 API(`published`만, `listed=1`·섹션별 `limit` 조회 옵션) |
| server/api/pages/[slug].get.js | 공개 고정 페이지 상세 API(`published`만) |
@@ -245,6 +284,7 @@
| server/api/search.get.js | 통합 검색 API(`q` 쿼리, 게시물·페이지 `search_text` 인덱스 기반) |
| server/api/site-settings.get.js | 공개 사이트 설정 API |
| server/api/homepage-widget.get.js | gethomepage customapi용 당일·7일·30일·180일·365일·누적·콘텐츠 통계 위젯 API |
| server/api/csp-report.post.js | 32KB·JSON 계열 형식·IP 해시 빈도 제한과 민감 URL 최소화를 적용한 CSP 위반 204 수집 API |
| server/repositories/blog-statistics-repository.js | 공개 게시글·댓글·페이지·태그·회원·누적 방문 통계 집계 |
| server/routes/admin/api/posts/[id]/featured.put.js | 관리자 글 목록 추천 상태 전용 수정 API |
| server/api/navigation.get.js | 공개 네비게이션 API |
@@ -255,7 +295,7 @@
| server/repositories/email-otp-repository.js | `email_otp_challenges` CRUD·검증 |
| server/utils/email-otp.js | OTP 생성·해시 |
| server/utils/resend-mail.js | Resend REST 발송 |
| server/api/auth/login.post.js | 회원 로그인 API |
| server/api/auth/login.post.js | 계정·IP 실패 횟수 제한을 적용한 회원 로그인 API |
| server/api/auth/me.get.js | 회원 세션 조회 API(`isAdmin`, `role` 포함) |
| server/api/auth/logout.post.js | 회원 로그아웃 API |
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
@@ -265,10 +305,10 @@
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
| server/api/auth/account.delete.js | 회원 탈퇴 API(마지막 `owner` 보호, 관리자 세션 함께 정리) |
| server/api/posts/[slug]/comments.get.js | 게시물 댓글 목록 조회 API |
| server/api/posts/[slug]/comments.post.js | 게시물 댓글 작성 API |
| server/api/posts/[slug]/comments/[commentId]/like.post.js | 댓글 좋아요 토글 API |
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
| server/api/posts/[slug]/comments.get.js | 게시물 댓글 목록 조회 API, 회원 등급 기반 멤버십 본문 접근 확인 |
| server/api/posts/[slug]/comments.post.js | 게시물 댓글 작성 API, 일반/멤버십 본문 접근 정책 적용 |
| server/api/posts/[slug]/comments/[commentId]/like.post.js | 댓글 좋아요 토글 API, 일반/멤버십 본문 접근 정책 적용 |
| server/routes/admin/api/auth/login.post.js | 계정·IP 실패 횟수 제한을 적용한 관리자 로그인 API |
| server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API |
| server/routes/admin/api/auth/me.get.js | 관리자 세션 조회 API |
| server/routes/admin/api/posts.get.js | 관리자 게시물 목록 API, 기본 배열 응답과 `paged=true` 페이지 응답 지원 |
@@ -298,9 +338,10 @@
| server/routes/admin/api/media.put.js | 관리자 미디어 파일명 변경 및 단일/복수 폴더 변경 API |
| server/routes/admin/api/media.delete.js | 관리자 미디어 삭제 API |
| server/routes/admin/api/media/thumbnail.post.js | 원본 이미지의 게시물 목록 카드·본문 표시·Pages 커버 썸네일 생성·재생성 API |
| server/routes/admin/api/media/reconcile.post.js | 파일을 변경하지 않고 카탈로그 upsert·참조 백필·누락 파일 `missing` 표시를 수행하는 관리자 수동 API |
| server/routes/admin/api/media-folders.get.js | 관리자 미디어 폴더 목록 API |
| server/routes/admin/api/media-folders.post.js | 관리자 미디어 폴더 생성 API |
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 미디어 원본 업로드 API(이미지·비디오·오디오·문서, 사이트 설정 1회 파일 수·총량 제한, Unicode 원본명 기반 파일명·충돌 시 넘버링, `/uploads/posts` 저장, 성공 시 `media_metadata`를 `미분류`로 기록) |
| server/routes/admin/api/uploads.post.js | 관리자 게시물·페이지용 다중 미디어 원본 업로드 API(임시 디스크 스트리밍, 수신 중 파일 수·종류별/전체 용량 제한, Unicode 원본명 기반 hard link 배타 이동·충돌 시 넘버링, 실패 파일 정리, `/uploads/posts` 저장, 성공 시 `media_metadata`를 `미분류`로 기록) |
| server/routes/admin/api/member-avatar.post.js | 관리자 새 회원 생성 전 썸네일 사전 업로드 API(`/uploads/members/avatars` 저장, WebP 변환·1:1 크롭) |
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API(`tagType`, `q`, `limit` 검색 옵션) |
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
@@ -309,6 +350,7 @@
| server/routes/admin/api/tags/[id].delete.js | 관리자 태그 삭제 API |
| server/routes/admin/api/tags/reorder.put.js | 관리자 메인 태그 순서 일괄 저장 API |
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
| server/routes/admin/api/settings.patch.js | 관리자 사이트 설정 허용 필드 검증·부분 수정 API |
| server/routes/admin/api/settings/backup-summary.get.js | 관리자 사이트 용량 API(콘텐츠 수량 DB 집계, `public/uploads` 파일 용량 스캔) |
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
| server/routes/admin/api/settings/logo.post.js | 관리자 사이트 로고 업로드 API(`/uploads/system/logo-YYYYMM-*.webp`, `/uploads/system/favicon-YYYYMM-*.png` 생성, `시스템` 미디어 메타 저장, DB 반영 없이 URL 반환) |
@@ -319,6 +361,7 @@
| server/routes/admin/api/navigation/[id]/favicon-refresh.post.js | 추천 사이트 파비콘 수동 갱신 API |
| server/utils/recommended-site-favicon.js | 추천 사이트 파비콘 fetch·`/uploads/system/recommended-favicon-*.png` 저장·일괄 갱신 |
| server/plugins/recommended-favicon-cron.js | 매월 1일 03:00(KST) 추천 사이트 파비콘 자동 갱신 |
| server/plugins/post-export-worker.js | 서버 시작·30초 주기 게시물 Export 대기/중단 작업 복구 실행 |
| scripts/refresh-recommended-favicons.js | 추천 사이트 파비콘 수동 일괄 갱신 CLI(`npm run navigation:refresh-recommended-favicons`) |
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
| server/routes/admin/api/members.post.js | 관리자 멤버 생성 API |
@@ -329,9 +372,10 @@
| server/routes/admin/api/members/[id]/ownership-transfer.post.js | 소유자 권한 이전 API(대상→소유자, 요청자→관리자) |
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
| server/utils/admin-auth.js | DB 세션 버전을 포함한 관리자 세션 쿠키 인증 유틸리티 |
| server/utils/analytics-admin-exclusion.js | 관리자 세션 요청의 공개 통계 수집 제외 판별 |
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
| server/utils/member-auth.js | DB 세션 버전 검증을 포함한 회원 세션 쿠키 인증 유틸리티 |
| server/utils/login-attempt-limit.js | 회원·관리자 로그인 계정·IP별 15분 실패 횟수 제한 유틸리티 |
| server/utils/request-ip.js | 프록시 헤더 포함 요청 IP 조회 유틸리티 |
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
| server/utils/member-avatar-upload.js | 회원 썸네일 공통 업로드 검증·Unicode 원본명 기반 파일명·WebP 변환·중앙 1:1 크롭·저장 유틸 |
@@ -346,18 +390,20 @@
| server/utils/media-library.js | 업로드 미디어·Unicode 파일명 정리·논리 폴더(`미분류`, 예약 `썸네일`)·`avatarOwner` 부착·아바타 삭제/이름변경 차단·게시물 카드·본문·Pages 커버 썸네일 사용처 연결 |
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
| server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·ZIP 생성 워커 저장소 |
| server/repositories/post-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter·자산 재매핑·게시물 생성) |
| server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·원본 자산 store 방식 스트리밍 ZIP 생성, PostgreSQL advisory lock 기반 다중 인스턴스 단일 worker 저장소 |
| server/repositories/post-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter 제한 읽기·자산 엔트리 스트리밍·URL 재매핑·게시물 생성) |
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
| server/repositories/comment-repository.js | 댓글 조회/생성/좋아요 저장소, 공개·멤버십 게시물 접근 조건 통합 |
| server/repositories/analytics-repository.js | 방문·게시물·페이지·유입 통계 집계·관리자 요약/날짜 상세 조회·방문자 해시 보관 정리 |
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·관리자 세션 제외·기록 |
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·관리자 세션 제외·IP 해시 분당 상한·기록 |
| server/utils/adsense-report.js | AdSense OAuth access token 발급·보고서 조회·대시보드 요약 생성 |
| server/utils/zip-writer.js | 게시물 Export ZIP 생성 유틸리티 |
| server/utils/zip-reader.js | 게시물 Import ZIP 읽기 유틸리티 |
| server/utils/zip-writer.js | `archiver` 기반 게시물 Export ZIP 디스크 스트리밍 생성 유틸리티 |
| server/utils/zip-reader.js | `unzipper` 기반 게시물 Import ZIP 중앙 디렉터리·엔트리 제한 검증 및 스트림 유틸리티 |
| server/utils/multipart-file-upload.js | `busboy` 기반 단일·다중 multipart 파일 수·개별/전체 용량 제한과 임시 디스크 스트리밍 유틸리티 |
| server/api/analytics/pageview.post.js | 공개 통계 수집 API |
| server/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API |
| server/utils/analytics-heartbeat-input.js | heartbeat 검증·관리자 세션 제외·기록 |
| server/utils/analytics-heartbeat-input.js | heartbeat 검증·관리자 세션 제외·IP 해시 분당 상한·기록 |
| server/utils/analytics-request-limit.js | 공개 통계 pageview·heartbeat 인메모리 IP 해시 윈도 제한 |
| server/routes/admin/api/analytics/summary.get.js | 관리자 통계 요약 API |
| server/routes/admin/api/analytics/posts.get.js | 관리자 인기 게시물 API |
| server/routes/admin/api/analytics/posts/[id].get.js | 관리자 게시물별 통계 API |
@@ -418,7 +464,7 @@
| 파일 | 기능 |
|------|------|
| package.json | Nuxt 실행 스크립트와 의존성 |
| package.json | Nuxt 실행·문법 검사·Node 회귀 테스트·전체 verify 스크립트와 의존성 |
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 |
| tailwind.config.js | Tailwind 테마 설정 |
| assets/css/main.css | 전역 스타일, 공개 배경·사이드바 배경 통일 기준, 관리자 라이트 테마 격리(`admin-layout`), 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과, 게시물 카드 제목 공통 인터랙션(`post-title-link`), 이동 중 클릭 잠금(`site-navigating`) |
@@ -427,9 +473,11 @@
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
| middleware/pages-html-document.global.js | HTML 문서 모드 고정 페이지(`/pages/:slug`) 클라이언트 소프트 이동을 전체 페이지 이동으로 전환(뒤로가기 잔상 방지) |
| scripts/dev-server.js | 로컬 개발 서버 링크 요약 출력 |
| scripts/migrate-database.js | 로컬·NAS DB 마이그레이션 적용/상태/baseline 실행 |
| scripts/migrate-database.js | 로컬·NAS DB 마이그레이션 적용/상태/baseline, 파일 실행+이력 기록 단일 트랜잭션 |
| scripts/migrate-development-db.js | 기존 로컬 개발 DB 마이그레이션 명령 호환 래퍼 |
| scripts/migrate-production-db.sh | npm 없는 NAS 호스트용 운영 DB 마이그레이션 적용/상태/baseline 실행 |
| .env.example | 환경 변수 예시 |
| Dockerfile | NAS 운영 이미지 빌드, `.output/server` 기반 앱 런타임과 이미지 백필 스크립트 포함 |
| docker-compose.yml | NAS 컨테이너 실행 초안 |
| tests/markdown-html-block.test.js | 게시물 HTML sanitizer 공격 우회·허용 HTML 회귀 테스트 |
| tests/multipart-file-upload.test.js | 다중 multipart 디스크 스트리밍·파일 크기 제한 회귀 테스트 |
| docker-compose.yml | NAS 컨테이너 실행 초안, PostgreSQL 호스트 기본 loopback 바인딩 |

View File

@@ -10,6 +10,14 @@
- **원격 저장소**: https://git.sori.studio/zenn/sori.studio.git
- **스타일**: Tailwind 엔트리는 `assets/css/main.css` 한 곳(`nuxt.config`의 `tailwindcss.cssPath`)이며, `tailwind.config.js`의 `content`가 Vue·composables·modules·plugins를 스캔한다.
### 테스트 계층
- `npm test`와 `npm run test:unit`은 Node 내장 테스트 러너로 `tests/*.test.js` 단위·회귀 테스트를 실행한다.
- `npm run test:api`는 Playwright API 프로젝트 10건을, `npm run test:e2e`는 Desktop Chrome 프로젝트 3건을 실행한다. Playwright는 `workers: 1`, 직렬 실행, 로컬 `http://127.0.0.1:43100` 전용 Nuxt 서버를 사용한다.
- Playwright 실행기는 `docker-compose.test.yml`의 PostgreSQL 16 컨테이너를 매번 새 볼륨으로 생성하고 이름이 `_test`로 끝나는 `sori_studio_test` DB만 사용한다. 업로드는 시스템 임시 디렉터리의 `SORI_TEST_UPLOAD_ROOT`에만 저장하고 테스트 종료 시 DB 볼륨과 임시 파일을 제거한다.
- Playwright 환경에서는 `PLAYWRIGHT_TEST=1`, `NUXT_POST_EXPORT_WORKER=0`, `NUXT_RECOMMENDED_FAVICON_CRON=0`을 적용해 백그라운드 worker가 테스트 상태를 바꾸지 않게 한다.
- `npm run verify`는 문법 점검, 단위 테스트, 프로덕션 빌드를 실행한다. `npm run verify:ci`는 그 사이에 API와 E2E 테스트까지 포함한 전체 검증 명령이다.
---
## 화면 구조
@@ -35,6 +43,7 @@
- 브라우저에서는 `localStorage.MENU_STATE`에 `open` 또는 `closed` 저장
- 닫힘 상태에서는 왼쪽 사이드바 폭을 0으로 줄이는 전환 애니메이션을 적용
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
- 모바일 왼쪽 메뉴와 데스크톱 양쪽 고정 사이드바의 top·높이는 `SiteTopChrome`이 측정한 `--site-top-chrome-height`를 사용해 어나운스 바 줄바꿈·표시 여부가 바뀌어도 헤더 아래에 맞춘다.
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
- 통합 검색 입력은 한글 등 IME 조합 중에도 입력창에 표시되는 현재 값을 디바운스로 검색에 반영하고, 조합 종료 시 최종 입력값을 즉시 검색한다. 검색어는 앞뒤 공백 제거, 연속 공백 단일화, Unicode NFC 정규화를 거쳐 태그·게시물·페이지 검색에 사용한다.
@@ -52,6 +61,14 @@
- 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리한다. 첫 페인트 전 `lib/site-theme-init.js` 인라인 스크립트가 테마를 적용해 시스템 다크·저장 라이트 불일치 시 깜빡임을 줄인다. 공개 페이지 로딩 중에는 `#site-splash`에 캐시된 로고 이미지 URL(`SITE_BRAND_LOGO_URL`, localStorage) 또는 사이트 제목(`NUXT_PUBLIC_SITE_TITLE`)을 잠깐 표시하고, 앱 마운트 후 `site-app-ready`로 숨긴다. `site_settings.logo_text`(기본 `井`)는 **이미지 로고가 없을 때** 헤더·사이드바에 쓰는 짧은 기호이며 localStorage·스플래시와는 별개다.
- Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 라인으로 구분한다. 사이드바 자체 배경은 라이트/다크 모두 기본 화면 배경(`--site-bg`)과 통일하고, 내부 카드형 요소만 패널 배경을 사용한다.
### 응답 보안 헤더
- 모든 응답은 `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN`, 카메라·마이크·위치 권한 차단 `Permissions-Policy`를 적용한다.
- 강제 CSP는 `base-uri 'self'`, `object-src 'none'`, `frame-ancestors 'self'`만 적용한다. 전체 HTML 문서의 자체 `<base>`·자산 구성을 보존해야 하는 `/pages/**` 응답은 강제 CSP에서 제외한다.
- 호환 정책은 모든 경로에서 `Content-Security-Policy-Report-Only`로 유지한다. 사용자 사이트 코드·광고·외부 미디어를 차단하지 않은 채 script/style/img/font/media/frame/connect/worker/manifest 위반을 `/api/csp-report`로 보고하며 `Reporting-Endpoints`와 `Report-To`도 같은 origin 수집 주소를 가리킨다.
- `POST /api/csp-report`는 `application/csp-report`, `application/reports+json`, `application/json`, `application/*+json`만 허용하고 요청 본문은 32KB, Reporting API 배열은 최대 20건으로 제한한다. IP 원문을 보관하지 않는 해시 키 기준으로 분당 60회까지만 받는다.
- CSP 위반은 정책 버전, 쿼리·fragment를 제거한 문서 경로, 지시어, 경로·쿼리를 제거한 차단 origin, 브라우저 계열만 `csp_violation_aggregates`에 누적한다. 원문 URL과 User-Agent는 저장하지 않으며 저장 실패가 일반 응답을 실패시키지 않는다.
### 홈 Featured (인덱스)
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
@@ -86,6 +103,8 @@
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
- 댓글 정렬은 `인기순`(좋아요 우선), `최신순`, `오래된순`을 제공한다.
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
- 같은 댓글 컴포넌트에서 게시물 slug가 바뀌면 이전 댓글·입력·답글·오류·좋아요 요청 상태를 초기화하고 새 댓글을 조회한다. 이전 slug의 늦은 응답은 현재 상태에 반영하지 않는다.
- 멤버십 게시물 댓글은 해당 본문을 볼 수 있는 VIP 이상 회원만 조회·작성·좋아요할 수 있다.
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
- 공개 게시물 오른쪽 목차는 H1~H3 제목 표시 텍스트에서 링크·굵게·기울임·인라인 코드 같은 마크다운 장식만 제거하고 `~` 같은 일반 제목 문자는 보존한다.
- 공개 Markdown 인라인 렌더러는 단일 루트와 `display: contents`를 사용해 사용자 화면 DOM의 Vue fragment 주석 반복을 줄이고, 기존 인라인 흐름은 유지한다. 링크·굵게·기울임·색상 토큰 내부의 굵게·기울임·링크·인라인 코드·색상·첨자 문법은 재귀적으로 렌더링한다. 여러 줄 문단은 첫 줄과 이후 줄을 분리해 첫 줄에 불필요한 `br` 조건 분기를 두지 않는다.
@@ -118,6 +137,18 @@
- 기존 본문 이미지는 로컬에서 `npm run images:backfill-post-body-thumbnails`, NAS 운영에서 `sh scripts/backfill-post-body-thumbnails-production.sh`로 전체 게시물 본문 URL을 스캔해 누락된 1600px 본문 표시용 WebP, 720px 작은 본문 표시용 WebP, 2560px 라이트박스용 WebP를 생성한다.
- 기존 Pages 진열장 커버 이미지는 로컬에서 `npm run images:backfill-page-cover-thumbnails`, NAS 운영에서 `sh scripts/backfill-page-cover-thumbnails-production.sh`로 홈 노출 페이지의 누락된 3:4 WebP 썸네일을 생성한다.
### 미디어 카탈로그
- 파일시스템은 미디어 파일의 실체와 제공 기준이고 PostgreSQL은 목록 검색·사용처·상태를 위한 카탈로그다. `MEDIA_CATALOG_MODE`는 `filesystem`, `shadow`, `database`를 허용하며 그 외 값·미설정은 `filesystem`으로 처리한다. 개발·전환 완료 환경은 `database`로 고정하고, 운영도 reconcile 완료 뒤 `database`로 고정한다.
- `filesystem`은 디스크 스캔 결과를 반환한다. `shadow`는 파일시스템 결과를 반환하면서 DB 카탈로그의 개수·bytes·종류 차이를 로그로 비교한다. `database`는 DB에서 목록을 반환하되 `media_catalog_state`가 준비되지 않았거나 reconcile 전, `staging`/`error` 행 존재, 개수·bytes 불일치, 쿼리 실패 시 파일시스템으로 fallback한다.
- `media_metadata`는 URL·저장 키·파일명·확장자·종류·역할·용량·수정 시각·원본/파생 관계·생성 출처·`staging|active|missing|deleting|error` 상태를 저장한다. `media_references`는 게시물·페이지·태그·회원·사이트 설정·네비게이션의 URL 참조와 표시 메타를 저장하고, `media_catalog_state` 단일 행은 reconcile 시각과 파일시스템/카탈로그 개수·bytes·준비 상태를 저장한다.
- 관리자 일반 업로드는 multipart 파일명을 UTF-8로 복구한 뒤 NFC 정규화·안전 문자 치환으로 저장한다. macOS NFD 한글 파일명도 `스크린샷-…` 형태로 유지되며, busboy Latin-1 오해로 인한 `á-á-³…` 깨짐을 막는다.
- 앱이 관리하는 업로드·Import·썸네일·아바타·파일명 변경·삭제는 디스크 성공 뒤 카탈로그에 최선 노력으로 dual-write한다. 게시물·페이지·태그·회원·사이트 설정·네비게이션 저장은 해당 대상의 `media_references`를 현재 필드 값으로 교체한다. 보조 카탈로그 기록 실패는 이미 성공한 파일·콘텐츠 작업을 되돌리지 않는다.
- reconcile은 업로드 파일을 삭제하거나 이동하지 않는다. 지원 파일을 스캔해 upsert하고 콘텐츠 참조를 백필하며, 스캔에서 사라진 기존 행은 삭제 대신 `missing`과 `missing_since`로 표시한다. 파일시스템과 active 카탈로그의 개수·bytes가 같고 미완료 행이 없을 때만 `ready=true`로 기록한다.
- 관리자는 `POST /admin/api/media/reconcile`로 수동 reconcile할 수 있다. 로컬 스크립트 `npm run media:reconcile:dev`는 loopback DB, 비운영 환경, 접속 URL과 실제 DB 이름 일치, DB 이름에 `prod|production` 미포함 조건을 모두 확인한 뒤 같은 작업을 실행한다.
- 개발 DB 검증 기준 파일시스템 124개·77,019,754 bytes와 active 카탈로그의 개수·bytes 차이는 모두 0이다.
- DB 모드의 원본 항목 `thumbnailStatus`/`bodyThumbnailStatus`는 파일시스템 모드와 같은 계약이다. `hasThumbnail`은 각각 `*-card.webp`/`*-page-cover.webp`와 `*-body-1600.webp` 존재 여부로만 판별하며, 레거시 `*-body.webp`만 있는 경우는 썸네일 없음·필요 시 `원본` 폴백으로 처리한다. 관리자 그리드 미리보기는 `body-720`이 있을 때만 우선 사용하고, 파생 URL 로드 실패 시 원본 URL로 되돌린다.
### 공개 목록·상세의 발행일 표시
- API의 ISO 8601 `publishedAt`를 공개 UI에서는 로컬 날짜 기준 `YYYY.MM.DD`로 표시한다.
@@ -150,7 +181,7 @@
- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다.
- `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. 게시물에 대표 이미지 또는 OG 이미지가 있으면 절대 URL로 변환해 `media:thumbnail`과 `media:content medium="image"`를 함께 포함한다.
- `/tags` - 태그 전체 목록
- `/tag/:slug` - 태그별 게시물 목록. 사이트 설정 POST 설정의 `tagPostListPageSize` 값 기준으로 서버 태그 필터와 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다. 하단 페이지네이션은 게시물 전체 목록과 같은 번호형 빠른 이동 UI를 사용한다.
- `/tag/:slug` - 태그별 게시물 목록. 사이트 설정 POST 설정의 `tagPostListPageSize` 값 기준으로 서버 태그 필터와 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다. 하단 페이지네이션은 게시물 전체 목록과 같은 번호형 빠른 이동 UI를 사용한다. 등록되지 않은 태그 slug는 빈 목록 200이 아니라 404로 응답한다.
- 태그명에 `/`를 입력하면 태그명은 그대로 저장하고 슬러그에서는 `/`를 `-`로 변환한다. 예: `Xenoblade3/Hero` 태그명은 `xenoblade3-hero` 슬러그로 저장한다.
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
- 태그에 `headerImageUrl`이 있으면 `/tag/:slug` 상단에는 기본 태그명·설명 대신 태그 헤더 이미지를 표시한다. `headerTitle`, `headerText`는 이미지 위 오버레이로 표시하며, 둘 다 비어 있으면 이미지 단독으로 표시한다.
@@ -285,8 +316,9 @@ components/content/
- 관리자 미디어 화면은 미디어 라이브러리·카드 썸네일·본문 이미지·프로필 이미지 탭으로 구분한다. 미디어 라이브러리 탭은 원본 업로드 파일만 표시하고, 게시물 목록용 `thumbs/*-card.webp` 파생 파일은 카드 썸네일 탭에만, 본문용 `thumbs/*-body-1600.webp`·`*-body-720.webp`·`*-body-2560.webp` 파생 파일과 레거시 `*-body.webp`·`*-lightbox.webp` 파일은 본문 이미지 탭에만 표시한다. 게시물 글쓰기의 대표 이미지와 본문 `/이미지`·`/갤러리` 선택 모달은 파생 파일을 숨기고 원본 이미지만 선택하게 한다. 본문 미디어 모달은 업로드 탭을 먼저 열고, 업로드 완료 후 라이브러리에서 새 파일을 자동 선택한다.
- 게시물 생성·수정 API는 `thumbs/*-card.webp` 카드 썸네일 URL을 대표 이미지로 저장하지 못하게 검증한다. 카드 썸네일은 원본 대표 이미지 저장 후 목록 표시용으로만 자동 생성한다.
- 링크·iframe·HTML 블록은 위 블록과 불필요하게 벌어지지 않도록 상단 외부 여백 없이 표시하고, 다음 블록과 구분되는 하단 여백만 유지한다.
- 관리자 미디어 화면의 미디어 라이브러리 탭은 전체·이미지·영상·음악·파일 종류 필터와 미사용 필터를 제공한다. 미사용은 게시물·페이지·사이트 설정·회원 프로필에서 참조되지 않는 항목을 의미한다. 비디오 항목은 브라우저에서 초반 프레임을 캔버스로 추출해 목록 썸네일로 표시하고, 추출 실패 시 `video` placeholder를 유지한다.
- 카드 썸네일 탭의 항목은 원본 대표 이미지가 사용 중이면 `목록 카드 썸네일` 사용처를 가진 것으로 판정한다. 원본 대표 이미지가 사용 중이지만 카드 썸네일 파일이 없으면 원본 항목에 `원본` 배지와 “목록에서 원본 이미지를 불러옴” 상태를 표시한다. 카드 썸네일은 원본과의 연결을 유지하기 위해 폴더 이동과 파일명 변경을 막고, 사용 중이면 삭제도 막는다.
- 관리자 미디어 화면의 미디어 라이브러리 탭은 전체·이미지·영상·음악·파일 종류 필터와 미사용 필터를 제공한다. 미사용은 게시물·페이지·사이트 설정·태그 헤더·네비게이션 추천 사이트 파비콘·회원 프로필에서 참조되지 않는 항목을 의미한다. 비디오 항목은 브라우저에서 초반 프레임을 캔버스로 추출해 목록 썸네일로 표시하고, 추출 실패 시 `video` placeholder를 유지한다. 서버의 디스크 파일·콘텐츠 사용처 전체 스캔 결과는 30초 동안 공유하며 동시 요청은 같은 조회 Promise를 사용한다. 업로드·삭제·이름/폴더 변경·파생 이미지 재생성은 캐시를 즉시 무효화한다.
- 카드 썸네일 탭의 항목은 원본 대표 이미지가 사용 중이면 `목록 카드 썸네일` 사용처를 가진 것으로 판정한다. 원본 대표 이미지가 사용 중이지만 카드 썸네일 파일이 없으면 원본 항목에 `원본` 배지와 “목록에서 원본 이미지를 불러옴” 상태를 표시한다. 페이지 대표 이미지는 페이지 커버 썸네일 상태를 우선하고, 커버 파일이 없으면 같은 `원본` 배지로 표시한다. 카드 썸네일은 원본과의 연결을 유지하기 위해 폴더 이동과 파일명 변경을 막고, 사용 중이면 삭제도 막는다.
- 프로필 이미지 사용 개수 배지는 `avatarOwner`(회원 프로필 연결) 1과 게시물·페이지 등 다른 사용처만 합산한다. 같은 프로필 URL의 `user.avatarUrl` 참조를 사용처 배열에 다시 넣어 중복 합산하지 않는다.
- 문단과 줄바꿈
- 관리자 Markdown-first 에디터에서 Enter는 새 문단(마크다운 한 줄)만 사용한다. Shift+Enter·문단 내 hard break는 지원하지 않는다.
- 공개 본문 렌더러는 마크다운 한 줄을 한 문단으로 렌더링한다(레거시 줄끝 `\\`/공백 2개 표식은 표시 시 제거).
@@ -300,7 +332,7 @@ components/content/
- Signup: `:::signup` ~ `:::` (선택: `title=`, `description=`, `button=`, `placeholder=`)
- Link: `:::link` ~ `:::`. YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외 URL은 외부 링크 카드
- iframe: `:::iframe` ~ `:::`. iframe HTML에서 안전 속성만 추출해 sandbox iframe으로 표시
- HTML: `:::html` ~ `:::`. 표 등 일반 HTML을 표시하되 script, iframe, 이벤트 속성 등 실행성 코드는 제거
- HTML: `:::html` ~ `:::`. 표·문단·목록·기본 이미지 등 허용 목록의 HTML만 표시한다. script, iframe, SVG, form 계열 태그, 이벤트 속성, 인라인 style, 실행 URL은 parser 기반 sanitizer가 제거한다.
- 렌더링: `ProseCallout.vue`, `ProseToggle.vue`, `ProseBookmark.vue`, `ProseSignup.vue`, `ProseEmbed.vue`, `ProseHtml.vue`
---
@@ -315,6 +347,7 @@ components/content/
- NAS 배포 환경은 운영 DB만 연결
- 운영 환경(`NODE_ENV=production`)에서는 `DATABASE_URL` 누락 시 샘플 콘텐츠로 대체하지 않고 서버 오류로 즉시 실패
- Docker Compose는 전용 브리지 네트워크를 사용하며 기본 subnet은 `DOCKER_SUBNET`(`10.250.50.0/24`)으로 관리
- Docker Compose의 PostgreSQL 호스트 포트는 기본 `DB_BIND_ADDRESS=127.0.0.1`에만 바인딩한다. 앱 컨테이너는 호스트 공개 포트가 아니라 `sori-studio-db:5432` 내부 네트워크를 사용한다.
- 운영 DB 접속 정보는 로컬 기본 `.env`에 기록하지 않음
- DB 관리 도구는 CloudBeaver 등을 사용할 수 있도록 접속 정보를 환경별로 분리
@@ -322,6 +355,7 @@ components/content/
- `schema_migrations` 테이블은 적용 완료된 SQL 파일명을 `file_name` 기준으로 기록한다.
- `npm run db:migrate:dev`와 `npm run db:migrate:prod`는 `db/migrations/*.sql` 중 `schema_migrations`에 없는 파일만 순서대로 실행한다.
- 각 마이그레이션 파일 실행과 `schema_migrations` 이력 INSERT는 하나의 `psql --single-transaction` 안에서 처리해 둘 중 하나가 실패하면 함께 롤백한다.
- `sh scripts/migrate-production-db.sh status`는 npm이 없는 NAS 호스트에서도 운영 DB의 적용/대기 파일 목록을 출력한다.
- 기존 운영 DB에 `posts` 테이블은 있지만 `schema_migrations`가 없으면 `sh scripts/migrate-production-db.sh migrate`는 데이터 보호를 위해 001부터 자동 실행하지 않고 중단한다.
- 기존 운영 DB가 현재 코드 기준으로 이미 최신이면 `sh scripts/migrate-production-db.sh baseline`으로 현재 마이그레이션 파일들을 실행 없이 적용 완료로 기록한 뒤 이후 새 파일만 적용한다.
@@ -396,6 +430,7 @@ components/content/
| avatar_url | String | 프로필 썸네일 URL |
| is_admin | Boolean | 관리자 권한 여부 |
| user_role | Enum | 권한 단계(`owner`/`admin`/`vip`/`member`) |
| session_version | Integer | 현재 유효한 로그인 세션 버전. 비밀번호 변경 시 1 증가 |
| last_seen_at | DateTime nullable | 마지막 접속 시각 |
| last_seen_ip | String | 마지막 접속 IP |
| created_at | DateTime | 생성일 |
@@ -585,26 +620,26 @@ components/content/
### 공개 API (`/api/`)
- `GET /api/posts` - 게시물 목록. `limit`, `featured=1`, `tag=:slug` 쿼리로 서버 목록 범위를 제한할 수 있다. `paginated=1&page=N&limit=N`이면 `{ items, pagination }` 형태로 응답하고, 없으면 기존처럼 배열을 반환한다.
- `GET /api/posts` - 게시물 목록. `limit`, `featured=1`, `tag=:slug` 쿼리로 서버 목록 범위를 제한할 수 있다. `limit`을 생략해도 서버 기본 표시 개수 상한을 적용하며 목록 항목에는 전체 `content`를 포함하지 않고 최대 320자의 정리된 `excerpt`만 반환한다. `paginated=1&page=N&limit=N`이면 `{ items, pagination }` 형태로 응답하고, 없으면 기존처럼 배열을 반환한다.
- `GET /api/posts/:slug` - 게시물 상세. 응답에 목록과 같은 발행일 정렬 기준의 인접 글을 `previous`(더 최신)·`next`(더 예전) 객체(`{ title, slug }` 또는 `null`)로 포함해, 게시물 상세 하단 이전·다음 글 이동에 전체 목록 조회 없이 바로 사용한다.
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
- `POST /api/posts/:slug/comments/:commentId/like` - 댓글 좋아요 토글(회원 세션 필요)
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록. 일반 공개 글 또는 현재 회원이 본문 접근 가능한 멤버십 글만 허용한다.
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단). 멤버십 글은 VIP 이상 본문 접근 권한을 함께 확인한다.
- `POST /api/posts/:slug/comments/:commentId/like` - 댓글 좋아요 토글(회원 세션 필요). 대상 게시물의 일반/멤버십 접근 정책을 댓글 목록과 동일하게 적용한다.
- `GET /api/pages` - 공개 고정 페이지 목록. `?listed=1`이면 홈 Pages 진열장에 노출할 공개 페이지만 반환하며, 홈 목록 성능을 위해 `content`는 빈 문자열로 내려준다. `listed=1&limit=N`이면 섹션별 최대 N개만 반환한다.
- `GET /api/pages/:slug` - 고정 페이지 상세
- `GET /api/tags` - 태그 목록. 기본값은 메인 태그(`managed`)만 반환하며, `?tagType=all`은 일반 태그를 포함한 전체 태그 메타를 반환한다. `postCount`는 공개 발행 게시물 기준이다.
- `GET /api/search?q=` - 통합 검색. 태그는 `name`·`slug`, 게시물은 DB 트리거가 갱신하는 `posts.search_text`, 홈 표시 페이지는 `pages.search_text` 기준으로 부분 일치한다. `search_text`는 제목·요약·본문·페이지 섹션/목록 설명에서 업로드 경로와 이미지 마크다운 토큰을 제거한 정규화 문자열이며 `pg_trgm` GIN 인덱스를 사용한다.
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
- `GET /api/homepage-widget` - gethomepage customapi용 공개 블로그 통계. 기본 필드 `title`, `updatedAt`, `todayVisitors`, `totalVisitors`, `todayPageViews`, `totalPageViews`, `visitorsLast7Days`, `visitorsLast30Days`, `visitorsLast180Days`, `visitorsLast365Days`, `pageViewsLast30Days`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `totalPosts`, `totalComments`, `totalPages`, `totalTags`, `totalMembers`, `items[]`를 반환한다. 기간별 방문자는 최근 7·30·180·365일의 일별 순방문자 합계다. `totalVisitors`는 날짜별 순방문자 수의 누적 합계라 동일 방문자가 다른 날짜에 다시 포함될 수 있다. 게시글·댓글·페이지 수는 현재 공개 가능한 항목만 집계한다.
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. 관리자 세션 요청은 저장하지 않고 `{ ok: true }`만 반환한다. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록하되, 관리자 세션은 제외한다.
- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결하며, 관리자 세션 요청은 저장하지 않는다.
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. 관리자 세션 요청은 저장하지 않고 `{ ok: true }`만 반환한다. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록하되, 관리자 세션은 제외한다. 단일 IP 해시는 1분에 최대 120개 쓰기 요청만 처리하고 초과 요청은 성공 응답만 반환한다.
- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결하며, 관리자 세션 요청은 저장하지 않는다. 단일 IP 해시는 1분에 최대 240개 쓰기 요청만 처리한다.
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절)
- `GET /ads.txt` - 사이트 설정의 ads.txt 본문을 `text/plain`으로 반환한다. 값이 없으면 빈 본문을 반환한다.
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status`의 `emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
- `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. 실제 메일 발송이 실패하면 방금 생성한 챌린지는 즉시 삭제한다. 발송 성공 후 같은 이메일·용도의 이전 pending 챌린지는 무효화한다. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요.
- `POST /api/auth/password-reset/confirm` - 본문: `email`, `code`(6자리), `newPassword`(8~32자). `password_reset` OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다.
- `POST /api/auth/login` - 회원 로그인
- `POST /api/auth/login` - 회원 로그인. 15분 동안 동일 계정 5회 또는 동일 IP 20회 실패 시 429와 `Retry-After`를 반환한다.
- `GET /api/auth/me` - 현재 회원 세션 조회(`id`, `username`, `email`, `avatarUrl`, `isAdmin`, `role`). `optional=1` 쿼리를 붙이면 비로그인 상태에서 401 대신 `null`을 반환한다.
- `POST /api/auth/logout` - 회원 로그아웃
- `GET /api/auth/profile` - 회원 설정 조회
@@ -612,7 +647,7 @@ components/content/
- `POST /api/auth/avatar` - 회원 썸네일 이미지 업로드
- `DELETE /api/auth/avatar` - 회원 썸네일 제거
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
- `PUT /api/auth/password` - 회원 비밀번호 변경
- `PUT /api/auth/password` - 회원 비밀번호 변경. 성공 시 `users.session_version`을 증가시키고 기존 회원·관리자 세션을 모두 무효화한다.
- `DELETE /api/auth/account` - 회원 탈퇴. 마지막 `owner` 계정은 삭제할 수 없으며, 탈퇴 성공 시 회원 세션과 관리자 세션 쿠키를 함께 정리한다.
> 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 업로드 시 WebP로 변환하고 `AVATAR_MIN_WIDTH`/`AVATAR_MIN_HEIGHT` 최소 해상도 검증 후 중앙 기준 1:1 정사각형으로 크롭한다.
@@ -627,7 +662,7 @@ components/content/
### 관리자 API (`/admin/api/`)
- `POST /admin/api/auth/login` - 로그인
- `POST /admin/api/auth/login` - 로그인. 회원 로그인과 분리된 계정·IP 실패 횟수 제한을 적용하며 한도를 넘으면 429를 반환한다.
- `POST /admin/api/auth/logout` - 로그아웃
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회. `optional=1` 쿼리를 붙이면 비로그인 상태에서 401 대신 `null`을 반환하며, 관리자 라우트 보호용 기본 호출은 401을 유지한다.
- `GET /admin/api/analytics/summary?period=weekly&days=56` - 통계 요약(오늘 및 최근 7/30/180/365일 방문, 누적 방문자·누적 조회수·공개 게시물 수, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, `collectedSince`, 선택 집계 단위 `trends`). `period`는 `daily`, `weekly`, `monthly`, `yearly`를 사용하며 대시보드는 일일·주간·월간·년간으로 전환한다.
@@ -645,11 +680,11 @@ components/content/
- `PUT /admin/api/posts/:id` - 글 수정
- `DELETE /admin/api/posts/:id` - 글 삭제
- `GET /admin/api/posts/export-jobs` - 게시물 Export 작업 목록
- `POST /admin/api/posts/export-jobs` - 게시물 Export 작업 요청. `dateRangeMode=all|year|month|custom`와 연도·월·날짜 범위, `maxFileSizeBytes`, `chunkSize`를 받아 작업 레코드와 용량 기준 분할 파일 계획을 만들고 백그라운드 ZIP 생성을 시작한다.
- `POST /admin/api/posts/export-jobs` - 게시물 Export 작업 요청. `dateRangeMode=all|year|month|custom`와 연도·월·날짜 범위, `maxFileSizeBytes`, `chunkSize`를 받아 작업 레코드와 용량 기준 분할 파일 계획을 만들고 백그라운드 ZIP 생성을 시작한다. ZIP은 디스크로 스트리밍 생성하며 Markdown만 deflate 압축하고 이미지·영상·첨부 파일은 원본 바이트를 유지하는 store 방식으로 담는다.
- `DELETE /admin/api/posts/export-jobs/:jobId` - 완료·실패한 게시물 Export 작업과 생성 ZIP 파일 삭제
- `POST /admin/api/posts/export-jobs/:jobId/retry` - 실패한 게시물 Export 작업을 준비 완료 파일은 유지한 채 나머지 분할 파일부터 재시도
- `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드
- `POST /admin/api/posts/import` - Export ZIP 파일 Import. multipart `file` 필드의 ZIP을 읽어 Markdown frontmatter를 게시물로 생성하고, ZIP 내부 `images/`·`files/` 자산을 새 업로드 URL로 재매핑한다. 응답은 `importedCount`, `assetCount`, `warningCount`, `warnings`, `posts`를 반환한다.
- `POST /admin/api/posts/import` - Export ZIP 파일 Import. multipart `file` 필드의 ZIP을 최대 300MB까지 임시 파일로 스트리밍하고 Markdown frontmatter를 게시물로 생성하며, ZIP 내부 `images/`·`files/` 자산을 엔트리별 스트림으로 새 업로드 URL에 저장한다. 엔트리는 최대 5,000개, 개별 300MB, 전체 비압축 2GB, 압축률 250배, Markdown 개별 5MB·합계 50MB로 제한한다. 응답은 `importedCount`, `assetCount`, `warningCount`, `warnings`, `posts`를 반환한다.
- `GET /admin/api/pages` - 고정 페이지 목록
- `POST /admin/api/pages` - 고정 페이지 작성
- `GET /admin/api/pages/:id` - 고정 페이지 상세
@@ -669,7 +704,7 @@ components/content/
- `GET /admin/api/media-folders` - 미디어 폴더 목록
- `POST /admin/api/media-folders` - 미디어 폴더 생성
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링, `/uploads/posts/YYYY/MM` 저장). 종류별 파일 크기는 환경 변수 한도를 따르고, 1회 파일 수·전체 용량은 사이트 설정 `uploadMaxFileCount`, `uploadMaxTotalSize`를 따른다.
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 다중 파일 업로드. multipart 전체를 Buffer로 읽지 않고 `/uploads/posts/YYYY/MM` 아래 요청별 임시 폴더에 스트리밍하며, 파일 수·전체 용량·종류별 파일 용량을 수신 중 제한한다. 검증된 파일은 원본명 기반으로 이동하고 같은 이름은 hard link 배타 생성으로 `이름-2`처럼 넘버링해 동시 요청 덮어쓰기를 막는다. 실패 시 해당 요청의 임시 파일과 이미 이동한 파일을 정리한다. 종류별 크기는 환경 변수, 1회 파일 수·전체 용량은 사이트 설정 `uploadMaxFileCount`, `uploadMaxTotalSize`를 따른다.
- `POST /admin/api/member-avatar` - 관리자 멤버 썸네일 사전 업로드(신규·편집 공통, `/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭). 편집 화면에서는 미리보기만 반영하고 **저장**(`PUT /admin/api/members/:id`) 시 회원에 연결한다.
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
- `POST /admin/api/tags` - 태그 생성
@@ -680,8 +715,9 @@ components/content/
- `GET /admin/api/settings` - 사이트 설정 조회(`showPostUpdatedAt`, `postTagLimit`, 업로드 제한, 라이트·다크 홈 커버, 어나운스 바, 사이트 코드 필드 포함)
- `GET /admin/api/settings/backup-summary` - 관리자 사이트 용량 조회. 콘텐츠 수량은 PostgreSQL에서 집계하고, 업로드 파일 용량은 `public/uploads`를 스캔해 이미지·영상·오디오·첨부 파일·보내기로 분류하고, 합계는 표시 항목의 합으로 계산한다.
- `PUT /admin/api/settings` - 사이트 설정 수정(`showPostUpdatedAt`, `postTagLimit`, 업로드 제한, 라이트·다크 홈 커버, 어나운스 바, `signupBlockedUsernames`, `adsTxt`, `customHeadCode`, `customFooterCode` 포함). `postTagLimit`은 1~10 정수만 허용한다. 업로드 최대 파일 수는 1~50, 업로드 전체 용량은 1MB~1GB 정수만 허용한다. `announcementEnabled`가 true이면 `announcementText` 필수. 배경색은 `#15171a`·`#ffffff`·`#ff4f2e`만 허용.
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며, `site_settings` 반영은 `PUT` 저장 시 함께 처리한다.
- `POST /admin/api/settings/logo` - 로고·파비콘 파일만 업로드(`{ logoUrl, faviconUrl }` 반환). `site_settings` 반영은 사이트 정보 저장 시 `PUT`으로 처리한다.
- `PATCH /admin/api/settings` - 전달된 사이트 설정 필드만 검증·수정한다. 설정 카드별 저장은 이 API를 사용해 다른 카드의 미저장 값이나 먼저 완료된 저장 결과를 덮어쓰지 않는다.
- `POST /admin/api/settings/home-cover` - 메인 화면 커버 이미지 파일만 업로드(720px WebP, `{ homeCoverImageUrl }` 반환). 라이트·다크 어느 슬롯에 반영할지는 클라이언트 폼에서 결정하며, `site_settings` 반영은 해당 카드의 `PATCH` 저장 시 함께 처리한다.
- `POST /admin/api/settings/logo` - 로고·파비콘 파일만 업로드(`{ logoUrl, faviconUrl }` 반환). `site_settings` 반영은 사이트 정보 카드의 `PATCH` 저장으로 처리한다.
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버·어나운스 바·사이트 코드 필드 포함)
- `GET /admin/api/navigation` - 네비게이션 항목 목록
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
@@ -753,7 +789,7 @@ components/content/
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
- 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태 문구, Preview, 상태별 주요 액션(Publish / Update·Unpublish / Update·Unschedule), 설정 패널 토글을 제공한다.
- 도구막대 상태 문구는 영어로 표시한다. **초안**: 편집 중 `Draft`, 저장(수동·서버 자동 저장) 진행 중 `Saving...`, 서버 기준과 동일할 때 `Draft - Saved`(신규 작성에서도 첫 `POST` 저장 후에는 `Draft - Saved`를 사용할 수 있다). **즉시 발행**: 공개 URL이 있으면 `Published ↗`를 링크로, 없으면 동일 문구만 표시한다. **예약 발행**: `Scheduled`를 `#2BBA3C`·보통 굵기로 표시하고 마우스 오버 시 영문 한 줄로 예약 시각을 `title` 툴팁에 보여 준다. **멤버십**은 `Members`, **비공개**는 `Private`로 표시한다.
- **초안**(예약 발행 제외)은 입력 변경 후 약 1.2초 디바운스로 서버 자동 저장을 호출한다(슬러그가 유효할 때만). 제목이 비어 있으면 DB/API 저장 시에만 `(제목 없음)` 플레이스홀더를 쓰고, 관리자 폼·목록 API 응답의 `title`은 빈 문자열로 내려 준다. 임시 슬러그(`d`+24자리 hex)는 제목을 직접 수정하기 전까지 제목 입력에 따라 슬러그가 따라가며, 사용자가 슬러그를 직접 고친 뒤에는 자동 연동하지 않는다. 신규 작성 화면 마운트 시 슬러그가 비어 있으면 임시 슬러그를 채운다. 기존 글은 `PUT /admin/api/posts/:id`, **신규 작성**은 첫 저장 시 `POST /admin/api/posts`로 행을 만든 뒤 `replace`로 `/admin/posts/:id` 편집 화면으로 옮긴다. 신규 글 첫 자동 저장 중에는 초안 저장 준비 안내 오버레이를 표시하고, 첫 `POST` 이후 이동 직전 현재 폼의 최신 제목·본문·태그·옵션이 첫 요청 payload와 다르면 생성된 글에 `PUT`으로 한 번 더 반영한다. 저장 요청 중 사용자가 추가 입력한 본문·태그는 성공 응답 시 저장 완료로 오판하지 않고, 저장 완료 후 남은 변경으로 다시 자동 저장한다. 미저장 변경 비교는 태그 표시명 보조값(`tagNames`)처럼 비동기 태그 API 로딩으로만 바뀌는 값을 제외하고 실제 저장 필드 기준으로 판단한다. **이미 발행·예약으로 서버에 반영된 글**은 사이드바 등으로 폼만 초안처럼 바뀌어도 자동 저장하지 않으며, `Update`를 눌렀을 때만 `PUT`으로 반영한다(툴바의 `Publish`/`Update`/`Unpublish`/`Unschedule` 분기도 서버에 반영된 게시 형태를 기준으로 한다). 다른 화면으로 나가기 직전에는 디바운스 대기 중인 초안 변경이 있으면 타이머를 취소하고 한 번에 `POST` 또는 `PUT`으로 플러시한다.
- **초안**(예약 발행 제외)은 입력 변경 후 약 1.2초 디바운스로 서버 자동 저장을 호출한다(슬러그가 유효할 때만). 제목이 비어 있으면 DB/API 저장 시에만 `(제목 없음)` 플레이스홀더를 쓰고, 관리자 폼·목록 API 응답의 `title`은 빈 문자열로 내려 준다. 임시 슬러그(`d`+24자리 hex)는 제목을 직접 수정하기 전까지 제목 입력에 따라 슬러그가 따라가며, 사용자가 슬러그를 직접 고친 뒤에는 자동 연동하지 않는다. 신규 작성 화면 마운트 시 슬러그가 비어 있으면 임시 슬러그를 채운다. 기존 글은 `PUT /admin/api/posts/:id`, **신규 작성**은 첫 저장 시 `POST /admin/api/posts`로 행을 만든 뒤 `replace`로 `/admin/posts/:id` 편집 화면으로 옮긴다. 신규 글 첫 자동 저장 중에는 초안 저장 준비 안내 오버레이를 표시하고, 첫 `POST` 이후 이동 직전 현재 폼의 최신 제목·본문·태그·옵션이 첫 요청 payload와 다르면 생성된 글에 `PUT`으로 한 번 더 반영한다. 저장 요청 중 사용자가 추가 입력한 본문·태그는 성공 응답 시 저장 완료로 오판하지 않고, 저장 완료 후 남은 변경으로 다시 자동 저장한다. 미저장 변경 비교는 태그 표시명 보조값(`tagNames`)처럼 비동기 태그 API 로딩으로만 바뀌는 값을 제외하고 실제 저장 필드 기준으로 판단한다. **이미 발행·예약으로 서버에 반영된 글**은 사이드바 등으로 폼만 초안처럼 바뀌어도 자동 저장하지 않으며, `Update`를 눌렀을 때만 `PUT`으로 반영한다(툴바의 `Publish`/`Update`/`Unpublish`/`Unschedule` 분기도 서버에 반영된 게시 형태를 기준으로 한다). 다른 화면으로 나가기 직전에는 디바운스 대기 중인 초안 변경이 있으면 타이머를 취소하고 한 번에 `POST` 또는 `PUT`으로 플러시한다. 이 저장이 실패하거나 이미 저장 중이거나 유효한 payload를 만들 수 없으면 화면 이동을 중단하고 미저장 확인을 표시한다.
- `Publish`는 **서버에 아직 초안으로만 저장된 글**에만 표시되며 클릭 시 전체 화면 발행 모달을 연다. 초안에서 연 모달의 기본 선택은 **발행·지금 바로**이다. 모달 본문은 뷰포트 세로 중앙에 가깝게 배치하고, 상단에는 제목·닫기만 둔다(도구막대의 `Preview` 버튼은 두지 않는다). 모달에서는 상태(발행/초안)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정하며, 예약 시각은 날짜·시간(KST 표기) 입력을 분리한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다. 발행 저장 요청이 진행되는 동안 다른 화면으로 이동하려 하면 미저장 이탈 확인을 표시한다.
- `Update`는 발행·예약·멤버십·비공개 글에 표시되며, 마지막 저장 이후 변경이 있을 때만 활성화된다(활성 텍스트 `#394047`, 비활성 `#8E9CAC`). `Publish`·활성화된 `Update`·`Unpublish`·`Unschedule`에는 호버 시 배경 `#f1f3f4`를 적용한다.
- `Unpublish`·`Unschedule` 클릭 시 Ghost형 전체 화면 확인 화면을 연다. 발행·예약 시각 요약과 **발행 취소하고 초안으로 되돌리기 →**(또는 예약 취소 문구) 링크를 눌렀을 때만 `status`를 초안으로 되돌리고 `published_at`을 비운 뒤 `PUT`으로 저장한다.
@@ -811,7 +847,7 @@ components/content/
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 저장된 기본 펼침·닫힘 상태로 처음 표시한 뒤 제목·본문을 인라인 편집하며, 공개 화면에서는 chevron 또는 제목 클릭으로 펼침·접힘 시 본문이 애니메이션된다.
- 링크 블록은 `:::link` fenced block 안에 URL을 저장하고 `ProseEmbed`로 렌더링한다. 이미지 파일 URL은 링크 블록이 아니라 이미지 블록으로 저장한다.
- iframe 블록은 `:::iframe` fenced block 안에 iframe HTML을 저장한다. 렌더링 시 `src`, `title`, `allow`, `width`, `height`, `allowfullscreen` 등 제한된 속성만 추출해 sandbox iframe으로 표시한다.
- HTML 블록은 `:::html` fenced block 안에 사용자 HTML을 저장하고 `ProseHtml`로 렌더링한다. 본문 HTML에서는 script, iframe, object, form 계열 태그와 이벤트 속성, `javascript:` URL을 제거한다. iframe은 `/iframe` 블록을 사용한다.
- HTML 블록은 `:::html` fenced block 안에 사용자 HTML을 저장하고 `ProseHtml`로 렌더링한다. 렌더링 직전에 `sanitize-html` parser의 허용 목록으로 표·문단·제목(h2~h6)·목록·인라인 서식·기본 이미지와 안전한 링크 속성만 남긴다. script, style, iframe, SVG, object, form 계열 태그와 이벤트 속성, 인라인 style, `javascript:` 등 비허용 URL scheme을 제거하며, `target="_blank"` 링크에는 `noopener noreferrer`를 추가한다. iframe은 `/iframe` 블록을 사용한다. 고정 페이지의 `html_document`는 별도의 완전한 HTML 문서 기능이므로 이 본문 블록 정책을 적용하지 않는다.
- 관리자 Markdown-first 에디터의 라이브/스타일 모드에서 링크·iframe·HTML 블록은 입력 카드 없이 즉시 실제 프리뷰로 표시된다. 링크·iframe·HTML·비디오·오디오·파일 프리뷰 카드는 hover/focus 시 우측 상단 삭제 버튼을 표시한다. 블록 래퍼에 포커스한 상태에서 `Backspace`·`Delete`·`Ctrl/Cmd+Shift+K`로 삭제하고, `Enter`로 아래 빈 줄을 추가하며, `ArrowUp`·`ArrowDown`은 브라우저 스크롤 대신 이전/다음 편집 줄로 이동한다.
- 라이브/스타일 모드에서 제목 블록 Enter는 현재 제목 내용을 저장한 뒤 바로 아래 빈 문단을 추가하고, 원문 마크다운 편집 상태로 전환하지 않는다.
- 게시물 작성 화면 상단 제목 입력 후 Enter는 현재 에디터 모드를 유지한 채 본문 첫 줄(마크다운 첫 줄이 제목이면 그 다음 줄)로 포커스를 옮긴다.
@@ -835,6 +871,7 @@ components/content/
- 고정 페이지 HTML 문서 모드는 전체 HTML 붙여넣기용 textarea를 사용하고, 공개 URL에서 Nuxt 레이아웃 없이 원문 HTML로 응답한다. HTML 문서 textarea에서 본문이 비어 있거나 `!`만 입력된 상태로 Tab을 누르면 기본 HTML 골격을 실제 본문에 자동 완성한다.
- 페이지 슬러그는 게시글처럼 한글 제목을 영문으로 로마자화해 자동 생성한다.
- 페이지 상태, 페이지 형식, Page URL, HTML 자산 업로드, 삭제 액션은 오른쪽 설정 패널에서 관리한다.
- 페이지 작성·수정 중 저장되지 않은 입력이 있으면 내부 라우트 이동과 브라우저 새로고침·닫기를 확인한다. 저장 완료 기준은 응답 시점의 폼 전체가 아니라 서버 요청에 실제로 전달한 payload이므로, 저장 중 추가 입력은 미저장 변경으로 유지한다. 새 페이지가 생성된 뒤 이탈 확인으로 이동이 보류되면 이후 저장은 생성된 ID를 `PUT`으로 수정해 중복 페이지 생성을 막는다.
- 페이지 형식 선택은 HTML 문서/일반 텍스트 모드와 무관하게 항상 표시한다. HTML 자산 업로드는 HTML 문서 모드에서만 표시한다.
- HTML 자산 업로드는 기존 관리자 업로드 API(`/admin/api/uploads`)를 사용하며, 성공한 파일 URL을 HTML textarea 현재 커서 위치에 삽입한다. 업로드 파일은 현재 에디터 업로드 정책에 따라 `/uploads/posts/YYYY/MM/` 아래 저장되고 미디어 라이브러리 논리 폴더는 `미분류`로 기록된다.
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문을 저장한다. 썸네일 등록 이미지는 홈 Pages 진열장 커버로 사용한다.
@@ -846,10 +883,11 @@ components/content/
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 콘텐츠·안전 영역의 **사이트 용량**은 그래프 없이 텍스트 카드로 표시하고, 기준 시각·콘텐츠 수량·파일 용량을 새로고침할 수 있다. 게시물 백업 도구는 `게시물 내보내기`와 `게시물 가져오기` 독립 카드로 표시한다. 내보내기 기본 화면은 제목·설명·내보내기 버튼만 표시하고, 상세 설정은 버튼을 눌렀을 때만 펼쳐진다. 내보내기 상세는 전체·특정년·특정월·직접 지정 범위, 목표 ZIP 용량, ZIP당 최대 게시물 수 지정을 제공한다. 내보내기 작업 목록은 작업이 있을 때만 내보내기 설정 카드 아래 별도 카드로 표시하며, 완료 작업은 상태 배지를 숨기고 만료일과 준비 완료 파일 선택 목록을 중심으로 표시한다. 전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·완료/실패 작업 삭제를 제공하며, 진행도 영역은 대기·생성 중 작업에서만 표시한다. 가져오기 기본 화면은 제목·설명·가져오기 버튼만 표시하고, 버튼을 누르면 ZIP 드롭존과 `적용` 버튼이 펼쳐진다. 가져오기는 파일 선택만으로 실행하지 않고 `적용` 버튼을 눌렀을 때만 Import API를 호출한다. 대기 중·생성 중 작업이 있으면 새 내보내기 요청 버튼을 비활성화한다. **사이트 코드** 카드는 ads.txt, 공통 헤더 코드, 공통 푸터 코드의 등록 여부를 라벨/값 세로 행으로 보여 주고, `편집`을 눌렀을 때만 textarea와 저장/취소가 나타난다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 라벨/값 세로 행으로 읽기 전용 표시하고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **사이트 정보** 카드는 로고, 공개 URL, 푸터 저작권 문구를 관리하며, 읽기 화면의 로고는 등록 상태 문구 없이 이미지 또는 점선 미등록 박스로 표시한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
- 게시물 Import/Export 1차 포맷은 Obsidian 호환 백업 번들을 기준으로 한다. Export는 게시물마다 별도 폴더를 만들고, 폴더 안에 `제목.md` 메인 파일과 `images/`, `files/` 같은 자산 폴더를 함께 둔다. 본문은 기존 Markdown을 최대한 유지하되 `/uploads/...`로 연결된 내부 이미지·파일은 번들 안의 로컬 파일로 복사하고, Markdown 참조는 `./images/...` 또는 `./files/...` 같은 상대 경로로 재작성한다. 제목·슬러그·상태·발행일·요약·대표 이미지·SEO·태그는 YAML frontmatter에 저장한다. Import는 같은 구조의 ZIP을 읽어 frontmatter를 게시물 메타데이터로 복원하고, 로컬 자산은 `/uploads/posts/YYYY/MM/`에 새 파일로 저장한 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다. 태그는 inline 배열(`tags: ["a"]`)과 Obsidian식 블록 배열(`tags:\n- a`)을 모두 읽는다. 같은 슬러그가 이미 있으면 기존 글을 덮어쓰지 않고 `slug-2`, `slug-3`처럼 새 슬러그로 가져온다. ZIP 안에서 자산을 찾지 못한 경우 Import는 계속 진행하되 응답 경고로 누락 경로를 알려 준다. 1회 Import는 최대 1000개 Markdown 게시물까지 처리한다.
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 ZIP에 들어갈 게시물 수의 안전 상한으로만 사용한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, 서버 프로세스 안에서 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. Export 대상은 전체 또는 `COALESCE(published_at, created_at)` 기준 특정년·특정월·직접 지정 날짜 범위로 제한할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_범위_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_2026-05_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 준비 완료 파일은 체크박스로 선택한 뒤 전체 선택 또는 선택 파일 다운로드로 브라우저에서 순차 다운로드할 수 있다. 실패 작업은 이미 준비된 파일을 유지하고 실패·대기 파일만 다시 생성하도록 재시도할 수 있으며, 실패 상세 로그는 작업 카드에서 확인한다. Resend 환경 변수가 설정되어 있으면 모든 분할 파일 생성 완료 후 요청 관리자 이메일로 알림을 보낸다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하며, 만료된 완료·실패 작업은 목록 조회나 새 요청 시 생성 ZIP 파일과 함께 자동 정리된다.
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 ZIP에 들어갈 게시물 수의 안전 상한으로만 사용한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, worker가 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. worker는 서버 시작과 30초 주기로 `queued` 및 재시작으로 중단된 `processing` 작업을 찾고, 작업 ID별 PostgreSQL advisory lock을 전용 연결에 유지해 여러 앱 인스턴스가 같은 작업을 동시에 생성하지 못하게 한다. Export 대상은 전체 또는 `COALESCE(published_at, created_at)` 기준 특정년·특정월·직접 지정 날짜 범위로 제한할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_범위_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_2026-05_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 준비 완료 파일은 체크박스로 선택한 뒤 전체 선택 또는 선택 파일 다운로드로 브라우저에서 순차 다운로드할 수 있다. 실패 작업은 이미 준비된 파일을 유지하고 실패·대기 파일만 다시 생성하도록 재시도할 수 있으며, 실패 상세 로그는 작업 카드에서 확인한다. Resend 환경 변수가 설정되어 있으면 모든 분할 파일 생성 완료 후 요청 관리자 이메일로 알림을 보낸다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하며, 만료된 완료·실패 작업은 목록 조회나 새 요청 시 생성 ZIP 파일과 함께 자동 정리된다.
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 설정 카드 저장은 카드가 담당하는 필드만 `PATCH /admin/api/settings`로 전달하며, 서버는 전달된 컬럼만 단일 `UPDATE`로 수정한다. 저장 성공 응답도 해당 필드만 현재 폼에 반영한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 라이트모드와 다크모드 프리뷰를 상하로 모두 표시하고, 각 모드 제목 오른쪽의 이미지 변경·삭제 버튼으로 개별 이미지를 관리한다. 이미지가 비어 있는 모드는 점선 드롭존으로 표시하며 파일 선택과 드래그 앤 드롭 업로드를 지원한다. 커버 파일 업로드·제목·본문은 편집 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 라이트모드와 다크모드 프리뷰를 상하로 모두 표시하고, 각 모드 제목 오른쪽의 이미지 변경·삭제 버튼으로 개별 이미지를 관리한다. 이미지가 비어 있는 모드는 점선 드롭존으로 표시하며 파일 선택과 드래그 앤 드롭 업로드를 지원한다. 커버 파일 업로드·제목·본문은 편집 뒤 **저장** 한 번에 `PATCH /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
- **POST 설정**(`show_post_updated_at`, `post_tag_limit`, `home_latest_post_limit`, `post_list_page_size`, `tag_post_list_page_size`, `featured_post_limit`, `page_section_item_limit`, `upload_max_file_count`, `upload_max_total_size`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부와 게시물별 태그 최대 개수, 공개 목록 표시 개수, 관리자 미디어 1회 업로드 제한을 관리한다. `post_tag_limit` 기본값은 5이며 1~10 범위로 제한한다. 표시개수 설정은 Latest, Posts, 태그 게시물, Featured, Pages 섹션의 공개 목록 로드·표시 개수를 관리하며 1~50 범위의 셀렉트 값만 사용한다. 업로드 제한은 기본 파일 20개·전체 300MB이며 파일 수는 1~50, 전체 용량은 1MB~1GB 범위에서 관리한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
@@ -891,8 +929,8 @@ components/content/
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. **관리자**는 VIP·멤버의 정보만 수정·삭제·비밀번호 변경할 수 있으며, 소유자·관리자·본인 계정은 조회만 한다. 관리자는 소유자·관리자 등급을 부여할 수 없다. **소유자**는 모든 등급 회원 정보를 수정할 수 있고 본인 정보 수정도 가능하나, 본인 등급은 직접 낮출 수 없다. 소유자는 다른 회원을 소유자로 지정할 수 있으며, 이때 확인 모달 후 `POST /admin/api/members/:id/ownership-transfer`로 권한을 이전하고 기존 소유자는 관리자가 된다. 소유자는 항상 1명만 유지된다.
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증하며 발급 시점의 `users.session_version`을 포함한다.
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지, 세션 버전이 현재 DB 값과 일치하는지 함께 확인한다. 권한 변경·계정 삭제·비밀번호 변경 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
### 게시물 작성자
@@ -905,12 +943,12 @@ components/content/
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정하고 발급 시점의 `users.session_version`을 서명된 페이로드에 포함한다.
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `GET/PUT /api/auth/profile`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다.
- 회원 로그인 성공 시 `previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. 요청 IP는 프록시 헤더(`x-forwarded-for`)를 포함해 기록한다. `/api/auth/me`는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다.
- 사용자 설정 화면은 공개 본문 폭에 맞춰 프로필 요약을 상단에 두고, 프로필 입력과 활동 정보를 하단에 배치한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다.
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`만 사용하며, 값이 없으면 서버 오류로 실패한다.
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`만 사용하며, 값이 없으면 서버 오류로 실패한다. 인증이 필요한 회원 API와 멤버십 콘텐츠 조회는 토큰 버전과 DB의 `session_version`이 일치하는지 확인한다.
- Docker 운영 서버 환경 변수는 이미지 빌드 시점 `runtimeConfig`보다 컨테이너 런타임 `process.env` 값을 우선한다.
---

View File

@@ -7,9 +7,9 @@
## 프론트엔드 개발
- 업로드 총량 제한 및 메모리 사용량 개선.
- 관리자 업로드는 사이트 설정에서 1회 최대 파일 수·전체 용량을 조절하고 클라이언트·서버 양쪽에서 검사함.
- 서버는 아직 multipart 전체를 메모리에 받은 뒤 검사하므로, 고용량 다중 업로드의 메모리 피크를 더 줄이려면 스트리밍 업로드 파서 도입 검토.
- CSP Report-Only 운영 보고 관찰 후 강제 정책 추가 강화 검토.
- 광고·사용자 사이트 코드·외부 미디어의 정상 출처를 `csp_violation_aggregates`에서 충분히 확인.
- 정상 출처 허용 목록과 반복 오탐을 정리한 뒤 script/style/img/frame/connect 지시어를 단계별 강제 전환.
## 콘텐츠 스타일 구현
@@ -21,3 +21,10 @@
- 원시 집계 단계에서도 같은 탭의 여러 게시물 이동이 스크롤 구간 계산에 섞이지 않도록 별도 콘텐츠 세션 저장 구조 검토.
## 배포
- (운영 배포 시) 미디어 카탈로그를 database 모드로 전환.
- `068`, `069` 마이그레이션 적용.
- `MEDIA_CATALOG_MODE=filesystem` 또는 배포 직후 상태에서 `POST /admin/api/media/reconcile` 1회.
- `ready=true`, 개수·용량 차이 0 확인 후 `MEDIA_CATALOG_MODE=shadow`로 짧게 관찰(선택).
- 운영 env를 `MEDIA_CATALOG_MODE=database`로 고정하고 재기동.
- 준비 미완료·조회 실패 시 앱은 파일시스템 목록으로 자동 대체한다.

View File

@@ -1,36 +1,58 @@
# [업데이트 기록] v1.0
안녕하세요.
뒤늦게 정리하는 `sori.studio` 업데이트 기록입니다.
`sori.studio`를 정식으로 오픈했습니다.
v1.0에서는 사이트를 오픈하면서, 이후 업데이트 내용을 꾸준히 기록할 수 있도록 운영 기반을 먼저 정리했습니다.
이번 버전은 새로운 기능을 크게 늘리기보다, 블로그를 안정적으로 운영하고 관리할 수 있는 기본 흐름을 맞추는 데 집중했습니다.
기존에는 티스토리를 비롯한 여러 블로그 서비스를 이용했지만, 운영하면서 기능과 표현 방식에 여러 아쉬움을 느껴 직접 사이트를 만들기로 했습니다.
방문자에게 보이는 화면뿐만 아니라 게시물 작성과 이미지 관리 같은 관리자 기능도 하나씩 직접 제작하고 있습니다.
이 때문에 업데이트 기록에는 방문자에게 직접적인 변화로 느껴지지 않는 내용도 포함될 수 있습니다.
다만 이러한 작업 역시 콘텐츠를 안정적으로 제공하고 다양한 운영 환경에 대응하기 위한 과정인 만큼, 사이트가 변화해 가는 모습과 함께 꾸준히 기록해 보려고 합니다.
v1.0은 사이트의 첫 정식 버전으로, v0.1부터 v0.9까지 진행한 개발과 개선 내용을 종합한 업데이트입니다.
게시물을 작성하고 공개하는 기본적인 블로그 기능부터 이미지와 갤러리 관리, 사이트 설정과 운영 환경까지 전반적인 기반을 정리했습니다.
아직 채워나가야 할 부분이 많지만, 이제부터는 하나의 완성된 블로그로서 콘텐츠와 함께 사이트의 변화도 차근차근 기록해 보려고 합니다.
## 이번 업데이트에서 반영된 내용
- 블로그 공개 오픈
사이트를 실제로 공개하고, 기본 페이지가 안정적으로 보이도록 초기 구조를 정리했습니다.
- **sori.studio 정식 오픈**
사이트의 기본 구조를 완성하고 누구나 게시물을 열람할 수 있도록 블로그를 공개했습니다.
다양한 화면 크기에서도 콘텐츠를 편하게 확인할 수 있도록 기본적인 화면 구성과 표시 방식을 함께 점검했습니다.
- 관리자 화면 중심의 운영 기능 정리
운영자가 필요한 메뉴를 한 곳에서 확인하고 처리할 수 있도록 관리자 화면 구성을 다듬었습니다.
글 관리, 설정, 계정 관련 흐름이 자연스럽게 이어지도록 정리해 운영 동선을 단순하게 맞췄습니다.
- **게시물 작성 및 발행 기능 구축**
새로운 글을 작성하고 초안으로 저장하거나 공개할 수 있는 기본적인 게시물 관리 기능을 적용했습니다.
작성부터 검토, 발행까지의 흐름이 자연스럽게 이어지도록 여러 차례 개선했습니다.
- 게시물 작성 기능 적용 및 작성 흐름 보완
글을 작성하고 저장하는 기본 기능을 적용하고, 작성 중 끊기지 않도록 입력/저장 흐름을 반복 보완했습니다.
초안 관리와 발행 전후 흐름도 함께 정리해 실제 운영에서 바로 사용할 수 있도록 맞췄습니다.
- **이미지와 갤러리 지원**
게시물에 필요한 이미지를 업로드하고 본문에 삽입할 수 있도록 미디어 관리 기능을 구성했습니다.
여러 이미지를 하나의 갤러리로 보여줄 수 있도록 기본적인 표시 방식도 함께 마련했습니다.
- 이미지/갤러리 등 미디어 삽입 흐름 정리
글 작성 중 이미지를 넣는 과정이 복잡하지 않도록 업로드와 선택 흐름을 정리했습니다.
미디어를 선택했을 때 화면에 어떻게 보이는지도 함께 다듬어 작성 경험을 안정화했습니다.
- **콘텐츠 열람 환경 정리**
제목과 본문, 이미지, 목록, 링크 등 게시물을 구성하는 요소들이 일관된 형태로 표시되도록 기본 스타일을 정리했습니다.
긴 글이나 이미지가 많은 게시물도 편하게 읽을 수 있도록 가독성과 간격을 조정했습니다.
- 사이트 설정, 계정/권한, 배포 관련 기본 구조 정비
사이트 운영에 필요한 기본 설정 항목을 정리하고, 계정/권한 처리 흐름을 점검했습니다.
배포 과정에서도 필수 설정이 누락되지 않도록 기본 점검 기준을 함께 맞췄습니다.
- **사이트 운영 환경 구축**
게시물과 사이트 설정, 계정 등을 한 곳에서 관리할 수 있도록 내부 운영 환경을 구성했습니다.
사이트를 지속적으로 관리하고 새로운 콘텐츠를 안정적으로 발행하기 위한 기본 토대를 마련했습니다.
- 초기 운영 중 발견된 불편 사항 수정
실제 사용 중 발견된 불편 요소를 작은 단위로 빠르게 수정해, 전체 사용감을 안정적으로 맞췄습니다.
- **계정과 권한 구조 정비**
사이트 운영에 필요한 계정과 접근 권한의 기본 구조를 구성하고, 관리 기능이 외부에 노출되지 않도록 관련 흐름을 점검했습니다.
- **배포 및 업데이트 기반 마련**
새로운 기능과 수정 사항을 사이트에 안정적으로 반영할 수 있도록 배포 환경을 정리했습니다.
이후 업데이트도 지속적으로 적용하고 기록할 수 있는 기본적인 관리 체계를 마련했습니다.
- **초기 사용성 개선**
v0.1부터 v0.9까지 직접 사이트를 운영하며 발견한 오류와 불편한 부분을 꾸준히 수정했습니다.
작은 기능과 화면 표현도 실제 사용 흐름에 맞춰 다듬어 첫 정식 버전에 반영했습니다.
## 앞으로의 계획
v1.0을 시작으로 기존 게시물을 차근차근 정리하고, 새로운 콘텐츠도 지속적으로 추가할 예정입니다.
사이트를 직접 운영하며 발견하는 불편한 부분도 계속 개선하고, 주요 변경 사항은 업데이트 기록을 통해 함께 남기겠습니다.
## 한 줄 요약
v1.0은 화려한 확장보다,
블로그를 꾸준히 운영할 수 있는 첫 기반을 다진 버전입니다.
v1.0은 `sori.studio`를 하나의 독립된 블로그로 정식 공개한 첫 번째 버전입니다.

43
docs/update-log/v1.1.md Normal file
View File

@@ -0,0 +1,43 @@
# [업데이트 기록] v1.1
안녕하세요.
조금 늦게 정리하는 `sori.studio` v1.1 업데이트 기록입니다.
이번 업데이트는 새로운 기능을 추가하기보다, 게시물을 더 안정적으로 작성하고 관리할 수 있도록 사이트 내부의 운영 환경을 정비하는 데 집중했습니다.
방문자에게 직접 보이는 변화는 크지 않지만, 앞으로 콘텐츠를 꾸준히 작성하고 기존 게시물을 개선하기 위한 기본적인 작업 환경을 다듬었습니다.
## 이번 업데이트에서 반영된 내용
- **콘텐츠 운영 환경 안정화**
사이트를 새롭게 설치하거나 업데이트하는 과정에서 필요한 설정을 정리하고, 문제가 발생했을 때 원인을 더 쉽게 확인할 수 있도록 기본적인 점검 절차를 보완했습니다.
게시물 작성이나 사이트 업데이트 과정에서 발생할 수 있는 오류를 줄이고, 콘텐츠를 안정적으로 제공하기 위한 작업입니다.
- **Markdown 중심의 작성 환경 구축**
게시물을 Markdown으로 작성하고 관리할 수 있도록 글쓰기 환경을 전반적으로 변경했습니다.
텍스트 범위 선택과 복사·붙여넣기 같은 기본 편집 기능을 보완했으며, 작성 화면과 미리보기를 전환하면서 실제 공개될 모습을 확인할 수 있도록 구성했습니다.
- **외부 자료 붙여넣기 개선**
다른 웹페이지에서 제목, 문단, 목록, 링크 또는 이미지를 복사해 붙여넣었을 때 불필요한 형식이 섞이지 않도록 변환 과정을 개선했습니다.
기존 자료를 이전하거나 정리할 때도 게시물 형식을 일정하게 유지할 수 있도록 했습니다.
- **이미지와 갤러리 관리 개선**
게시물에 포함된 이미지와 갤러리를 작성 화면에서 바로 확인하고 수정할 수 있도록 편집 기능을 보완했습니다.
이미지 주소와 노출 순서를 변경하거나 이미지를 추가하고 삭제하는 작업을 한 흐름 안에서 처리할 수 있게 되었습니다.
- **문단과 줄바꿈 표현 정리**
Enter와 Shift+Enter의 동작을 구분하고, 여러 개의 빈 줄과 문단 간격이 미리보기와 실제 게시물에서도 작성 의도에 가깝게 표시되도록 개선했습니다.
작성 화면과 공개된 게시물 사이에서 문단 구조가 달라지는 문제를 줄이고, 콘텐츠의 가독성을 일정하게 유지하는 데 중점을 두었습니다.
- **작성 화면과 미리보기 개선**
작성에 집중할 수 있도록 불필요한 테두리와 스크롤 표시를 정리했습니다.
미리보기를 확인한 뒤 작성 화면으로 돌아왔을 때 기존에 편집하던 위치를 이어갈 수 있도록 개선해, 긴 게시물도 조금 더 편하게 작성할 수 있게 되었습니다.
- **초기 운영 절차 보완**
새로운 운영 환경에서도 관리자 계정을 생성하고 바로 사이트 관리를 시작할 수 있도록 초기 설정 과정을 안정화했습니다.
방문자에게 직접 보이는 기능은 아니지만, 사이트를 지속적으로 유지하고 관리하기 위한 내부 기반을 보완한 작업입니다.
## 한 줄 요약
v1.1은 방문자에게 새로운 기능을 선보이기보다,
앞으로 더 많은 콘텐츠를 안정적으로 작성하고 제공하기 위해 사이트의 내부 기반을 다듬은 버전입니다.

69
docs/update-log/v1.10.md Normal file
View File

@@ -0,0 +1,69 @@
# [업데이트 기록] v1.10
안녕하세요.
조금 늦게 정리하는 `sori.studio` v1.10 업데이트 기록입니다.
이번 업데이트에서는 콘텐츠가 늘어나더라도 원하는 정보를 쉽게 찾고, 다양한 게시물과 페이지를 자연스럽게 둘러볼 수 있도록 사이트의 탐색 구조를 개선했습니다.
추천 사이트와 태그, 독립 페이지, 검색 및 목록 기능을 서로 연결하고, 주요 콘텐츠를 홈 화면에서 효과적으로 소개할 수 있도록 콘텐츠 구성 방식을 확장했습니다.
내부적으로는 콘텐츠별 이용 현황을 자세히 확인할 수 있도록 통계 기능을 보완하고, 회원 권한과 미디어 관리 등 안정적인 운영에 필요한 기능도 함께 정비했습니다.
## 이번 업데이트에서 반영된 내용
- **추천 사이트 영역 개선**
추천 사이트를 실제 공개 화면과 동일한 카드 형태로 확인하며 관리할 수 있도록 개선했습니다.
사이트 주소와 아이콘이 안정적으로 표시되도록 하고, 공개 화면의 추천 사이트 영역은 왼쪽 사이드바의 태그 아래로 옮겨 전체 구성을 정돈했습니다.
- **회원 권한 관리 보강**
운영 권한에 따라 확인하거나 수정할 수 있는 회원의 범위를 명확하게 구분했습니다.
사이트 소유자 권한도 필요한 경우 다른 회원에게 안전하게 이전할 수 있도록 관리 절차와 보호 장치를 보완했습니다.
- **미디어 유형과 사용 현황 정리**
원본 이미지와 게시물 카드용 썸네일, 프로필 이미지 등을 용도에 따라 구분할 수 있도록 했습니다.
각 파일이 사이트에서 실제로 사용되고 있는 횟수도 표시해, 불필요한 미디어를 더욱 쉽게 확인하고 정리할 수 있습니다.
- **태그 화면 구성 확대**
일반 태그에도 이름과 설명, 고유 색상이 일관된 형태로 표시되도록 태그 화면을 개선했습니다.
태그마다 별도의 헤더 이미지와 안내 문구를 설정할 수 있어, 각 주제의 성격과 관련 콘텐츠를 더욱 분명하게 보여줄 수 있습니다.
- **본문 콘텐츠 표현 방식 확장**
HTML 콘텐츠와 일반 링크, 외부 페이지를 각각의 용도에 맞는 형태로 본문에 삽입할 수 있도록 작성 기능을 세분화했습니다.
게시물 안에서 다양한 외부 자료와 콘텐츠를 더욱 자연스럽게 소개할 수 있습니다.
- **Markdown 표시 안정화**
표와 목록, 링크 안의 강조 표현과 줄바꿈 등 복잡한 Markdown 문법이 공개 게시물에서도 의도한 형태로 표시되도록 보완했습니다.
여러 형식이 함께 사용된 게시물도 일관된 구조와 가독성을 유지할 수 있도록 다듬었습니다.
- **콘텐츠별 조회수 표시 추가**
게시물과 독립 페이지의 조회수를 관리자 목록에서 바로 확인할 수 있도록 했습니다.
별도의 통계 화면을 열지 않아도 각 콘텐츠의 이용 현황을 간단하게 살펴볼 수 있습니다.
- **운영 통계 상세화**
게시물과 페이지의 일간·주간·월간·연간 조회 추이를 확인할 수 있도록 통계 범위를 확대했습니다.
유입 경로와 이용 디바이스, 인기 콘텐츠 등의 정보도 상세 화면에서 함께 살펴볼 수 있도록 구성했습니다.
- **독립 페이지 노출 범위 확대**
기존의 독립 페이지도 홈 화면과 검색 결과에 노출할 수 있도록 기능을 확장했습니다.
게시물과는 다른 형식으로 제작된 주요 페이지도 방문자가 사이트 안에서 쉽게 발견할 수 있습니다.
- **홈 콘텐츠 섹션 추가**
독립 페이지를 주제별로 묶어 홈 화면에 별도의 콘텐츠 섹션으로 표시할 수 있도록 했습니다.
각 페이지의 추천 여부와 표시 순서를 설정해 콘텐츠의 중요도와 목적에 맞게 홈 화면을 구성할 수 있습니다.
- **홈 콘텐츠 탐색 기능 개선**
홈 화면의 콘텐츠 섹션을 마우스 드래그와 좌우 이동 버튼으로 둘러볼 수 있도록 했습니다.
하나의 섹션에 여러 페이지가 포함된 경우에도 원하는 콘텐츠를 편리하게 확인할 수 있습니다.
- **목록과 페이지 이동 개선**
게시물과 태그 목록에서 한 번에 표시되는 콘텐츠 수와 페이지 이동 방식을 정리했습니다.
콘텐츠가 많아진 경우에도 목록을 안정적으로 탐색하고 원하는 항목을 쉽게 찾을 수 있도록 개선했습니다.
- **한글 검색 안정화**
한글을 입력하는 도중 검색 결과가 일시적으로 사라지거나 이전 검색어의 결과가 남아 있던 문제를 수정했습니다.
검색어 입력과 결과 갱신이 자연스럽게 이어지도록 검색 동작을 전반적으로 보완했습니다.
## 한 줄 요약
v1.10은 페이지와 태그, 검색 및 홈 콘텐츠 섹션을 자연스럽게 연결하고 통계와 관리 기능을 보완해,
늘어난 콘텐츠를 더욱 쉽게 발견하고 안정적으로 운영할 수 있도록 만든 버전입니다.

60
docs/update-log/v1.2.md Normal file
View File

@@ -0,0 +1,60 @@
# [업데이트 기록] v1.2
안녕하세요.
조금 늦게 정리하는 `sori.studio` v1.2 업데이트 기록입니다.
이번 업데이트에서는 게시물을 더욱 편안하게 읽을 수 있도록 본문 표현을 다듬고, 원하는 콘텐츠를 쉽게 찾을 수 있도록 태그와 메뉴 구조를 개선했습니다.
이와 함께 글을 작성하고 발행하는 과정부터 게시물, 메뉴, 사이트 설정을 관리하는 내부 기능까지 폭넓게 정비했습니다.
방문자에게 직접 보이지 않는 변화도 많지만, 콘텐츠를 꾸준하고 안정적으로 제공하기 위한 운영 환경을 개선하는 데 중점을 둔 버전입니다.
## 이번 업데이트에서 반영된 내용
- **본문 가독성 개선**
게시물 본문의 글자 크기와 행간을 조정하고, 제목과 문단 사이의 간격을 다듬었습니다.
내용이 긴 게시물도 조금 더 편안하게 읽을 수 있도록 전반적인 본문 표현을 개선했습니다.
- **태그 탐색 및 관리 개선**
게시물을 분류하는 일반 태그를 한눈에 확인하고 검색하거나 정렬할 수 있도록 관리 기능 관리을 정리했습니다.
주요 콘텐츠를 구분하는 메인 태그의 노출 순서도 더욱 유연하게 조정할 수 있도록 개선했습니다.
- **사이트 메뉴 구성 개선**
콘텐츠 구성에 따라 메뉴의 순서와 하위 항목을 자유롭게 조정할 수 있도록 메뉴 관리 방식을 개선했습니다.
사이트에 표시되는 탐색 구조를 보다 쉽게 정리하고 변경할 수 있게 되었습니다.
- **추천 사이트 기능 추가**
함께 소개하고 싶은 외부 사이트를 별도로 등록해 공개 화면에 표시할 수 있는 기능을 추가했습니다.
사이트의 성격과 콘텐츠에 어울리는 관련 페이지를 방문자에게 안내할 수 있습니다.
- **사이트 설정 화면 개편**
여러 곳에 나뉘어 있던 사이트 설정을 하나의 전체 화면 구조로 정리했습니다.
필요한 설정을 왼쪽 메뉴에서 빠르게 찾아 수정할 수 있도록 내부 운영 동선을 단순화했습니다.
- **게시물 검색 및 관리 기능 보완**
게시물의 공개 상태와 태그, 정렬 조건을 기준으로 원하는 글을 빠르게 찾을 수 있도록 목록 기능을 개선했습니다.
게시물이 많아져도 기존 콘텐츠를 쉽게 확인하고 관리할 수 있는 기반을 마련했습니다.
- **추천 게시물 기능 추가**
주요 게시물을 추천 글로 지정해 홈 화면의 Featured 영역에 별도로 소개할 수 있도록 했습니다.
새롭게 작성한 글이나 먼저 소개하고 싶은 콘텐츠를 방문자가 보다 쉽게 발견할 수 있습니다.
- **초안 자동 저장 기능 적용**
새로운 글을 작성하는 동안 초안이 자동으로 저장되도록 개선했습니다.
예상하지 못한 상황에서도 작성 중인 내용을 안전하게 보관하고 이어서 편집할 수 있습니다.
- **발행 및 예약 흐름 개선**
게시물의 초안, 공개, 예약 발행 및 발행 취소 상태를 구분해 현재 상태를 명확하게 확인할 수 있도록 정리했습니다.
콘텐츠를 원하는 시점에 보다 안정적으로 공개할 수 있도록 발행 과정을 보완했습니다.
- **이미지와 갤러리 삽입 통합**
이미지와 갤러리를 하나의 미디어 화면에서 검색하거나 새롭게 업로드해 본문에 삽입할 수 있도록 작성 흐름을 통합했습니다.
이미지가 많은 게시물도 더욱 간결한 과정으로 작성할 수 있게 되었습니다.
- **발행일과 수정일 표시 정리**
공개된 게시물을 수정하더라도 최초 발행일이 유지되도록 날짜 처리 방식을 개선했습니다.
내용이 변경된 경우에는 필요에 따라 수정 시각을 게시물에 함께 표시할 수 있도록 했습니다.
## 한 줄 요약
v1.2는 본문의 가독성과 콘텐츠 탐색 환경을 개선하고,
글의 작성과 발행부터 태그·메뉴·설정까지 전반적인 운영 흐름을 다듬은 버전입니다.

61
docs/update-log/v1.5.md Normal file
View File

@@ -0,0 +1,61 @@
# [업데이트 기록] v1.5
안녕하세요.
조금 늦게 정리하는 `sori.studio` v1.5 업데이트 기록입니다.
이번 업데이트에서는 실제 게시물에 표시되는 모습을 확인하며 콘텐츠를 편집할 수 있도록 글쓰기 환경을 크게 확장했습니다.
홈 화면과 공지 배너처럼 방문자가 직접 확인할 수 있는 기능을 추가하고, 다양한 미디어와 외부 콘텐츠가 본문에 자연스럽게 표시되도록 개선했습니다.
방문 통계와 미디어 관리 등 내부 운영 기능도 함께 정비해, 콘텐츠를 작성하고 사이트를 관리하는 전반적인 경험을 확장했습니다.
## 이번 업데이트에서 반영된 내용
- **이미지 정보 및 갤러리 기능 개선**
이미지에 표시되는 캡션과 이미지의 내용을 설명하는 대체 텍스트를 각각 설정할 수 있도록 개선했습니다.
갤러리의 이미지 순서를 자유롭게 변경하고, 선택한 이미지를 큰 화면으로 넘겨 볼 수 있는 기능도 추가했습니다.
- **라이브 편집 모드 적용**
문단과 제목, 목록, 인용문 등이 실제 게시물에 표시되는 모습에 가깝게 보이는 상태에서 내용을 바로 수정할 수 있도록 글쓰기 방식을 확장했습니다.
작성 화면과 공개 화면 사이의 차이를 줄여 콘텐츠의 최종 모습을 더욱 쉽게 확인할 수 있습니다.
- **슬래시 명령과 콘텐츠 블록 추가**
간단한 슬래시 명령을 사용해 제목과 이미지, 코드, 안내 상자, 접이식 영역 등을 본문에 삽입할 수 있도록 했습니다.
키보드만으로도 블록을 추가하거나 콘텐츠 사이를 자연스럽게 이동할 수 있도록 작성 흐름을 함께 다듬었습니다.
- **홈 화면 맞춤 설정 확대**
홈 화면의 커버 이미지와 제목, 설명을 콘텐츠의 성격에 맞게 설정할 수 있도록 개선했습니다.
라이트 모드와 다크 모드에 서로 다른 커버 이미지를 적용해 각 화면에 어울리는 분위기를 구성할 수 있습니다.
- **공지 배너 기능 추가**
사이트 상단에 방문자에게 전달할 안내 문구와 관련 링크를 표시할 수 있는 공지 배너를 추가했습니다.
방문자는 배너를 직접 닫거나 일정 기간 표시되지 않도록 선택할 수 있습니다.
- **다양한 미디어 형식 지원**
기존 이미지뿐만 아니라 비디오와 오디오, 일반 파일도 업로드해 게시물에 첨부할 수 있도록 미디어 기능을 확장했습니다.
콘텐츠의 성격에 따라 다양한 자료를 한 게시물 안에서 함께 제공할 수 있습니다.
- **외부 콘텐츠 표시 개선**
외부 게시물이나 콘텐츠의 주소를 본문에 추가했을 때 페이지 안에서 자연스럽게 표시되도록 개선했습니다.
관련 자료를 단순한 텍스트 링크보다 이해하기 쉬운 형태로 소개할 수 있습니다.
- **방문 통계 기능 확대**
방문자와 페이지 조회수, 인기 게시물뿐만 아니라 평균 체류 시간과 스크롤 도달 범위 등을 확인할 수 있도록 통계 기능을 확장했습니다.
어떤 콘텐츠가 주로 읽히고 있는지 파악해 향후 게시물 구성과 기존 콘텐츠 개선에 참고할 수 있습니다.
- **실시간 접속 현황 추가**
현재 사이트에 접속한 방문자와 열람 중인 페이지를 관리자 대시보드에서 확인할 수 있도록 했습니다.
공개 직후의 게시물 반응이나 사이트의 현재 이용 상황을 간단하게 살펴볼 수 있습니다.
- **미디어 검색 및 관리 개선**
업로드한 미디어를 이미지, 비디오, 오디오 및 파일 등의 종류에 따라 분류해 확인할 수 있도록 했습니다.
게시물에서 사용 중인 미디어와 사용되지 않는 미디어도 구분해 더욱 쉽게 정리할 수 있습니다.
- **관리자 화면 표시 안정화**
공개 사이트에 적용된 라이트 모드와 다크 모드 설정에 영향을 받지 않도록 관리자 화면의 표시 방식을 분리했습니다.
사이트의 공개 화면을 자유롭게 꾸미면서도 관리 기능은 일관된 환경에서 사용할 수 있도록 정리했습니다.
## 한 줄 요약
v1.5는 실제 결과를 확인하며 작성하는 라이브 편집 환경과 다양한 미디어 기능을 도입하고,
홈 화면 구성과 콘텐츠 열람 및 사이트 운영 경험을 전반적으로 확장한 버전입니다.

72
docs/update-log/v1.6.md Normal file
View File

@@ -0,0 +1,72 @@
# [업데이트 기록] v1.6
안녕하세요.
조금 늦게 정리하는 `sori.studio` v1.6 업데이트 기록입니다.
이번 업데이트에서는 게시물 작성 환경을 더욱 안정적으로 다듬고, 일반 게시물과 구분되는 독립 페이지를 제작할 수 있도록 콘텐츠 구성 방식을 확장했습니다.
회원별 콘텐츠 공개 범위와 게시물 백업 및 복원 기능을 추가했으며, 방문 통계와 검색 노출, 사이트 설정 등 장기적인 블로그 운영에 필요한 내부 기능도 함께 보완했습니다.
방문자에게는 긴 게시물을 더욱 편리하게 읽고 새로운 콘텐츠를 쉽게 발견할 수 있도록 개선하고, 운영 측면에서는 콘텐츠가 많아져도 안전하고 꾸준하게 관리할 수 있는 기반을 마련하는 데 집중한 버전입니다.
## 이번 업데이트에서 반영된 내용
- **독립 페이지 제작 기능 확대**
일반적인 문서 형태의 페이지뿐만 아니라 전체 HTML을 활용한 랜딩 페이지도 제작할 수 있도록 페이지 기능을 확장했습니다.
콘텐츠의 목적에 따라 게시물과 다른 자유로운 구성의 화면을 제공할 수 있으며, 각 페이지의 공개 상태도 별도로 관리할 수 있습니다.
- **회원 전용 콘텐츠 지원**
콘텐츠를 누구나 볼 수 있는 공개 상태와 로그인한 회원만 볼 수 있는 회원 전용 상태, 외부에 표시되지 않는 비공개 상태로 구분할 수 있도록 했습니다.
게시물의 성격에 따라 공개 범위를 더욱 세밀하게 설정할 수 있습니다.
- **회원과 권한 관리 강화**
회원 정보를 확인하는 화면과 수정하는 화면을 구분해 관리 과정에서 발생할 수 있는 실수를 줄였습니다.
중요한 관리 권한이 의도치 않게 사라지거나 잘못 변경되지 않도록 관련 안전장치도 보강했습니다.
- **게시물 목차와 읽기 흐름 개선**
내용이 긴 게시물에서 현재 읽고 있는 부분을 쉽게 파악할 수 있도록 목차에 현재 제목을 표시하는 기능을 추가했습니다.
목차의 항목을 선택하면 해당 내용으로 부드럽게 이동해 원하는 정보를 더욱 빠르게 찾을 수 있습니다.
- **게시물 백업과 복원 기능 추가**
게시물과 본문에 포함된 첨부 파일을 함께 내려받고, 필요한 경우 다시 가져올 수 있도록 백업 및 복원 기능을 추가했습니다.
콘텐츠가 많은 경우에는 백업 파일을 여러 개로 나누어 준비하고 전체 진행 상황도 확인할 수 있도록 구성했습니다.
- **사이트 디자인 설정 확대**
사이트를 대표하는 브랜드 색상과 공지 배너를 관리자 화면에서 직접 설정할 수 있도록 했습니다.
콘텐츠와 사이트의 분위기에 맞춰 주요 색상과 안내 영역을 더욱 유연하게 변경할 수 있습니다.
- **SNS 및 외부 연결 설정 추가**
사이트에서 사용하는 SNS와 외부 서비스의 링크를 한 곳에서 관리할 수 있도록 설정 기능을 추가했습니다.
방문자가 다른 채널의 콘텐츠도 쉽게 찾아갈 수 있도록 공개 화면의 연결 구조를 정리했습니다.
- **광고 및 공통 코드 관리 지원**
게시물과 사이트의 광고 노출 위치를 설정하고, 여러 페이지에서 공통으로 사용하는 사이트 코드를 관리할 수 있도록 내부 설정을 확장했습니다.
운영 중 필요한 외부 서비스와 코드를 더욱 일관된 방식으로 적용할 수 있습니다.
- **방문 통계 기능 보강**
방문자의 유입 경로와 이용 디바이스, 검색어, 인기 게시물 및 페이지를 확인할 수 있도록 통계 항목을 확대했습니다.
어떤 경로를 통해 방문하고 어떤 콘텐츠를 주로 읽는지 한눈에 살펴볼 수 있도록 통계 화면도 함께 개편했습니다.
- **검색 노출을 위한 사이트맵 추가**
검색 서비스가 새로운 게시물과 페이지의 구조를 더욱 쉽게 파악할 수 있도록 사이트맵을 제공하기 시작했습니다.
새롭게 작성하거나 수정한 콘텐츠가 검색 서비스에 원활하게 전달될 수 있는 기본 환경을 마련했습니다.
- **RSS 구독 지원**
별도의 RSS 서비스를 이용해 새로운 게시물을 구독할 수 있도록 피드를 추가했습니다.
게시물의 제목과 요약뿐만 아니라 대표 이미지도 함께 제공해 새로운 콘텐츠를 더욱 쉽게 확인할 수 있습니다.
- **글쓰기 도구 확장**
표와 글자색, 안내 상자, 인용문, 코드 및 접이식 영역의 세부 설정을 확장했습니다.
콘텐츠의 목적과 정보 구조에 따라 본문을 더욱 다양하고 명확한 형태로 구성할 수 있습니다.
- **작성 기능 안정화**
되돌리기와 다시 실행, 텍스트 범위 선택, 한글 입력 등 글쓰기 과정에서 반복적으로 사용되는 기능을 보완했습니다.
복잡한 형식의 게시물이나 긴 글도 안정적으로 작성할 수 있도록 편집 동작을 전반적으로 다듬었습니다.
- **대표 이미지와 미디어 관리 개선**
게시물의 대표 이미지에 적합한 썸네일을 생성하고 관리할 수 있도록 이미지 처리 방식을 개선했습니다.
게시물에서 사용 중인 미디어와 사용되지 않는 미디어를 구분해 불필요한 파일을 더욱 쉽게 확인하고 정리할 수 있습니다.
## 한 줄 요약
v1.6은 콘텐츠 제작과 열람 환경을 개선하고 회원 권한, 백업, 통계, 검색 및 구독 기능을 더해,
콘텐츠가 많아져도 안정적으로 운영할 수 있도록 블로그 전반의 기반을 확장한 버전입니다.

View File

@@ -1,5 +1,89 @@
# 업데이트 이력
## v1.10.88
- Playwright API 통합 테스트 10건과 Chromium E2E 테스트 3건 추가.
- Playwright 실행마다 `_test` 전용 PostgreSQL 컨테이너·볼륨과 임시 업로드 루트를 생성하고 종료 시 제거하도록 격리.
- 테스트 중 게시물 Export worker와 추천 사이트 파비콘 cron을 비활성화하고 Playwright worker를 1개로 고정.
- `test:unit`, `test:api`, `test:e2e`, `verify:ci` 검증 명령 추가 및 로컬 `verify`와 CI 전체 검증 범위 분리.
- CSP를 `base-uri 'self'`, `object-src 'none'`, `frame-ancestors 'self'` 강제 최소 정책과 사용자 코드·광고·외부 미디어 호환 관찰용 Report-Only 정책으로 분리.
- 전체 HTML 문서를 반환하는 `/pages/**`에는 강제 CSP를 적용하지 않고 Report-Only 수집만 유지.
- `/api/csp-report`에 32KB 본문 제한, JSON 계열 Content-Type 검증, 최대 20건 payload 검증, IP 해시 기준 분당 60회 제한 추가.
- CSP 보고에서 문서 URL의 쿼리·fragment와 차단 URL의 경로·쿼리를 제거하고 브라우저 계열·지시어·origin 단위로만 DB 집계.
- `068_csp_violation_aggregates.sql` 마이그레이션으로 CSP 위반 집계 테이블·최근 관찰 인덱스 추가.
- `069_media_catalog.sql` 마이그레이션으로 `media_metadata` 카탈로그 필드·상태·관계, `media_references`, `media_catalog_state` 추가.
- 미디어 목록 조회를 `filesystem` 기본, `shadow` 비교, `database` 전환 모드로 분리하고 준비 미완료·집계 불일치·DB 조회 실패 시 파일시스템 결과로 대체.
- DB 모드에서 탭·폴더·종류·미사용·파일명/사용처 검색·50/100/200개 페이지네이션을 SQL로 처리.
- 업로드·Import·썸네일·아바타·이름 변경·삭제와 게시물·페이지·태그·회원·설정·네비게이션 저장에 미디어 파일·참조 카탈로그 최선 노력 dual-write 적용.
- 파일을 삭제하거나 이동하지 않는 안전 reconcile로 발견 파일 upsert, 콘텐츠 참조 백필, 사라진 파일 `missing` 표시, 파일시스템/DB 개수·용량 준비 상태 집계 추가.
- 관리자 `POST /admin/api/media/reconcile`과 로컬 전용 `npm run media:reconcile:dev` 수동 재동기화 경로 추가.
- 관리자 미디어 화면에 임시 전환 도구(장부 정리·비교 모드·DB 모드) 추가. 전환 완료 후 삭제 예정.
- 임시 전환 도구에 기존 방식(filesystem) 버튼을 추가해 shadow·database와 화면 비교가 가능하게 함.
- 개발 검증 완료 후 임시 전환 UI·`catalog-mode`/`catalog-status` API·런타임 모드 덮어쓰기를 제거.
- 개발(`.env.development`)과 예시(`.env.example`)의 `MEDIA_CATALOG_MODE`를 `database`로 고정. 운영도 reconcile 후 동일하게 고정.
- 기존 방식(filesystem) 사용처 계산에 네비게이션 추천 사이트 파비콘(`cachedFaviconUrl`)을 포함해 DB 모드와 맞춤.
- 사이트 설정 미디어 참조 백필 필드명을 camelCase로 정리해 사용처 라벨이 `favicon_url` 원문으로 나오지 않게 수정.
- 기존 방식이 페이지 대표 이미지를 카드 썸네일 상태로만 판정해 `원본` 배지가 빠지던 문제를 수정.
- DB 모드에서 프로필 `avatarOwner`와 `user.avatarUrl` 참조를 중복 합산하던 사용 개수 배지를 기존 방식과 같게 맞춤.
- 프로필에 연결된 미디어 상세에서도 게시물 본문 등 다른 사용처를 함께 표시.
- multipart 업로드에서 busboy가 UTF-8 한글 파일명을 Latin-1로 오해하던 문제를 복구해 macOS NFD 한글 파일명도 NFC로 저장.
- 개발 DB reconcile에서 파일 124개·77,019,754 bytes와 DB 카탈로그의 개수·용량 차이 0 확인.
- DB 모드에서 레거시 `-body.webp`만 있는 원본을 `hasThumbnail`로 오판해 없는 `body-720`을 그리드에 넣어 이미지가 깨지던 문제 수정.
- 카드·본문 파생 존재 판별을 `card`/`body-1600`/`body-720` URL 기준으로 파일시스템 모드와 맞추고, 그리드 배지를 썸네일 프레임 안으로 고정.
- 파생 미리보기 실패 시 Vue 재렌더에도 원본 URL이 유지되도록 폴백 상태를 항목 URL 기준으로 보관.
- 공유형 요청 제한은 현재 단일 서버 운영 방침에 따라 이번 후속 범위에서 제외.
## v1.10.87
- 관리자 사이트 설정을 카드별 부분 저장 API로 변경해 다른 카드의 미저장 입력 덮어쓰기 방지.
- 초안 이탈 직전 자동 저장 실패·진행 중·저장 불가능 상태에서 화면 이동 차단.
- 페이지 작성·수정 화면에 미저장 변경 이탈 확인 추가.
- 저장 요청 중 추가된 페이지 입력을 저장 완료로 오판하지 않도록 서버 전송 payload 기준점 적용.
- 새 페이지 생성 후 이탈이 보류되어도 다음 저장은 생성된 페이지 수정으로 이어지도록 보완.
- 게시물 `:::html` 블록의 정규식 필터를 허용 태그·속성 기반 HTML parser sanitizer로 교체.
- HTML 블록의 이벤트 속성·실행 URL·script/iframe/SVG/form 계열 요소와 인라인 style 차단.
- 외부 새 창 링크에 `noopener noreferrer` 자동 적용 및 `sanitize-html` 의존성 추가.
- 일반 관리자 다중 업로드를 전체 Buffer 적재에서 임시 디스크 스트리밍 방식으로 변경.
- multipart 수신 중 파일 수·전체 용량·종류별 개별 용량을 제한하고 실패 시 임시·이동 파일 정리.
- 미디어 전체 파일·사용처 스캔에 30초 공유 캐시와 동시 요청 단일 실행 적용.
- 업로드·삭제·이름/폴더 변경·썸네일 재생성 시 미디어 캐시 즉시 무효화.
- Export worker를 서버 시작·30초 주기로 복구하고 PostgreSQL advisory lock으로 다중 인스턴스 중복 실행 차단.
- `croner` 직접 의존성 추가 및 운영 의존성 취약점 0건 재확인.
- 존재하지 않는 `/tag/:slug` 요청이 빈 목록 200 대신 실제 404를 반환하도록 수정.
- 같은 댓글 컴포넌트에서 게시물 slug가 바뀔 때 목록·답글·입력·오류·좋아요 진행 상태 초기화 및 재조회.
- 이전 게시물의 지연된 댓글 응답이 새 게시물 상태를 덮어쓰지 않도록 요청 식별 처리.
- 멤버십 본문 접근 권한과 댓글 조회·작성·좋아요 접근 정책 통일.
- 왼쪽·오른쪽 고정 사이드바의 top·높이를 `--site-top-chrome-height` 기준으로 변경해 어나운스 바 겹침 수정.
- Docker PostgreSQL 호스트 포트를 기본 `127.0.0.1`에만 바인딩하고 `DB_BIND_ADDRESS` 환경 변수 추가.
- 개별 마이그레이션 SQL 실행과 `schema_migrations` 이력 기록을 `psql --single-transaction`으로 원자화.
- Node 내장 테스트 러너 기반 HTML sanitizer·multipart 스트리밍 회귀 테스트 4건 추가.
- `npm run verify`를 문법 검사·회귀 테스트·프로덕션 빌드 순서로 보강.
- 전역 `nosniff`·Referrer Policy·SAMEORIGIN·Permissions Policy와 CSP Report-Only 보안 헤더 추가.
- 공개 통계 pageview·heartbeat에 IP 해시 기준 분당 쓰기 요청 상한 추가.
## v1.10.86
- 게시물 Export ZIP을 전체 Buffer 생성 방식에서 디스크 스트리밍 방식으로 변경.
- Export 자산은 이미지·영상·첨부 파일 원본 바이트를 재압축하지 않고 ZIP store 방식으로 보존.
- 게시물 Import multipart ZIP을 메모리 적재 대신 임시 파일 스트리밍 저장으로 변경.
- Import ZIP 엔트리 수·개별/전체 압축 해제 크기·압축률·Markdown 총량 제한 추가.
- Import 자산을 엔트리별 스트림으로 저장하고 처리 완료 후 임시 ZIP 자동 삭제.
- ZIP 스트리밍용 `archiver`, `unzipper`, `busboy` 직접 의존성 추가.
## v1.10.85
- Nuxt 3.21.9와 전이 의존성 보안 패치 적용 및 운영 의존성 취약점 0건 확인.
- 회원·관리자 세션에 DB 세션 버전 검증을 추가하고 비밀번호 변경 시 기존 세션 무효화.
- 회원·관리자 로그인에 계정·IP 기준 15분 실패 횟수 제한 추가.
- 공개 게시물 목록 API에 기본 표시 개수 상한을 강제하고 응답에서 전체 본문 제외.
- 세션 버전용 `067_users_session_version.sql` 마이그레이션 추가.
- Nuxt 3.21.9 CLI 옵션 해석에 맞춰 로컬 preview host·port 인자를 등호 형식으로 수정.
## v1.10.84
- 블로그 게시용 업데이트 기록 `v1.1`, `v1.2`, `v1.5`, `v1.6`, `v1.10` 초안 작성.
- 각 공지는 직전 개발 구간의 주요 변화를 일반 독자가 이해하기 쉬운 설명으로 정리.
## v1.10.83
- 관리자 대시보드 AdSense 잔고 카드에 지급 계정 통화 기준 안내 추가.

160
lib/csp-report.js Normal file
View File

@@ -0,0 +1,160 @@
import { z } from 'zod'
export const CSP_REPORT_POLICY_VERSION = 'compat-v1'
export const CSP_REPORT_MAX_BYTES = 32 * 1024
export const CSP_REPORT_MAX_ITEMS = 20
const reportBodySchema = z.object({
documentURL: z.string().optional(),
documentUri: z.string().optional(),
'document-uri': z.string().optional(),
blockedURL: z.string().optional(),
blockedUri: z.string().optional(),
'blocked-uri': z.string().optional(),
effectiveDirective: z.string().optional(),
'effective-directive': z.string().optional(),
violatedDirective: z.string().optional(),
'violated-directive': z.string().optional()
}).passthrough()
const legacyReportSchema = z.object({
'csp-report': reportBodySchema
}).passthrough()
const reportingApiReportSchema = z.object({
type: z.string().optional(),
body: reportBodySchema
}).passthrough()
const cspPayloadSchema = z.union([
legacyReportSchema,
reportingApiReportSchema,
z.array(reportingApiReportSchema).min(1).max(CSP_REPORT_MAX_ITEMS)
])
/**
* CSP 수집 API가 허용하는 JSON 계열 미디어 타입인지 확인한다.
* @param {string} contentType - Content-Type 헤더
* @returns {boolean} 허용 여부
*/
export const isSupportedCspReportContentType = (contentType) => {
const mediaType = String(contentType || '').split(';', 1)[0].trim().toLowerCase()
return mediaType === 'application/csp-report'
|| mediaType === 'application/reports+json'
|| mediaType === 'application/json'
|| (mediaType.startsWith('application/') && mediaType.endsWith('+json'))
}
/**
* URL에서 쿼리와 fragment를 제외한 문서 경로만 추출한다.
* @param {string} value - 문서 URL
* @returns {string} 정규화된 문서 경로
*/
export const normalizeCspDocumentPath = (value) => {
try {
const url = new URL(String(value || ''), 'https://csp.invalid')
const pathname = url.pathname
.replace(/\/{2,}/g, '/')
.slice(0, 512)
return pathname.startsWith('/') ? pathname : '/'
} catch {
return '/'
}
}
/**
* 차단 URL에서 경로·쿼리·fragment를 제거하고 origin 수준으로 축약한다.
* @param {string} value - 차단 URL
* @returns {string} 차단 origin 또는 안전한 분류값
*/
export const normalizeCspBlockedOrigin = (value) => {
const normalized = String(value || '').trim().toLowerCase()
if (!normalized) {
return 'unknown'
}
if (['inline', 'eval', 'self'].includes(normalized)) {
return normalized
}
if (/^(data|blob|filesystem):/.test(normalized)) {
return `${normalized.split(':', 1)[0]}:`
}
try {
const url = new URL(normalized)
return ['http:', 'https:'].includes(url.protocol)
? url.origin.slice(0, 255)
: `${url.protocol}`.slice(0, 32)
} catch {
return 'unknown'
}
}
/**
* User-Agent 원문을 저장하지 않도록 브라우저 계열로 축약한다.
* @param {string} userAgent - User-Agent 헤더
* @returns {string} 브라우저 계열
*/
export const classifyCspUserAgentFamily = (userAgent) => {
const value = String(userAgent || '')
if (/Edg\//i.test(value)) {
return 'Edge'
}
if (/Firefox\//i.test(value)) {
return 'Firefox'
}
if (/Chrome\//i.test(value) || /Chromium\//i.test(value)) {
return 'Chrome'
}
if (/Safari\//i.test(value) && /Version\//i.test(value)) {
return 'Safari'
}
return 'Other'
}
/**
* 지시어 이름을 집계 키로 사용할 수 있는 짧은 값으로 정규화한다.
* @param {string} value - CSP 지시어
* @returns {string} 정규화된 지시어
*/
const normalizeDirective = (value) => {
const directive = String(value || '').trim().toLowerCase().split(/\s+/, 1)[0]
return /^[a-z][a-z0-9-]{0,63}$/.test(directive) ? directive : 'unknown'
}
/**
* CSP 보고 payload를 검증하고 개인정보가 최소화된 집계 행으로 변환한다.
* @param {unknown} payload - JSON payload
* @param {string} userAgent - User-Agent 헤더
* @returns {Array<{ policyVersion: string, documentPath: string, effectiveDirective: string, blockedOrigin: string, userAgentFamily: string }>} 집계 행 목록
*/
export const parseCspReportPayload = (payload, userAgent) => {
const parsed = cspPayloadSchema.safeParse(payload)
if (!parsed.success) {
throw new Error('INVALID_CSP_REPORT')
}
const reports = Array.isArray(parsed.data) ? parsed.data : [parsed.data]
return reports.map((report) => {
const body = 'csp-report' in report ? report['csp-report'] : report.body
const documentUrl = body.documentURL || body.documentUri || body['document-uri'] || ''
const blockedUrl = body.blockedURL || body.blockedUri || body['blocked-uri'] || ''
const directive = body.effectiveDirective
|| body['effective-directive']
|| body.violatedDirective
|| body['violated-directive']
|| ''
return {
policyVersion: CSP_REPORT_POLICY_VERSION,
documentPath: normalizeCspDocumentPath(documentUrl),
effectiveDirective: normalizeDirective(directive),
blockedOrigin: normalizeCspBlockedOrigin(blockedUrl),
userAgentFamily: classifyCspUserAgentFamily(userAgent)
}
})
}

View File

@@ -1,35 +1,99 @@
const DANGEROUS_TAG_NAMES = [
'script',
'iframe',
'object',
'embed',
'form',
'input',
'button',
'textarea',
'select',
'meta',
'link',
'base'
import sanitizeHtml from 'sanitize-html'
const ALLOWED_HTML_BLOCK_TAGS = [
'a',
'abbr',
'b',
'blockquote',
'br',
'caption',
'code',
'col',
'colgroup',
'dd',
'details',
'div',
'dl',
'dt',
'em',
'figcaption',
'figure',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'li',
'mark',
'ol',
'p',
'pre',
's',
'small',
'span',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'time',
'tr',
'u',
'ul'
]
const dangerousTagPattern = new RegExp(`<\\s*(${DANGEROUS_TAG_NAMES.join('|')})\\b[\\s\\S]*?<\\s*\\/\\s*\\1\\s*>`, 'gi')
const dangerousSingleTagPattern = new RegExp(`<\\s*(${DANGEROUS_TAG_NAMES.join('|')})\\b[^>]*\\/?>`, 'gi')
const HTML_BLOCK_SANITIZE_OPTIONS = {
allowedTags: ALLOWED_HTML_BLOCK_TAGS,
allowedAttributes: {
'*': ['class', 'title', 'aria-label', 'aria-hidden'],
a: ['href', 'target', 'rel'],
col: ['span', 'width'],
img: ['src', 'alt', 'width', 'height', 'loading'],
li: ['value'],
ol: ['start', 'type'],
td: ['colspan', 'rowspan', 'headers'],
th: ['colspan', 'rowspan', 'headers', 'scope'],
time: ['datetime']
},
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
allowedSchemesAppliedToAttributes: ['href', 'src'],
allowProtocolRelative: false,
nonTextTags: ['script', 'style', 'textarea', 'option', 'template', 'noscript'],
transformTags: {
a: (tagName, attribs) => {
if (attribs.target !== '_blank') {
return { tagName, attribs }
}
const relValues = new Set(String(attribs.rel || '').split(/\s+/).filter(Boolean))
relValues.add('noopener')
relValues.add('noreferrer')
return {
tagName,
attribs: {
...attribs,
rel: [...relValues].join(' ')
}
}
}
}
}
/**
* 본문 HTML 블록에서 실행성 코드와 위험 속성을 제거한다.
* @param {string} value - 원본 HTML
* @returns {string} 정리된 HTML
*/
export const sanitizeHtmlBlock = (value) => String(value || '')
.replace(dangerousTagPattern, '')
.replace(dangerousSingleTagPattern, '')
.replace(/\s+on[a-z]+\s*=\s*"[^"]*"/gi, '')
.replace(/\s+on[a-z]+\s*=\s*'[^']*'/gi, '')
.replace(/\s+on[a-z]+\s*=\s*[^\s>]+/gi, '')
.replace(/\s+(href|src)\s*=\s*"javascript:[^"]*"/gi, '')
.replace(/\s+(href|src)\s*=\s*'javascript:[^']*'/gi, '')
.replace(/\s+(href|src)\s*=\s*javascript:[^\s>]+/gi, '')
.replace(/\s+style\s*=\s*"[^"]*expression\s*\([^"]*"/gi, '')
.replace(/\s+style\s*=\s*'[^']*expression\s*\([^']*'/gi, '')
.trim()
export const sanitizeHtmlBlock = (value) => {
const html = String(value || '').trim()
return html ? sanitizeHtml(html, HTML_BLOCK_SANITIZE_OPTIONS).trim() : ''
}

151
lib/media-catalog.js Normal file
View File

@@ -0,0 +1,151 @@
import { basename, extname, posix } from 'node:path'
import { z } from 'zod'
const mediaUrlSchema = z.string().trim().min(1).max(2048)
const imageExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
const videoExtensions = new Set(['.mp4', '.webm', '.mov'])
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a'])
/**
* PostgreSQL LIKE 검색어의 wildcard 문자를 리터럴로 이스케이프한다.
* SQL에서는 `ESCAPE '!'`와 함께 사용한다.
* @param {string} value - 검색어
* @returns {string} LIKE 리터럴
*/
export const escapePostgresLikeLiteral = (value) => String(value || '')
.replace(/!/g, '!!')
.replace(/%/g, '!%')
.replace(/_/g, '!_')
/**
* 업로드 URL을 카탈로그의 공개 URL과 저장 키로 정규화한다.
* @param {unknown} value - 입력 URL
* @returns {{ url: string, storageKey: string }} 정규화 결과
*/
export const normalizeMediaCatalogUrl = (value) => {
const input = mediaUrlSchema.parse(value)
const pathOnly = input.split(/[?#]/, 1)[0]
let decodedPath = ''
try {
decodedPath = decodeURIComponent(pathOnly.replace(/%(?![0-9a-f]{2})/gi, '%25'))
} catch {
throw new Error('MEDIA_URL_INVALID_ENCODING')
}
if (decodedPath.includes('\0') || decodedPath.includes('\\')) {
throw new Error('MEDIA_URL_INVALID_PATH')
}
const normalizedPath = posix.normalize(decodedPath)
const rawSegments = decodedPath.split('/')
const normalizedSegments = normalizedPath.split('/')
if (rawSegments.some((segment) => segment === '.' || segment === '..')
|| normalizedSegments.some((segment) => segment === '..')
|| normalizedPath !== decodedPath.replace(/\/+/g, '/')) {
throw new Error('MEDIA_URL_TRAVERSAL')
}
if (!normalizedPath.startsWith('/uploads/') || normalizedPath === '/uploads/') {
throw new Error('MEDIA_URL_OUTSIDE_UPLOADS')
}
const storageKey = normalizedPath.slice('/uploads/'.length)
return {
url: `/uploads/${storageKey}`,
storageKey
}
}
/**
* 확장자에서 미디어 종류를 판별한다.
* @param {string} fileName - 파일명
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
*/
export const detectMediaKind = (fileName) => {
const extension = extname(String(fileName || '')).toLowerCase()
if (imageExtensions.has(extension)) {
return 'image'
}
if (videoExtensions.has(extension)) {
return 'video'
}
if (audioExtensions.has(extension)) {
return 'audio'
}
return 'file'
}
/**
* 저장 키에서 미디어 역할과 원본 후보 키를 판별한다.
* @param {string} storageKey - 업로드 루트 기준 저장 키
* @returns {{ role: string, parentStorageKey: string|null }} 역할 판별 결과
*/
export const detectMediaRole = (storageKey) => {
const key = String(storageKey || '').replace(/^\/+/, '')
const fileName = basename(key)
const directory = posix.dirname(key)
const thumbsDirectory = directory.endsWith('/thumbs')
const derivedMatch = fileName.match(/^(.*)-(card|page-cover|body(?:-(?:720|1600|2560))?|lightbox)\.webp$/i)
if (key.startsWith('exports/')) {
return { role: 'export', parentStorageKey: null }
}
if (key.includes('/members/avatars/') || key.startsWith('members/avatars/')) {
return { role: 'avatar', parentStorageKey: null }
}
if (/^(system|site)\//.test(key) || key.startsWith('tags/')) {
return { role: 'system', parentStorageKey: null }
}
if (thumbsDirectory && derivedMatch) {
const originalDirectory = posix.dirname(directory)
const parentCandidates = ['.jpg', '.jpeg', '.png', '.webp', '.gif']
const parentStorageKey = parentCandidates
.map((extension) => posix.join(originalDirectory, `${derivedMatch[1]}${extension}`))[0]
const variant = derivedMatch[2].toLowerCase()
const role = variant === 'card'
? 'card'
: variant === 'page-cover'
? 'page_cover'
: 'body'
return { role, parentStorageKey }
}
return { role: 'original', parentStorageKey: null }
}
/**
* 문자열 필드에서 로컬 미디어 URL 참조를 중복 없이 추출한다.
* @param {Record<string, unknown>} fields - 필드명별 값
* @returns {Array<{ mediaUrl: string, referenceField: string }>} 참조 목록
*/
export const extractMediaReferences = (fields) => {
const references = new Map()
const mediaPattern = /\/uploads\/[^\s"'<>()[\]{}]+/g
for (const [referenceField, rawValue] of Object.entries(fields || {})) {
if (typeof rawValue !== 'string' || !rawValue.includes('/uploads/')) {
continue
}
for (const match of rawValue.matchAll(mediaPattern)) {
const candidate = match[0].replace(/[.,;:!?]+$/g, '')
try {
const { url } = normalizeMediaCatalogUrl(candidate)
references.set(`${referenceField}\0${url}`, {
mediaUrl: url,
referenceField
})
} catch {
continue
}
}
}
return [...references.values()]
}

View File

@@ -97,6 +97,20 @@ export const getPostBodyThumbnailUrl = (imageUrl) => getPostDerivativeImageUrl(i
*/
export const getPostBodyPreviewThumbnailUrl = (imageUrl) => getPostDerivativeImageUrl(imageUrl, 'body-720')
/**
* 본문 1600 파생 이미지 URL인지 확인한다.
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
* @returns {boolean} body-1600이면 true
*/
export const isPostBody1600ThumbnailUrl = (imageUrl) => /\/thumbs\/[^/?#]+-body-1600\.webp$/i.test(String(imageUrl || ''))
/**
* 본문 720 파생 이미지 URL인지 확인한다.
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
* @returns {boolean} body-720이면 true
*/
export const isPostBody720ThumbnailUrl = (imageUrl) => /\/thumbs\/[^/?#]+-body-720\.webp$/i.test(String(imageUrl || ''))
/**
* 게시물 원본 이미지 URL에 대응하는 라이트박스용 이미지 URL을 만든다.
* @param {string|null|undefined} imageUrl - 원본 이미지 URL

3953
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.10.83",
"version": "1.10.88",
"private": true,
"type": "module",
"imports": {
@@ -13,15 +13,20 @@
"dev": "node scripts/dev-server.js",
"build": "nuxt build",
"lint": "node scripts/check-js-syntax.js",
"test": "npm run build",
"verify": "npm run lint && npm run test",
"preview": "nuxt preview --dotenv .env.development --host 127.0.0.1 --port 43080",
"test": "npm run test:unit",
"test:unit": "node --test tests/*.test.js",
"test:api": "node scripts/run-playwright-tests.js api",
"test:e2e": "node scripts/run-playwright-tests.js e2e",
"verify": "npm run lint && npm run test:unit && npm run build",
"verify:ci": "npm run lint && npm run test:unit && npm run test:api && npm run test:e2e && npm run build",
"preview": "nuxt preview --dotenv .env.development --host=127.0.0.1 --port=43080",
"db:migrate": "node scripts/migrate-database.js migrate",
"db:migrate:dev": "MIGRATION_ENV=development ENV_FILE=.env.development node scripts/migrate-database.js migrate",
"db:migrate:dev:status": "MIGRATION_ENV=development ENV_FILE=.env.development node scripts/migrate-database.js status",
"db:migrate:prod": "sh scripts/migrate-production-db.sh migrate",
"db:migrate:prod:status": "sh scripts/migrate-production-db.sh status",
"db:migrate:prod:baseline": "sh scripts/migrate-production-db.sh baseline",
"media:reconcile:dev": "node --env-file=.env.development scripts/reconcile-media-catalog.js",
"images:backfill-post-thumbnails": "node scripts/backfill-post-thumbnails.js",
"images:backfill-post-body-thumbnails": "node scripts/backfill-post-body-thumbnails.js",
"images:backfill-page-cover-thumbnails": "node scripts/backfill-page-cover-thumbnails.js",
@@ -31,12 +36,20 @@
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"archiver": "^8.0.0",
"bcrypt": "^6.0.0",
"nuxt": "^3.21.2",
"busboy": "^1.6.0",
"croner": "^10.0.1",
"nuxt": "^3.21.9",
"postgres": "^3.4.9",
"sanitize-html": "^2.17.6",
"sharp": "^0.34.5",
"unzipper": "^0.12.5",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@playwright/test": "^1.61.1"
}
}

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) {

View File

@@ -180,7 +180,7 @@ const mapLatestPost = (post) => {
return {
title: post.title,
excerpt: createPostSummary(post.excerpt, post.content, {
excerpt: createPostSummary(post.excerpt, '', {
maxLength: 320,
appendEllipsis: false
}),

View File

@@ -81,7 +81,7 @@ const postCards = computed(() => (postsResponse.value?.items || []).map((post) =
return {
title: post.title,
excerpt: createPostSummary(post.excerpt, post.content, {
excerpt: createPostSummary(post.excerpt, '', {
maxLength: 320,
appendEllipsis: false
}),

View File

@@ -8,10 +8,26 @@ import {
const route = useRoute()
const slug = computed(() => String(route.params.slug || ''))
const { data: tags } = await useFetch('/api/tags?tagType=all', {
const { data: tags, error: tagsError } = await useFetch('/api/tags?tagType=all', {
default: () => []
})
const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
if (tagsError.value) {
throw createError({
statusCode: Number(tagsError.value.statusCode || 500),
statusMessage: '태그 정보를 불러오지 못했습니다.'
})
}
if (!tag.value) {
throw createError({
statusCode: 404,
statusMessage: '태그를 찾을 수 없습니다.'
})
}
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({})
})
@@ -48,7 +64,6 @@ const { data: postsResponse } = await useFetch('/api/posts', {
})
})
const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
const hasTagHeaderImage = computed(() => Boolean(tag.value?.headerImageUrl))
/**
@@ -72,7 +87,7 @@ const tagPosts = computed(() => (postsResponse.value?.items || [])
return {
title: post.title,
excerpt: createPostSummary(post.excerpt, post.content, {
excerpt: createPostSummary(post.excerpt, '', {
maxLength: 320,
appendEllipsis: false
}),

42
playwright.config.js Normal file
View File

@@ -0,0 +1,42 @@
import { defineConfig, devices } from '@playwright/test'
const baseURL = 'http://127.0.0.1:43100'
export default defineConfig({
testDir: './tests/playwright',
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL,
trace: 'retain-on-failure'
},
webServer: {
command: 'npx nuxt dev --host=127.0.0.1 --port=43100',
url: baseURL,
reuseExistingServer: false,
timeout: 120000,
env: {
...process.env
}
},
projects: [
{
name: 'api',
testMatch: /api\/.*\.spec\.js/,
use: {
baseURL
}
},
{
name: 'e2e',
testMatch: /e2e\/.*\.spec\.js/,
use: {
...devices['Desktop Chrome'],
baseURL
}
}
]
})

View File

@@ -49,6 +49,11 @@ const envValues = readEnvFile()
const databaseName = process.env.POSTGRES_DB || envValues.POSTGRES_DB || 'sori_studio'
const databaseUser = process.env.POSTGRES_USER || envValues.POSTGRES_USER || 'sori_studio'
if (migrationEnvironment === 'test' && !databaseName.endsWith('_test')) {
console.error(`테스트 DB 이름은 _test로 끝나야 합니다: ${databaseName}`)
process.exit(1)
}
/**
* SQL 문자열 리터럴 이스케이프
* @param {string} value - SQL 문자열 값
@@ -96,7 +101,11 @@ const runCommand = (command, args, options = {}) => {
* @param {string[]} args - compose 하위 명령 인자
* @returns {string[]} 전체 docker compose 인자
*/
const getComposeArgs = (args) => ['compose', '--env-file', envFile, ...args]
const getComposeArgs = (args) => [
'compose',
...(existsSync(join(rootDir, envFile)) ? ['--env-file', envFile] : []),
...args
]
/**
* psql 실행 인자 생성
@@ -234,8 +243,14 @@ const getAppliedMigrationFiles = () => {
* @returns {void}
*/
const runMigrationFile = (fileName) => {
runCommand('docker', getPsqlArgs(['-f', `${containerMigrationsDir}/${fileName}`]))
runSql(`INSERT INTO ${schemaMigrationsTable} (file_name) VALUES ('${escapeSqlLiteral(fileName)}') ON CONFLICT (file_name) DO NOTHING;`)
const recordSql = `INSERT INTO ${schemaMigrationsTable} (file_name) VALUES ('${escapeSqlLiteral(fileName)}') ON CONFLICT (file_name) DO NOTHING;`
runCommand('docker', getPsqlArgs([
'--single-transaction',
'-f',
`${containerMigrationsDir}/${fileName}`,
'-c',
recordSql
]))
}
/**

View File

@@ -0,0 +1,90 @@
import { mkdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
const rootDir = process.cwd()
const composeProjectName = 'sori-studio-playwright'
const databaseName = 'sori_studio_test'
const databasePort = process.env.TEST_DB_PORT || '43091'
const uploadRoot = join(tmpdir(), 'sori-studio-playwright-uploads')
if (!databaseName.endsWith('_test')) {
throw new Error(`테스트 DB 이름은 _test로 끝나야 합니다: ${databaseName}`)
}
export const playwrightTestEnv = {
...process.env,
PLAYWRIGHT_TEST: '1',
DATABASE_URL: `postgres://sori_studio_test:sori_studio_test_password@127.0.0.1:${databasePort}/${databaseName}`,
DATABASE_NAME: databaseName,
POSTGRES_DB: databaseName,
POSTGRES_USER: 'sori_studio_test',
POSTGRES_PASSWORD: 'sori_studio_test_password',
ADMIN_EMAIL: 'playwright-admin@example.com',
ADMIN_PASSWORD: 'playwright-admin-password',
MEMBER_SESSION_SECRET: 'playwright-member-session-secret',
ANALYTICS_HASH_SECRET: 'playwright-analytics-secret',
NUXT_PUBLIC_SITE_URL: 'http://127.0.0.1:43100',
NUXT_POST_EXPORT_WORKER: '0',
NUXT_RECOMMENDED_FAVICON_CRON: '0',
MEDIA_CATALOG_MODE: 'database',
SORI_TEST_UPLOAD_ROOT: uploadRoot,
TEST_DB_PORT: databasePort
}
const composeArgs = [
'compose',
'--project-name',
composeProjectName,
'-f',
'docker-compose.test.yml'
]
/**
* 명령을 실행하고 실패 시 즉시 중단한다.
* @param {string} command - 실행 파일
* @param {string[]} args - 실행 인자
* @returns {void}
*/
const run = (command, args) => {
const result = spawnSync(command, args, {
cwd: rootDir,
env: playwrightTestEnv,
stdio: 'inherit'
})
if (result.status !== 0) {
throw new Error(`${command} 실행 실패 (${result.status || 1})`)
}
}
/**
* Playwright 전용 DB와 업로드 디렉터리를 새로 준비한다.
* @returns {void}
*/
export const setupPlaywrightTestEnvironment = () => {
teardownPlaywrightTestEnvironment()
rmSync(uploadRoot, { recursive: true, force: true })
mkdirSync(uploadRoot, { recursive: true })
try {
run('docker', [...composeArgs, 'up', '-d', '--wait'])
} catch (error) {
teardownPlaywrightTestEnvironment()
throw error
}
}
/**
* Playwright 전용 자원만 정리한다.
* @returns {void}
*/
export const teardownPlaywrightTestEnvironment = () => {
spawnSync('docker', [...composeArgs, 'down', '--volumes'], {
cwd: rootDir,
env: playwrightTestEnv,
stdio: 'inherit'
})
rmSync(uploadRoot, { recursive: true, force: true })
}

View File

@@ -0,0 +1,40 @@
import postgres from 'postgres'
const databaseUrl = String(process.env.DATABASE_URL || '').trim()
if (!databaseUrl) {
throw new Error('DATABASE_URL_REQUIRED')
}
const parsedDatabaseUrl = new URL(databaseUrl)
const allowedHosts = new Set(['127.0.0.1', 'localhost', '::1'])
const expectedDatabase = decodeURIComponent(parsedDatabaseUrl.pathname.replace(/^\/+/, ''))
if (process.env.NODE_ENV === 'production' || !allowedHosts.has(parsedDatabaseUrl.hostname)) {
throw new Error('MEDIA_CATALOG_RECONCILE_LOCAL_DATABASE_ONLY')
}
const database = postgres(databaseUrl, {
max: 1
})
try {
const identityRows = await database`
SELECT current_database() AS database_name
`
const databaseName = identityRows[0]?.database_name
if (!databaseName || databaseName !== expectedDatabase || /prod|production/i.test(databaseName)) {
throw new Error('MEDIA_CATALOG_DATABASE_IDENTITY_MISMATCH')
}
const { reconcileMediaCatalog } = await import('../server/repositories/media-catalog-repository.js')
const report = await reconcileMediaCatalog()
console.info(JSON.stringify({
database: databaseName,
destructiveFileChanges: false,
report
}, null, 2))
} finally {
await database.end()
}

View File

@@ -0,0 +1,37 @@
import { spawnSync } from 'node:child_process'
import {
playwrightTestEnv,
setupPlaywrightTestEnvironment,
teardownPlaywrightTestEnvironment
} from './playwright-test-environment.js'
const project = process.argv[2]
if (!['api', 'e2e'].includes(project)) {
console.error('Playwright 프로젝트는 api 또는 e2e여야 합니다.')
process.exit(1)
}
let exitCode = 1
try {
setupPlaywrightTestEnvironment()
const result = spawnSync(
process.execPath,
['./node_modules/@playwright/test/cli.js', 'test', `--project=${project}`],
{
cwd: process.cwd(),
env: playwrightTestEnv,
stdio: 'inherit'
}
)
exitCode = result.status || 0
} catch (error) {
console.error(error instanceof Error ? error.message : error)
} finally {
teardownPlaywrightTestEnvironment()
}
process.exit(exitCode)

View File

@@ -16,7 +16,7 @@ const deleteAccountSchema = z.object({
* @returns {Promise<{ ok: true }>} 처리 결과
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const parsedBody = deleteAccountSchema.safeParse(await readBody(event))
if (!parsedBody.success) {

View File

@@ -9,7 +9,7 @@ import { removeManagedAvatarAsset } from '../../utils/member-avatar'
* @returns {Promise<{ ok: true }>} 처리 결과
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const user = await getUserById(session.userId)
if (!user) {

View File

@@ -4,6 +4,7 @@ import { requireMemberSession } from '../../utils/member-auth'
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
import { uploadMemberAvatarImage } from '../../utils/member-avatar-upload'
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/media-library'
import { replaceMediaReferences } from '../../repositories/media-catalog-repository'
/**
* 회원 썸네일 업로드 API
@@ -11,7 +12,7 @@ import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../utils/m
* @returns {Promise<{ avatarUrl: string }>} 업로드 결과
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const currentUser = await getUserById(session.userId)
if (!currentUser) {
throw createError({
@@ -29,6 +30,13 @@ export default defineEventHandler(async (event) => {
})
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
await replaceMediaReferences('user', session.userId, {
avatarUrl
}, {
title: currentUser.username
}).catch(() => {
console.warn('[media-catalog] 회원 아바타 참조 갱신 실패')
})
if (currentUser.avatarUrl && currentUser.avatarUrl !== avatarUrl) {
await removeManagedAvatarAsset(currentUser.avatarUrl)

View File

@@ -8,7 +8,7 @@ import { requireMemberSession } from '../../utils/member-auth'
* @returns {Promise<{ available: boolean }>} 사용 가능 여부
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const rawUsername = getQuery(event).username
const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''

View File

@@ -4,6 +4,11 @@ import { createError, readBody } from 'h3'
import { getUserByEmail, touchUserActivity } from '../../repositories/member-repository'
import { setMemberSession } from '../../utils/member-auth'
import { getClientIp } from '../../utils/request-ip'
import {
assertLoginAttemptAllowed,
clearLoginAccountFailures,
recordLoginFailure
} from '../../utils/login-attempt-limit'
const loginSchema = z.object({
email: z.string().trim().email(),
@@ -27,19 +32,34 @@ export default defineEventHandler(async (event) => {
const body = parsedBody.data
const emailNorm = body.email.trim().toLowerCase()
const ip = getClientIp(event)
const attempt = {
scope: 'member',
email: emailNorm,
ip
}
assertLoginAttemptAllowed(event, attempt)
const user = await getUserByEmail(emailNorm)
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
recordLoginFailure(attempt)
throw createError({
statusCode: 401,
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
})
}
setMemberSession(event, { userId: user.id, email: user.email })
clearLoginAccountFailures(attempt)
setMemberSession(event, {
userId: user.id,
email: user.email,
sessionVersion: user.sessionVersion
})
await touchUserActivity({
userId: user.id,
ip: getClientIp(event)
ip
})
return {

View File

@@ -1,5 +1,5 @@
import { getUserById } from '../../repositories/member-repository'
import { getMemberSession, requireMemberSession } from '../../utils/member-auth'
import { getVerifiedMemberSession, requireMemberSession } from '../../utils/member-auth'
/**
* 회원 세션 조회 API
@@ -8,7 +8,9 @@ import { getMemberSession, requireMemberSession } from '../../utils/member-auth'
*/
export default defineEventHandler(async (event) => {
const isOptional = getQuery(event).optional === '1'
const session = isOptional ? getMemberSession(event) : requireMemberSession(event)
const session = isOptional
? await getVerifiedMemberSession(event)
: await requireMemberSession(event)
if (!session) {
return null

View File

@@ -2,7 +2,8 @@ import bcrypt from 'bcrypt'
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { getUserByIdWithPassword, updateMemberPassword } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
import { clearAdminSession } from '../../utils/admin-auth'
const updatePasswordSchema = z.object({
currentPassword: z.string().min(1),
@@ -15,7 +16,7 @@ const updatePasswordSchema = z.object({
* @returns {Promise<{ ok: true }>} 변경 결과
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const parsedBody = updatePasswordSchema.safeParse(await readBody(event))
if (!parsedBody.success) {
@@ -46,6 +47,8 @@ export default defineEventHandler(async (event) => {
userId: session.userId,
passwordHash: nextHash
})
clearMemberSession(event)
clearAdminSession(event)
return { ok: true }
})

View File

@@ -8,7 +8,7 @@ import { createError } from 'h3'
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const user = await getUserProfileById(session.userId)
if (!user) {

View File

@@ -16,7 +16,7 @@ const updateProfileSchema = z.object({
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 수정된 회원 프로필
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const parsedBody = updateProfileSchema.safeParse(await readBody(event))
if (!parsedBody.success) {

View File

@@ -104,11 +104,16 @@ export default defineEventHandler(async (event) => {
passwordHash
})
setMemberSession(event, { userId: created.id, email: created.email })
setMemberSession(event, {
userId: created.id,
email: created.email,
sessionVersion: created.sessionVersion
})
if (created.isAdmin) {
setAdminSession(event, {
userId: created.id,
email: created.email
email: created.email,
sessionVersion: created.sessionVersion
})
}
await touchUserActivity({

View File

@@ -0,0 +1,118 @@
import {
createError,
getRequestHeader,
setResponseStatus
} from 'h3'
import {
CSP_REPORT_MAX_BYTES,
isSupportedCspReportContentType,
parseCspReportPayload
} from '../../lib/csp-report.js'
import { recordCspReports } from '../repositories/csp-report-repository.js'
import { assertCspReportRequestAllowed } from '../utils/csp-report-request-limit.js'
const STORAGE_FAILURE_LOG_INTERVAL_MS = 5 * 60 * 1000
let lastStorageFailureLogAt = 0
/**
* 요청 스트림을 최대 크기 안에서 JSON으로 읽는다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<unknown>} JSON payload
*/
const readLimitedJsonBody = async (event) => {
const contentLengthHeader = String(getRequestHeader(event, 'content-length') || '').trim()
if (contentLengthHeader) {
const contentLength = Number(contentLengthHeader)
if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
throw createError({
statusCode: 400,
message: '올바르지 않은 Content-Length입니다.'
})
}
if (contentLength > CSP_REPORT_MAX_BYTES) {
throw createError({
statusCode: 413,
message: 'CSP 보고 본문이 너무 큽니다.'
})
}
}
const chunks = []
let byteLength = 0
for await (const chunk of event.node.req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
byteLength += buffer.byteLength
if (byteLength > CSP_REPORT_MAX_BYTES) {
throw createError({
statusCode: 413,
message: 'CSP 보고 본문이 너무 큽니다.'
})
}
chunks.push(buffer)
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch {
throw createError({
statusCode: 400,
message: '올바른 JSON CSP 보고서가 아닙니다.'
})
}
}
/**
* 저장 실패를 원문 정보 없이 제한된 빈도로 기록한다.
* @returns {void}
*/
const logStorageFailure = () => {
const now = Date.now()
if (now - lastStorageFailureLogAt < STORAGE_FAILURE_LOG_INTERVAL_MS) {
return
}
lastStorageFailureLogAt = now
console.warn('CSP 위반 집계 저장에 실패했습니다.')
}
/**
* 인증 없는 CSP 위반 보고를 개인정보 최소화 집계로 저장한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<null>} 빈 204 응답
*/
export default defineEventHandler(async (event) => {
assertCspReportRequestAllowed(event)
const contentType = String(getRequestHeader(event, 'content-type') || '')
if (!isSupportedCspReportContentType(contentType)) {
throw createError({
statusCode: 415,
message: '지원하지 않는 CSP 보고 형식입니다.'
})
}
const payload = await readLimitedJsonBody(event)
let reports
try {
reports = parseCspReportPayload(
payload,
String(getRequestHeader(event, 'user-agent') || '')
)
} catch {
throw createError({
statusCode: 400,
message: '올바른 CSP 보고서가 아닙니다.'
})
}
try {
await recordCspReports(reports)
} catch {
logStorageFailure()
}
setResponseStatus(event, 204)
return null
})

View File

@@ -1,7 +1,7 @@
import { getQuery } from 'h3'
import { listPosts } from '../repositories/content-repository'
import { canAccessMembershipContent } from '../repositories/member-repository'
import { getMemberSession } from '../utils/member-auth'
import { getVerifiedMemberSession } from '../utils/member-auth'
import {
DEFAULT_POST_LIST_PAGE_SIZE,
normalizeDisplayCount,
@@ -14,14 +14,12 @@ import {
* @returns {Promise<Array>} 게시물 목록
*/
export default defineEventHandler(async (event) => {
const session = getMemberSession(event)
const session = await getVerifiedMemberSession(event)
const includeMembership = await canAccessMembershipContent(session?.userId)
const query = getQuery(event)
const page = normalizeListPage(query.page)
const limit = query.limit
? normalizeDisplayCount(query.limit, DEFAULT_POST_LIST_PAGE_SIZE)
: null
const offset = limit ? (page - 1) * limit : 0
const limit = normalizeDisplayCount(query.limit, DEFAULT_POST_LIST_PAGE_SIZE)
const offset = (page - 1) * limit
const paginated = query.paginated === '1'
const result = await listPosts({
@@ -38,7 +36,7 @@ export default defineEventHandler(async (event) => {
}
const total = Number(result.total || 0)
const pageSize = limit || DEFAULT_POST_LIST_PAGE_SIZE
const pageSize = limit
return {
items: result.items,

View File

@@ -1,6 +1,6 @@
import { getAdjacentPosts, getPostBySlug } from '../../repositories/content-repository'
import { canAccessMembershipContent } from '../../repositories/member-repository'
import { getMemberSession } from '../../utils/member-auth'
import { getVerifiedMemberSession } from '../../utils/member-auth'
/**
* 공개 게시물 상세 API
@@ -9,7 +9,7 @@ import { getMemberSession } from '../../utils/member-auth'
*/
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
const session = getMemberSession(event)
const session = await getVerifiedMemberSession(event)
const includeMembership = await canAccessMembershipContent(session?.userId)
const [post, adjacent] = await Promise.all([
getPostBySlug(slug, { includeMembership }),

View File

@@ -1,5 +1,6 @@
import { listPostCommentsBySlug } from '../../../repositories/comment-repository'
import { getMemberSession } from '../../../utils/member-auth'
import { canAccessMembershipContent } from '../../../repositories/member-repository'
import { getVerifiedMemberSession } from '../../../utils/member-auth'
/**
* 게시물 댓글 목록 조회 API
@@ -8,8 +9,9 @@ import { getMemberSession } from '../../../utils/member-auth'
*/
export default defineEventHandler(async (event) => {
const slug = String(getRouterParam(event, 'slug') || '')
const session = getMemberSession(event)
const comments = await listPostCommentsBySlug(slug, session?.userId || null)
const session = await getVerifiedMemberSession(event)
const includeMembership = await canAccessMembershipContent(session?.userId)
const comments = await listPostCommentsBySlug(slug, session?.userId || null, includeMembership)
return {
comments

View File

@@ -1,7 +1,10 @@
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { createComment } from '../../../repositories/comment-repository'
import { touchUserActivity } from '../../../repositories/member-repository'
import {
canAccessMembershipContent,
touchUserActivity
} from '../../../repositories/member-repository'
import { requireMemberSession } from '../../../utils/member-auth'
import { getClientIp } from '../../../utils/request-ip'
@@ -16,7 +19,8 @@ const createCommentSchema = z.object({
* @returns {Promise<{ comment: Object }>} 생성 댓글
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const includeMembership = await canAccessMembershipContent(session.userId)
const slug = String(getRouterParam(event, 'slug') || '')
const parsedBody = createCommentSchema.safeParse(await readBody(event))
@@ -31,7 +35,8 @@ export default defineEventHandler(async (event) => {
slug,
userId: session.userId,
body: parsedBody.data.body,
parentId: parsedBody.data.parentId || null
parentId: parsedBody.data.parentId || null,
includeMembership
})
await touchUserActivity({
userId: session.userId,

View File

@@ -1,5 +1,8 @@
import { toggleCommentLike } from '../../../../../repositories/comment-repository'
import { touchUserActivity } from '../../../../../repositories/member-repository'
import {
canAccessMembershipContent,
touchUserActivity
} from '../../../../../repositories/member-repository'
import { requireMemberSession } from '../../../../../utils/member-auth'
import { getClientIp } from '../../../../../utils/request-ip'
@@ -9,14 +12,16 @@ import { getClientIp } from '../../../../../utils/request-ip'
* @returns {Promise<{ liked: boolean, likeCount: number }>} 좋아요 상태
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const session = await requireMemberSession(event)
const includeMembership = await canAccessMembershipContent(session.userId)
const slug = String(getRouterParam(event, 'slug') || '')
const commentId = String(getRouterParam(event, 'commentId') || '')
const result = await toggleCommentLike({
slug,
commentId,
userId: session.userId
userId: session.userId,
includeMembership
})
await touchUserActivity({

View File

@@ -1,6 +1,7 @@
import { createError, getRequestURL } from 'h3'
import { getAdminSession } from '../utils/admin-auth'
import { isPrivilegedMember } from '../repositories/member-repository'
import { clearAdminSession, getAdminSession } from '../utils/admin-auth'
import { clearMemberSession } from '../utils/member-auth'
import { isPrivilegedMemberSession } from '../repositories/member-repository'
/**
* 관리자 API 요청마다 현재 DB 권한을 다시 확인한다.
@@ -23,12 +24,14 @@ export default defineEventHandler(async (event) => {
})
}
const stillPrivileged = await isPrivilegedMember(session.userId)
const stillPrivileged = await isPrivilegedMemberSession(session.userId, session.sessionVersion)
if (!stillPrivileged) {
clearAdminSession(event)
clearMemberSession(event)
throw createError({
statusCode: 403,
message: '현재 관리자 권한이 없습니다. 다시 로그인해 주세요.'
statusCode: 401,
message: '관리자 세션이 만료되었습니다. 다시 로그인해 주세요.'
})
}
})

View File

@@ -6,6 +6,7 @@ import {
} from '../repositories/analytics-repository'
import { getPageBySlug } from '../repositories/content-repository'
import { shouldExcludeAdminAnalytics } from '../utils/analytics-admin-exclusion'
import { applySecurityHeaders } from '../utils/security-headers.js'
/**
* 공개 페이지가 HTML 문서 모드일 때 Nuxt 렌더링 대신 원문 HTML을 응답한다.
@@ -49,6 +50,7 @@ export default defineEventHandler(async (event) => {
setResponseHeader(event, 'content-type', 'text/html; charset=utf-8')
setResponseHeader(event, 'cache-control', 'no-cache')
applySecurityHeaders(event, { enforceCsp: false })
return page.content || ''
})

View File

@@ -0,0 +1,14 @@
import { getRequestURL } from 'h3'
import { applySecurityHeaders } from '../utils/security-headers.js'
/**
* 모든 응답에 브라우저 기본 보안 헤더를 적용한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {void}
*/
export default defineEventHandler((event) => {
const pathname = getRequestURL(event).pathname
applySecurityHeaders(event, {
enforceCsp: !/^\/pages(?:\/|$)/.test(pathname)
})
})

View File

@@ -0,0 +1,29 @@
import { Cron } from 'croner'
import { queuePendingPostExportJobs } from '../repositories/post-export-repository'
/**
* 대기·중단된 게시물 Export 작업 실행을 요청한다.
* @returns {Promise<void>}
*/
const queuePostExportWork = async () => {
try {
await queuePendingPostExportJobs()
} catch (error) {
console.error('[post-export] 작업 복구 확인 실패', error)
}
}
/**
* 서버 시작과 주기 확인으로 게시물 Export 작업을 복구한다.
*/
export default defineNitroPlugin(() => {
if (process.env.NUXT_POST_EXPORT_WORKER === '0' || process.env.PLAYWRIGHT_TEST === '1') {
return
}
queuePostExportWork()
new Cron('*/30 * * * * *', {
protect: true
}, queuePostExportWork)
})

View File

@@ -29,18 +29,24 @@ const getSql = () => getPostgresClient()
const isUndefinedTableError = (error) => String(error?.code || '') === '42P01'
/**
* 게시물 ID 조회
* 댓글 접근 가능한 게시물 ID 조회
* @param {ReturnType<typeof import('postgres').default>} sql - postgres 클라이언트
* @param {string} slug - 게시물 슬러그
* @param {boolean} includeMembership - 멤버십 게시물 허용 여부
* @returns {Promise<string | null>} 게시물 ID
*/
const findPublishedPostIdBySlug = async (sql, slug) => {
const findAccessiblePostIdBySlug = async (sql, slug, includeMembership = false) => {
const rows = await sql`
SELECT id
FROM posts
WHERE slug = ${slug}
AND status = 'published'
AND (
status = 'published'
OR (${includeMembership} = true AND status = 'members')
)
AND (
status = 'members'
OR
published_at IS NULL
OR published_at <= now()
)
@@ -55,14 +61,14 @@ const findPublishedPostIdBySlug = async (sql, slug) => {
* @param {string} slug - 게시물 슬러그
* @returns {Promise<Array<PostComment>>} 댓글 목록
*/
export const listPostCommentsBySlug = async (slug, viewerUserId = null) => {
export const listPostCommentsBySlug = async (slug, viewerUserId = null, includeMembership = false) => {
const sql = getSql()
if (!sql) {
return []
}
const postId = await findPublishedPostIdBySlug(sql, slug)
const postId = await findAccessiblePostIdBySlug(sql, slug, includeMembership)
if (!postId) {
throw createError({
statusCode: 404,
@@ -149,7 +155,7 @@ export const listPostCommentsBySlug = async (slug, viewerUserId = null) => {
/**
* 댓글 생성
* @param {{ slug: string, userId: string, body: string, parentId?: string | null }} input - 댓글 입력값
* @param {{ slug: string, userId: string, body: string, parentId?: string | null, includeMembership?: boolean }} input - 댓글 입력값
* @returns {Promise<PostComment>} 생성된 댓글
*/
export const createComment = async (input) => {
@@ -162,7 +168,7 @@ export const createComment = async (input) => {
})
}
const postId = await findPublishedPostIdBySlug(sql, input.slug)
const postId = await findAccessiblePostIdBySlug(sql, input.slug, Boolean(input.includeMembership))
if (!postId) {
throw createError({
statusCode: 404,
@@ -250,7 +256,7 @@ export const createComment = async (input) => {
/**
* 댓글 좋아요를 토글한다.
* @param {{ slug: string, commentId: string, userId: string }} input - 좋아요 입력값
* @param {{ slug: string, commentId: string, userId: string, includeMembership?: boolean }} input - 좋아요 입력값
* @returns {Promise<{ liked: boolean, likeCount: number }>} 좋아요 결과
*/
export const toggleCommentLike = async (input) => {
@@ -263,7 +269,7 @@ export const toggleCommentLike = async (input) => {
})
}
const postId = await findPublishedPostIdBySlug(sql, input.slug)
const postId = await findAccessiblePostIdBySlug(sql, input.slug, Boolean(input.includeMembership))
if (!postId) {
throw createError({
statusCode: 404,

View File

@@ -42,6 +42,71 @@ import {
getExistingPostThumbnailUrl
} from '../utils/post-thumbnail-image.js'
import { getPostgresClient } from './postgres-client'
import { replaceMediaReferences } from './media-catalog-repository'
import { createPostSummary } from '../../composables/createPostSummary.js'
/**
* 콘텐츠 저장 성공을 방해하지 않고 미디어 참조를 갱신한다.
* @param {string} type - 참조 종류
* @param {string} id - 콘텐츠 id
* @param {Object} input - 저장 입력
* @returns {Promise<void>}
*/
const syncContentMediaReferences = async (type, id, input) => {
try {
await replaceMediaReferences(type, id, {
content: input.content,
featuredImage: input.featuredImage,
ogImage: input.ogImage
}, {
title: input.title,
slug: input.slug,
status: input.status
})
} catch {
console.warn('[media-catalog] 콘텐츠 참조 갱신 실패')
}
}
/**
* 사이트 설정 미디어 참조를 최선 노력 방식으로 갱신한다.
* @param {Object} settings - 현재 사이트 설정
* @returns {Promise<void>}
*/
const syncSiteSettingsMediaReferences = async (settings) => {
try {
await replaceMediaReferences('site_setting', 'site', {
logoUrl: settings.logoUrl,
faviconUrl: settings.faviconUrl,
homeCoverImageUrl: settings.homeCoverImageUrl,
homeCoverDarkImageUrl: settings.homeCoverDarkImageUrl
}, {
title: '사이트 설정',
status: 'system'
})
} catch {
console.warn('[media-catalog] 사이트 설정 참조 갱신 실패')
}
}
/**
* 태그 미디어 참조를 최선 노력 방식으로 갱신한다.
* @param {Object} tag - 현재 태그
* @returns {Promise<void>}
*/
const syncTagMediaReferences = async (tag) => {
try {
await replaceMediaReferences('tag', tag.id, {
headerImageUrl: tag.headerImageUrl
}, {
title: tag.name,
slug: tag.slug,
status: tag.tagType
})
} catch {
console.warn('[media-catalog] 태그 참조 갱신 실패')
}
}
/**
* 게시물 행을 API 응답 구조로 변환
@@ -74,6 +139,23 @@ const mapPostRow = (row) => ({
tags: row.tags || []
})
/**
* 공개 목록 게시물에서 본문을 제외하고 표시용 요약을 만든다.
* @param {Object} post - 게시물 응답
* @returns {Object} 공개 목록 게시물 요약
*/
const mapPublicPostSummary = (post) => {
const { content, ...summary } = post
return {
...summary,
excerpt: createPostSummary(post.excerpt, content, {
maxLength: 320,
appendEllipsis: false
})
}
}
/**
* 관리자 API용 게시물 행 변환(제목 없음 플레이스홀더는 빈 문자열)
* @param {Object} row - 게시물 행
@@ -500,11 +582,12 @@ const syncPostTags = async (sql, postId, tags, tagNames = {}) => {
/**
* 공개 게시물 목록 조회
* @param {{ includeMembership?: boolean, limit?: number|null, offset?: number, tagSlug?: string, featuredOnly?: boolean, includeTotal?: boolean }} [options] - 조회 옵션
* @param {{ includeMembership?: boolean, includeContent?: boolean, limit?: number|null, offset?: number, tagSlug?: string, featuredOnly?: boolean, includeTotal?: boolean }} [options] - 조회 옵션
* @returns {Promise<Array|{ items: Array, total: number }>} 게시물 목록
*/
export const listPosts = async ({
includeMembership = false,
includeContent = false,
limit = null,
offset = 0,
tagSlug = '',
@@ -525,16 +608,40 @@ export const listPosts = async ({
samplePosts = samplePosts.filter((post) => post.tags.includes(normalizedTagSlug))
}
const total = samplePosts.length
const items = resolvedLimit
const selectedItems = resolvedLimit
? samplePosts.slice(resolvedOffset, resolvedOffset + resolvedLimit)
: samplePosts
const items = includeContent
? selectedItems
: selectedItems.map(mapPublicPostSummary)
return includeTotal ? { items, total } : items
}
const rows = await sql`
SELECT
posts.*,
posts.id,
posts.title,
posts.slug,
posts.author_id,
CASE
WHEN ${includeContent} = true THEN posts.content
ELSE LEFT(posts.content, 4000)
END AS content,
posts.excerpt,
posts.featured_image,
posts.show_featured_image,
posts.is_featured,
posts.admin_pinned,
posts.seo_title,
posts.seo_description,
posts.canonical_url,
posts.noindex,
posts.og_image,
posts.status,
posts.published_at,
posts.created_at,
posts.updated_at,
(
SELECT COUNT(*)::int
FROM comments
@@ -576,7 +683,10 @@ export const listPosts = async ({
OFFSET ${resolvedOffset}
`
const items = rows.map(mapPostRow)
const mappedRows = rows.map(mapPostRow)
const items = includeContent
? mappedRows
: mappedRows.map(mapPublicPostSummary)
if (!includeTotal) {
return items
@@ -1122,7 +1232,8 @@ export const createAdminPost = async (input, authorId) => {
await Promise.all([
createPostThumbnailForImageUrl(input.featuredImage),
createPostBodyThumbnailsForContent(input.content)
createPostBodyThumbnailsForContent(input.content),
syncContentMediaReferences('post', rows[0].id, input)
])
return getAdminPostById(rows[0].id)
@@ -1178,7 +1289,8 @@ export const updateAdminPost = async (id, input, editorId) => {
if (rows[0]) {
await Promise.all([
createPostThumbnailForImageUrl(input.featuredImage),
createPostBodyThumbnailsForContent(input.content)
createPostBodyThumbnailsForContent(input.content),
syncContentMediaReferences('post', rows[0].id, input)
])
}
@@ -1838,7 +1950,10 @@ export const createAdminPage = async (input) => {
RETURNING *
`
await createPageCoverThumbnailForImageUrl(input.featuredImage)
await Promise.all([
createPageCoverThumbnailForImageUrl(input.featuredImage),
syncContentMediaReferences('page', rows[0].id, input)
])
return mapPageRow(rows[0])
}
@@ -1877,7 +1992,10 @@ export const updateAdminPage = async (id, input) => {
`
if (rows[0]) {
await createPageCoverThumbnailForImageUrl(input.featuredImage)
await Promise.all([
createPageCoverThumbnailForImageUrl(input.featuredImage),
syncContentMediaReferences('page', rows[0].id, input)
])
}
return rows[0] ? mapPageRow(rows[0]) : null
@@ -2315,7 +2433,85 @@ export const updateSiteSettings = async (input) => {
RETURNING *
`
return mapSiteSettingsRow(rows[0])
const settings = mapSiteSettingsRow(rows[0])
await syncSiteSettingsMediaReferences(settings)
return settings
}
/**
* 전달된 사이트 설정 필드만 원자적으로 수정한다.
* @param {Object} input - 부분 사이트 설정 입력값
* @returns {Promise<Object>} 수정된 사이트 설정
*/
export const updateSiteSettingsPartial = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const hasOwn = (key) => Object.prototype.hasOwnProperty.call(input, key)
const values = {}
if (hasOwn('title')) values.title = input.title
if (hasOwn('description')) values.description = input.description
if (hasOwn('siteUrl')) values.site_url = input.siteUrl
if (hasOwn('logoText')) values.logo_text = input.logoText || '井'
if (hasOwn('logoUrl')) values.logo_url = input.logoUrl || ''
if (hasOwn('faviconUrl')) values.favicon_url = input.faviconUrl || ''
if (hasOwn('copyrightText')) values.copyright_text = input.copyrightText
if (hasOwn('socialLinks')) values.social_links = sql.json(normalizeSocialLinks(input.socialLinks))
if (hasOwn('showPostUpdatedAt')) values.show_post_updated_at = Boolean(input.showPostUpdatedAt)
if (hasOwn('postTagLimit')) values.post_tag_limit = normalizePostTagLimit(input.postTagLimit)
if (hasOwn('homeLatestPostLimit')) values.home_latest_post_limit = normalizeDisplayCount(input.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT)
if (hasOwn('postListPageSize')) values.post_list_page_size = normalizeDisplayCount(input.postListPageSize, DEFAULT_POST_LIST_PAGE_SIZE)
if (hasOwn('tagPostListPageSize')) values.tag_post_list_page_size = normalizeDisplayCount(input.tagPostListPageSize, DEFAULT_TAG_POST_LIST_PAGE_SIZE)
if (hasOwn('featuredPostLimit')) values.featured_post_limit = normalizeDisplayCount(input.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT)
if (hasOwn('pageSectionItemLimit')) values.page_section_item_limit = normalizeDisplayCount(input.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT)
if (hasOwn('uploadMaxFileCount')) values.upload_max_file_count = normalizeUploadMaxFileCount(input.uploadMaxFileCount)
if (hasOwn('uploadMaxTotalSize')) values.upload_max_total_size = normalizeUploadMaxTotalSize(input.uploadMaxTotalSize)
if (hasOwn('homeCoverImageUrl')) values.home_cover_image_url = input.homeCoverImageUrl || ''
if (hasOwn('homeCoverDarkImageUrl')) values.home_cover_dark_image_url = input.homeCoverDarkImageUrl || ''
if (hasOwn('homeCoverTitle')) values.home_cover_title = input.homeCoverTitle || ''
if (hasOwn('homeCoverText')) values.home_cover_text = input.homeCoverText || ''
if (hasOwn('brandColor')) values.brand_color = normalizeBrandColor(input.brandColor)
if (hasOwn('announcementEnabled')) values.announcement_enabled = Boolean(input.announcementEnabled)
if (hasOwn('announcementText')) values.announcement_text = input.announcementText || ''
if (hasOwn('announcementUrl')) values.announcement_url = input.announcementUrl || ''
if (hasOwn('announcementBackgroundColor')) values.announcement_background_color = normalizeAnnouncementBackgroundColor(input.announcementBackgroundColor)
if (hasOwn('announcementAlignment')) values.announcement_alignment = normalizeAnnouncementAlignment(input.announcementAlignment)
if (hasOwn('signupBlockedUsernames')) values.signup_blocked_usernames = JSON.stringify(normalizeSignupBlockedUsernames(input.signupBlockedUsernames))
if (hasOwn('adsTxt')) values.ads_txt = input.adsTxt || ''
if (hasOwn('customHeadCode')) values.custom_head_code = input.customHeadCode || ''
if (hasOwn('customFooterCode')) values.custom_footer_code = input.customFooterCode || ''
if (hasOwn('adHomeFeedCode')) values.ad_home_feed_code = input.adHomeFeedCode || ''
if (hasOwn('adHomeInfeedCode')) values.ad_home_infeed_code = input.adHomeInfeedCode || ''
if (hasOwn('adSidebarCode')) values.ad_sidebar_code = input.adSidebarCode || ''
if (hasOwn('adPostSidebarCode')) values.ad_post_sidebar_code = input.adPostSidebarCode || ''
if (hasOwn('adPostTopCode')) values.ad_post_top_code = input.adPostTopCode || ''
if (hasOwn('adPostInArticleCode')) values.ad_post_in_article_code = input.adPostInArticleCode || ''
if (hasOwn('adPostBottomCode')) values.ad_post_bottom_code = input.adPostBottomCode || ''
if (!Object.keys(values).length) {
return getSiteSettings()
}
const rows = await sql`
UPDATE site_settings
SET
${sql(values)},
updated_at = now()
WHERE id = 1
RETURNING *
`
if (!rows[0]) {
throw new Error('SITE_SETTINGS_NOT_FOUND')
}
const settings = mapSiteSettingsRow(rows[0])
await syncSiteSettingsMediaReferences(settings)
return settings
}
/**
@@ -2351,7 +2547,9 @@ export const updateSiteLogo = async (input) => {
RETURNING *
`
return mapSiteSettingsRow(rows[0])
const settings = mapSiteSettingsRow(rows[0])
await syncSiteSettingsMediaReferences(settings)
return settings
}
/**
@@ -2384,7 +2582,9 @@ export const updateSiteHomeCoverImage = async (input) => {
RETURNING *
`
return mapSiteSettingsRow(rows[0])
const settings = mapSiteSettingsRow(rows[0])
await syncSiteSettingsMediaReferences(settings)
return settings
}
/**
@@ -2613,7 +2813,9 @@ export const createAdminTag = async (input) => {
RETURNING *
`
return mapTagRow(rows[0])
const tag = mapTagRow(rows[0])
await syncTagMediaReferences(tag)
return tag
}
/**
@@ -2646,7 +2848,13 @@ export const updateAdminTag = async (id, input) => {
RETURNING *
`
return rows[0] ? mapTagRow(rows[0]) : null
if (!rows[0]) {
return null
}
const tag = mapTagRow(rows[0])
await syncTagMediaReferences(tag)
return tag
}
/**

View File

@@ -0,0 +1,50 @@
import { getPostgresClient } from './postgres-client.js'
/**
* CSP 위반을 원본 없이 정규화된 차원별로 집계한다.
* @param {Array<{ policyVersion: string, documentPath: string, effectiveDirective: string, blockedOrigin: string, userAgentFamily: string }>} reports - CSP 집계 행
* @returns {Promise<void>}
*/
export const recordCspReports = async (reports) => {
const sql = getPostgresClient()
if (!sql || reports.length === 0) {
return
}
await sql.begin(async (transaction) => {
for (const report of reports) {
await transaction`
INSERT INTO csp_violation_aggregates (
policy_version,
document_path,
effective_directive,
blocked_origin,
user_agent_family,
violation_count,
first_seen_at,
last_seen_at
)
VALUES (
${report.policyVersion},
${report.documentPath},
${report.effectiveDirective},
${report.blockedOrigin},
${report.userAgentFamily},
1,
now(),
now()
)
ON CONFLICT (
policy_version,
document_path,
effective_directive,
blocked_origin,
user_agent_family
)
DO UPDATE SET
violation_count = csp_violation_aggregates.violation_count + 1,
last_seen_at = now()
`
}
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -130,6 +130,7 @@ const mapAdminMemberRow = (row) => {
* @property {string} avatarUrl - 아바타 URL
* @property {boolean} isAdmin - 관리자 여부
* @property {'owner' | 'admin' | 'vip' | 'member'} role - 권한 코드
* @property {number} sessionVersion - 세션 무효화 버전
* @property {string} createdAt - 생성 시각(ISO)
* @property {string} updatedAt - 수정 시각(ISO)
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
@@ -169,6 +170,7 @@ export const getUserByEmail = async (email) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -313,6 +315,7 @@ export const createUser = async (input) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -367,6 +370,7 @@ export const upsertBootstrapOwner = async (input) => {
UPDATE users
SET
password_hash = ${input.passwordHash},
session_version = session_version + 1,
is_admin = true,
user_role = ${MEMBER_ROLE.OWNER},
updated_at = now()
@@ -379,6 +383,7 @@ export const upsertBootstrapOwner = async (input) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -406,6 +411,7 @@ export const upsertBootstrapOwner = async (input) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -480,6 +486,7 @@ export const updateMemberPasswordByEmail = async (input) => {
UPDATE users
SET
password_hash = ${input.passwordHash},
session_version = session_version + 1,
updated_at = now()
WHERE lower(email) = lower(${input.email})
RETURNING id
@@ -499,6 +506,7 @@ export const updateMemberPassword = async (input) => {
UPDATE users
SET
password_hash = ${input.passwordHash},
session_version = session_version + 1,
updated_at = now()
WHERE id = ${input.userId}
`
@@ -787,6 +795,7 @@ export const getAdminUserByEmail = async (email) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -826,16 +835,37 @@ export const getMemberBootstrapState = async () => {
}
/**
* 관리자 권한을 가진 회원 여부를 확인한다.
* 회원 세션 버전이 현재 DB 값과 일치하는지 확인한다.
* @param {string} userId - 사용자 ID
* @returns {Promise<boolean>} 관리자 권한 여부
* @param {number} sessionVersion - 세션 토큰 버전
* @returns {Promise<boolean>} 현재 세션 여부
*/
export const isPrivilegedMember = async (userId) => {
export const isMemberSessionCurrent = async (userId, sessionVersion) => {
const sql = requireSql()
const rows = await sql`
SELECT id
FROM users
WHERE id = ${userId}
AND session_version = ${sessionVersion}
LIMIT 1
`
return Boolean(rows?.[0])
}
/**
* 관리자 권한과 세션 버전을 함께 확인한다.
* @param {string} userId - 사용자 ID
* @param {number} sessionVersion - 세션 토큰 버전
* @returns {Promise<boolean>} 현재 관리자 세션 여부
*/
export const isPrivilegedMemberSession = async (userId, sessionVersion) => {
const sql = requireSql()
const rows = await sql`
SELECT id
FROM users
WHERE id = ${userId}
AND session_version = ${sessionVersion}
AND user_role = ANY(${PRIVILEGED_ROLES})
LIMIT 1
`

View File

@@ -1,10 +1,15 @@
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, join, relative } from 'node:path'
import { createZipBuffer } from '../utils/zip-writer'
import { mkdir, rm, stat } from 'node:fs/promises'
import { basename, dirname, extname, join, relative, sep } from 'node:path'
import { writeZipFile } from '../utils/zip-writer'
import { getRuntimeEnvValue } from '../utils/runtime-env'
import { isResendConfigured, sendResendEmail } from '../utils/resend-mail'
import { getDefaultSiteSettings } from '../utils/site-settings'
import { getUploadRoot } from '../utils/upload-root'
import { getPostgresClient } from './postgres-client'
import {
deleteMediaCatalogEntries,
recordMediaCatalogFile
} from './media-catalog-repository'
const EXPORT_STATUS = {
QUEUED: 'queued',
@@ -26,7 +31,7 @@ const DEFAULT_RETENTION_DAYS = 100
const DEFAULT_MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024
const MIN_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
const MAX_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024 * 1024
const UPLOAD_ROOT = join(process.cwd(), 'public', 'uploads')
const UPLOAD_ROOT = getUploadRoot()
const EXPORT_ROOT_NAME = 'exports'
const runningPostExportJobIds = new Set()
const localUploadUrlPattern = /\/uploads\/[^\s"'<>)]*/g
@@ -245,7 +250,8 @@ const buildPostAssetFiles = async (post, postFolder) => {
replacements[url] = localPath
zipFiles.push({
path: `${postFolder}/${assetFolder}/${fileName}`,
data: await readFile(diskPath)
sourcePath: diskPath,
store: true
})
} catch {
replacements[url] = url
@@ -262,7 +268,7 @@ const buildPostAssetFiles = async (post, postFolder) => {
* 게시물을 Obsidian 친화적인 Markdown 파일 묶음으로 변환한다.
* @param {Object} post - 게시물
* @param {Set<string>} usedFolderNames - 사용한 폴더명
* @returns {Promise<Array<{path: string, data: Buffer|string}>>} ZIP 내부 파일 목록
* @returns {Promise<Array<{path: string, data?: Buffer|string, sourcePath?: string, store?: boolean}>>} ZIP 내부 파일 목록
*/
const buildPostZipFiles = async (post, usedFolderNames) => {
const postFolder = createUniqueSegment(post.title || post.slug || post.id, usedFolderNames)
@@ -724,6 +730,7 @@ export const cleanupExpiredPostExportJobs = async () => {
FROM post_export_files
WHERE job_id = ANY(${jobIds})
`
const deletedMediaUrls = []
for (const file of fileRows) {
if (!file.file_path) {
@@ -734,9 +741,11 @@ export const cleanupExpiredPostExportJobs = async () => {
if (absolutePath) {
await rm(absolutePath, { force: true })
deletedMediaUrls.push(`/uploads/${String(file.file_path).split(sep).join('/').replace(/^\/+/, '')}`)
}
}
await deleteMediaCatalogEntries(deletedMediaUrls)
await sql`
DELETE FROM post_export_jobs
WHERE id = ANY(${jobIds})
@@ -790,8 +799,8 @@ export const listPostExportJobs = async () => {
}
/**
* 실행 가능한 대기 작업 ID 목록 조회
* @returns {Promise<Array<string>>} 대기 작업 ID 목록
* 실행하거나 재개할 Export 작업 ID 목록 조회
* @returns {Promise<Array<string>>} 실행 가능한 작업 ID 목록
*/
export const listQueuedPostExportJobIds = async () => {
const sql = getPostgresClient()
@@ -803,7 +812,7 @@ export const listQueuedPostExportJobIds = async () => {
const rows = await sql`
SELECT id
FROM post_export_jobs
WHERE status = ${EXPORT_STATUS.QUEUED}
WHERE status IN (${EXPORT_STATUS.QUEUED}, ${EXPORT_STATUS.PROCESSING})
ORDER BY created_at ASC
LIMIT 3
`
@@ -1167,7 +1176,6 @@ const createPostExportZipFile = async (job, file) => {
zipEntries.push(...await buildPostZipFiles(post, usedFolderNames))
}
const zipBuffer = createZipBuffer(zipEntries)
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
@@ -1175,11 +1183,18 @@ const createPostExportZipFile = async (job, file) => {
const absolutePath = join(UPLOAD_ROOT, relativePath)
await mkdir(dirname(absolutePath), { recursive: true })
await writeFile(absolutePath, zipBuffer)
await rm(absolutePath, { force: true })
await writeZipFile(absolutePath, zipEntries)
const fileStat = await stat(absolutePath)
await recordMediaCatalogFile(`/uploads/${relativePath.split(sep).join('/')}`, {
origin: 'system',
role: 'export',
category: 'exports'
})
return {
relativePath,
fileSizeBytes: zipBuffer.length,
fileSizeBytes: fileStat.size,
postCount: posts.length
}
}
@@ -1197,8 +1212,24 @@ export const runPostExportJob = async (jobId) => {
}
runningPostExportJobIds.add(jobId)
let reservedSql = null
let advisoryLockAcquired = false
const advisoryLockNamespace = 'sori-post-export'
try {
reservedSql = await sql.reserve()
const lockRows = await reservedSql`
SELECT pg_try_advisory_lock(
hashtext(${advisoryLockNamespace}),
hashtext(${jobId})
) AS acquired
`
advisoryLockAcquired = Boolean(lockRows[0]?.acquired)
if (!advisoryLockAcquired) {
return
}
const job = await getPostExportJobById(jobId)
if (!job || ![EXPORT_STATUS.QUEUED, EXPORT_STATUS.PROCESSING].includes(job.status)) {
@@ -1263,9 +1294,31 @@ export const runPostExportJob = async (jobId) => {
})
}
} catch (error) {
await markPostExportJobFailed(sql, jobId, error)
if (advisoryLockAcquired) {
await markPostExportJobFailed(sql, jobId, error)
} else {
console.error('게시물 Export 작업 잠금 획득 실패', error)
}
} finally {
if (reservedSql && advisoryLockAcquired) {
await reservedSql`
SELECT pg_advisory_unlock(
hashtext(${advisoryLockNamespace}),
hashtext(${jobId})
)
`.catch((error) => {
console.error('게시물 Export 작업 잠금 해제 실패', error)
})
}
reservedSql?.release()
runningPostExportJobIds.delete(jobId)
if (advisoryLockAcquired) {
setTimeout(() => {
queuePendingPostExportJobs().catch((error) => {
console.error('후속 게시물 Export 작업 실행 실패', error)
})
}, 0)
}
}
}
@@ -1287,7 +1340,7 @@ export const queuePostExportJobRun = (jobId) => {
}
/**
* 대기 중인 Export 작업을 백그라운드 실행 대기열에 올린다.
* 대기 중이거나 중단된 Export 작업을 백그라운드 실행 대기열에 올린다.
* @returns {Promise<void>}
*/
export const queuePendingPostExportJobs = async () => {
@@ -1389,6 +1442,8 @@ export const deletePostExportJob = async (jobId) => {
return false
}
const deletedMediaUrls = []
for (const file of job.files) {
if (!file.filePath) {
continue
@@ -1398,9 +1453,11 @@ export const deletePostExportJob = async (jobId) => {
if (absolutePath) {
await rm(absolutePath, { force: true })
deletedMediaUrls.push(`/uploads/${String(file.filePath).split(sep).join('/').replace(/^\/+/, '')}`)
}
}
await deleteMediaCatalogEntries(deletedMediaUrls)
await sql`
DELETE FROM post_export_jobs
WHERE id = ${jobId}

View File

@@ -1,12 +1,21 @@
import { mkdir, stat, writeFile } from 'node:fs/promises'
import { createWriteStream } from 'node:fs'
import { mkdir, rm, stat } from 'node:fs/promises'
import { basename, extname, join } from 'node:path'
import { readZipBufferEntries } from '../utils/zip-reader'
import { pipeline } from 'node:stream/promises'
import { openZipFileEntries } from '../utils/zip-reader'
import { upsertMediaMetadataCategory } from '../utils/media-library'
import { getUploadRoot } from '../utils/upload-root'
import { getPostgresClient } from './postgres-client'
import { createAdminPost } from './content-repository'
const UPLOAD_BASE_URL = '/uploads'
const MAX_IMPORT_POSTS = 1000
const MAX_IMPORT_ZIP_ENTRY_COUNT = 5000
const MAX_IMPORT_ENTRY_BYTES = 300 * 1024 * 1024
const MAX_IMPORT_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
const MAX_IMPORT_COMPRESSION_RATIO = 250
const MAX_IMPORT_MARKDOWN_BYTES = 5 * 1024 * 1024
const MAX_IMPORT_TOTAL_MARKDOWN_BYTES = 50 * 1024 * 1024
const MARKDOWN_EXTENSION_PATTERN = /\.md$/i
/**
@@ -252,10 +261,10 @@ const parseMarkdownDocument = (markdown) => {
/**
* ZIP 엔트리를 경로 기준 Map으로 만든다.
* @param {Array<{ path: string, data: Buffer }>} entries - ZIP 엔트리
* @returns {Map<string, Buffer>} 엔트리 맵
* @param {Array<{ path: string }>} entries - ZIP 엔트리
* @returns {Map<string, Object>} 엔트리 맵
*/
const createZipEntryMap = (entries) => new Map(entries.map((entry) => [entry.path, entry.data]))
const createZipEntryMap = (entries) => new Map(entries.map((entry) => [entry.path, entry]))
/**
* Markdown 파일의 상위 폴더를 조회한다.
@@ -324,7 +333,7 @@ const pickUniqueDiskFileName = async (directoryPath, originalName) => {
/**
* Import 자산을 업로드 폴더에 저장한다.
* @param {Object} input - 저장 입력
* @param {Map<string, Buffer>} input.entryMap - ZIP 엔트리 맵
* @param {Map<string, Object>} input.entryMap - ZIP 엔트리 맵
* @param {string} input.postFolder - 게시물 폴더
* @param {Set<string>} input.assetPaths - 자산 경로 목록
* @returns {Promise<{ replacements: Map<string, string>, missingAssets: Array<string> }>} 저장 결과
@@ -333,7 +342,7 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const directoryPath = join(process.cwd(), 'public', 'uploads', 'posts', year, month)
const directoryPath = join(getUploadRoot(), 'posts', year, month)
const replacements = new Map()
const missingAssets = []
@@ -341,18 +350,32 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
for (const assetPath of assetPaths) {
const entryPath = resolveAssetEntryPath(postFolder, assetPath)
const data = entryMap.get(entryPath)
const entry = entryMap.get(entryPath)
if (!data) {
if (!entry) {
missingAssets.push(assetPath)
continue
}
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, basename(entryPath))
await writeFile(filePath, data)
try {
await pipeline(
entry.createReadStream(),
createWriteStream(filePath, {
flags: 'wx'
})
)
} catch (error) {
await rm(filePath, {
force: true
})
throw error
}
const publicUrl = `${UPLOAD_BASE_URL}/posts/${year}/${month}/${fileName}`
await upsertMediaMetadataCategory(publicUrl, '미분류')
await upsertMediaMetadataCategory(publicUrl, '미분류', {
origin: 'import'
})
replacements.set(assetPath, publicUrl)
replacements.set(assetPath.replace(/^\.\//, ''), publicUrl)
@@ -511,35 +534,55 @@ const normalizeIsoDate = (value) => {
/**
* ZIP 엔트리에서 Markdown 게시물 목록을 만든다.
* @param {Array<{ path: string, data: Buffer }>} entries - ZIP 엔트리
* @returns {Array<Object>} Markdown 문서 목록
* @param {Array<{ path: string, uncompressedSize: number, readBuffer: (maxBytes?: number) => Promise<Buffer> }>} entries - ZIP 엔트리
* @returns {Promise<Array<Object>>} Markdown 문서 목록
*/
const collectMarkdownPosts = (entries) => entries
.filter((entry) => MARKDOWN_EXTENSION_PATTERN.test(entry.path))
.map((entry) => ({
path: entry.path,
postFolder: getPostFolder(entry.path),
...parseMarkdownDocument(entry.data.toString('utf8'))
}))
const collectMarkdownPosts = async (entries) => {
const markdownEntries = entries.filter((entry) => MARKDOWN_EXTENSION_PATTERN.test(entry.path))
if (markdownEntries.length > MAX_IMPORT_POSTS) {
throw new Error('IMPORT_POST_LIMIT_EXCEEDED')
}
const markdownPosts = []
let totalMarkdownBytes = 0
for (const entry of markdownEntries) {
totalMarkdownBytes += Number(entry.uncompressedSize || 0)
if (totalMarkdownBytes > MAX_IMPORT_TOTAL_MARKDOWN_BYTES) {
throw new Error('IMPORT_MARKDOWN_SIZE_LIMIT_EXCEEDED')
}
const data = await entry.readBuffer(MAX_IMPORT_MARKDOWN_BYTES)
markdownPosts.push({
path: entry.path,
postFolder: getPostFolder(entry.path),
...parseMarkdownDocument(data.toString('utf8'))
})
}
return markdownPosts
}
/**
* Export ZIP을 게시물로 가져온다.
* @param {{ zipBuffer: Buffer, authorId: string }} input - Import 입력
* @param {{ zipFilePath: string, authorId: string }} input - Import 입력
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
*/
export const importPostsFromExportZip = async ({ zipBuffer, authorId }) => {
const entries = readZipBufferEntries(zipBuffer)
export const importPostsFromExportZip = async ({ zipFilePath, authorId }) => {
const entries = await openZipFileEntries(zipFilePath, {
maxEntryCount: MAX_IMPORT_ZIP_ENTRY_COUNT,
maxEntryBytes: MAX_IMPORT_ENTRY_BYTES,
maxTotalBytes: MAX_IMPORT_TOTAL_BYTES,
maxCompressionRatio: MAX_IMPORT_COMPRESSION_RATIO
})
const entryMap = createZipEntryMap(entries)
const markdownPosts = collectMarkdownPosts(entries)
const markdownPosts = await collectMarkdownPosts(entries)
if (!markdownPosts.length) {
throw new Error('IMPORT_MARKDOWN_NOT_FOUND')
}
if (markdownPosts.length > MAX_IMPORT_POSTS) {
throw new Error('IMPORT_POST_LIMIT_EXCEEDED')
}
const reservedSlugs = new Set()
const importedPosts = []
const warnings = []

View File

@@ -1,5 +1,5 @@
import postgres from 'postgres'
import { getRuntimeEnvValue } from '../utils/runtime-env'
import { getRuntimeEnvValue } from '../utils/runtime-env.js'
let client = null

View File

@@ -6,6 +6,11 @@ import { getAdminUserByEmail, getMemberBootstrapState, touchUserActivity, upsert
import { setMemberSession } from '../../../../utils/member-auth'
import { getRuntimeEnvValue } from '../../../../utils/runtime-env'
import { getClientIp } from '../../../../utils/request-ip'
import {
assertLoginAttemptAllowed,
clearLoginAccountFailures,
recordLoginFailure
} from '../../../../utils/login-attempt-limit'
const loginSchema = z.object({
email: z.string().email(),
@@ -64,6 +69,15 @@ export default defineEventHandler(async (event) => {
}
const body = parsedBody.data
const emailNorm = body.email.trim().toLowerCase()
const ip = getClientIp(event)
const attempt = {
scope: 'admin',
email: emailNorm,
ip
}
assertLoginAttemptAllowed(event, attempt)
const adminUser = await getAdminUserByEmail(body.email) || await createBootstrapAdminUser(body)
const passwordMatched = adminUser
@@ -71,23 +85,27 @@ export default defineEventHandler(async (event) => {
: false
if (!adminUser || !passwordMatched) {
recordLoginFailure(attempt)
throw createError({
statusCode: 401,
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
})
}
clearLoginAccountFailures(attempt)
setAdminSession(event, {
userId: adminUser.id,
email: adminUser.email
email: adminUser.email,
sessionVersion: adminUser.sessionVersion
})
setMemberSession(event, {
userId: adminUser.id,
email: adminUser.email
email: adminUser.email,
sessionVersion: adminUser.sessionVersion
})
await touchUserActivity({
userId: adminUser.id,
ip: getClientIp(event)
ip
})
return {

View File

@@ -17,7 +17,9 @@ export default defineEventHandler(async (event) => {
const member = await getMemberForAdmin(session.userId)
return {
...session,
userId: session.userId,
email: session.email,
role: session.role,
roleCode: member?.roleCode || 'admin',
roleLabel: member?.role || '관리자'
}

View File

@@ -1,5 +1,11 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { listMediaItems } from '../../../utils/media-library'
import { getMediaCatalogMode } from '../../../utils/media-catalog-mode'
import {
getMediaCatalogReadiness,
listMediaCatalogItems,
listPagedMediaCatalogItems
} from '../../../repositories/media-catalog-repository'
import {
isPageCoverThumbnailUrl,
isPostBodyThumbnailUrl,
@@ -172,7 +178,7 @@ const countMediaKinds = (items) => items.reduce((counts, item) => {
* @param {Record<string, unknown>} query - 요청 쿼리
* @returns {Object} 페이지 미디어 응답
*/
const createPagedMediaResponse = (items, query) => {
export const createPagedMediaResponse = (items, query) => {
const tab = normalizeMediaTab(query.tab)
const pageSize = normalizeMediaPageSize(query.pageSize)
const requestedPage = Math.max(1, Number(query.page) || 1)
@@ -220,6 +226,17 @@ const createPagedMediaResponse = (items, query) => {
}
}
/**
* 미디어 목록의 민감정보 없는 비교 수치를 만든다.
* @param {Array<Object>} items - 미디어 항목 목록
* @returns {{ count: number, bytes: number, kinds: Record<string, number> }} 비교 수치
*/
const createMediaComparisonSummary = (items) => ({
count: items.length,
bytes: items.reduce((total, item) => total + Number(item.size || 0), 0),
kinds: countMediaKinds(items)
})
/**
* 관리자 미디어 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -228,10 +245,53 @@ const createPagedMediaResponse = (items, query) => {
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const items = await listMediaItems()
const mode = getMediaCatalogMode()
const query = getQuery(event)
const paged = query.paged === '1' || query.paged === 'true'
let items
if (query.paged === '1' || query.paged === 'true') {
if (mode === 'database') {
try {
const readiness = await getMediaCatalogReadiness()
if (!readiness.ready) {
console.warn('[media-catalog] DB 카탈로그 준비 미완료, 파일시스템으로 대체', {
reason: readiness.reason
})
throw new Error('MEDIA_CATALOG_NOT_READY')
}
if (paged) {
return await listPagedMediaCatalogItems(query)
}
items = await listMediaCatalogItems()
} catch {
console.warn('[media-catalog] DB 목록 조회 실패, 파일시스템으로 대체')
items = await listMediaItems()
}
} else {
items = await listMediaItems()
if (mode === 'shadow') {
listMediaCatalogItems().then((catalogItems) => {
const filesystem = createMediaComparisonSummary(items)
const catalog = createMediaComparisonSummary(catalogItems)
console.info('[media-catalog] shadow 차이', {
count: catalog.count - filesystem.count,
bytes: catalog.bytes - filesystem.bytes,
kinds: Object.fromEntries(Object.keys(filesystem.kinds).map((kind) => [
kind,
Number(catalog.kinds[kind] || 0) - Number(filesystem.kinds[kind] || 0)
]))
})
}).catch(() => {
console.warn('[media-catalog] shadow 비교 실패')
})
}
}
if (paged) {
return createPagedMediaResponse(items, query)
}

View File

@@ -0,0 +1,14 @@
import { requireAdminSession } from '../../../../utils/admin-auth'
import { reconcileMediaCatalog } from '../../../../repositories/media-catalog-repository'
/**
* 관리자 수동 미디어 카탈로그 재동기화 API
* 파일을 삭제하거나 이동하지 않고 발견 파일 upsert와 누락 상태 표시만 수행한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 재동기화 비교 보고
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
return reconcileMediaCatalog()
})

View File

@@ -5,6 +5,7 @@ import {
createPostBodyThumbnailForImageUrl,
createPostThumbnailForImageUrl
} from '../../../../utils/post-thumbnail-image.js'
import { invalidateMediaItemsCache } from '../../../../utils/media-library.js'
/**
* 이미지 파생 파일 재생성 API
@@ -33,6 +34,8 @@ export default defineEventHandler(async (event) => {
})
}
invalidateMediaItemsCache()
return {
thumbnailUrl
}

View File

@@ -4,6 +4,7 @@ import { getMemberForAdmin, updateMemberAvatarByAdmin } from '../../../../../rep
import { removeManagedAvatarAsset } from '../../../../../utils/member-avatar'
import { uploadMemberAvatarImage } from '../../../../../utils/member-avatar-upload'
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../../../utils/media-library'
import { replaceMediaReferences } from '../../../../../repositories/media-catalog-repository'
/**
* 관리자 회원 썸네일 업로드 API
@@ -44,6 +45,13 @@ export default defineEventHandler(async (event) => {
}
await upsertMediaMetadataCategory(avatarUrl, MEDIA_THUMBNAIL_ROOT)
await replaceMediaReferences('user', memberId, {
avatarUrl
}, {
title: updated.username
}).catch(() => {
console.warn('[media-catalog] 회원 아바타 참조 갱신 실패')
})
if (member.avatarUrl && member.avatarUrl !== avatarUrl) {
await removeManagedAvatarAsset(member.avatarUrl)

View File

@@ -9,8 +9,9 @@ import {
} from 'h3'
import { requireAdminSession } from '../../../../../../utils/admin-auth'
import { getReadyPostExportFile } from '../../../../../../repositories/post-export-repository'
import { getUploadRoot } from '../../../../../../utils/upload-root'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const uploadRoot = getUploadRoot()
/**
* Export 파일 경로를 안전하게 해석한다.

View File

@@ -1,6 +1,10 @@
import { createError, readMultipartFormData } from 'h3'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createError } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { importPostsFromExportZip } from '../../../../repositories/post-import-repository'
import { saveMultipartFileToDisk } from '../../../../utils/multipart-file-upload'
const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
@@ -11,43 +15,53 @@ const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
*/
export default defineEventHandler(async (event) => {
const adminSession = requireAdminSession(event)
const formData = await readMultipartFormData(event)
const file = (formData || []).find((part) => part.name === 'file' && part.filename)
if (!file) {
throw createError({
statusCode: 400,
message: 'Import할 ZIP 파일을 선택해 주세요.'
})
}
if (!String(file.filename || '').toLowerCase().endsWith('.zip')) {
throw createError({
statusCode: 400,
message: 'ZIP 파일만 Import할 수 있습니다.'
})
}
if (!file.data?.length) {
throw createError({
statusCode: 400,
message: '비어 있는 ZIP 파일은 Import할 수 없습니다.'
})
}
if (file.data.length > MAX_IMPORT_ZIP_BYTES) {
throw createError({
statusCode: 413,
message: 'Import ZIP 파일은 최대 300MB까지 처리할 수 있습니다.'
})
}
const tempDirectory = await mkdtemp(join(tmpdir(), 'sori-studio-import-'))
const zipFilePath = join(tempDirectory, 'upload.zip')
try {
const uploaded = await saveMultipartFileToDisk(event, {
fieldName: 'file',
filePath: zipFilePath,
maxBytes: MAX_IMPORT_ZIP_BYTES
})
if (!String(uploaded.filename || '').toLowerCase().endsWith('.zip')) {
throw createError({
statusCode: 400,
message: 'ZIP 파일만 Import할 수 있습니다.'
})
}
if (!uploaded.size) {
throw createError({
statusCode: 400,
message: '비어 있는 ZIP 파일은 Import할 수 없습니다.'
})
}
return await importPostsFromExportZip({
zipBuffer: file.data,
zipFilePath,
authorId: adminSession.userId
})
} catch (error) {
if (error?.statusCode) {
throw error
}
if (error?.code === 'MULTIPART_FILE_TOO_LARGE') {
throw createError({
statusCode: 413,
message: 'Import ZIP 파일은 최대 300MB까지 처리할 수 있습니다.'
})
}
if (['MULTIPART_FILE_NOT_FOUND', 'INVALID_MULTIPART_REQUEST'].includes(error?.code)) {
throw createError({
statusCode: 400,
message: 'Import할 ZIP 파일을 선택해 주세요.'
})
}
if (error?.message === 'IMPORT_MARKDOWN_NOT_FOUND') {
throw createError({
statusCode: 400,
@@ -62,6 +76,30 @@ export default defineEventHandler(async (event) => {
})
}
if ([
'ZIP_ENTRY_COUNT_LIMIT_EXCEEDED',
'ZIP_ENTRY_SIZE_LIMIT_EXCEEDED',
'ZIP_TOTAL_SIZE_LIMIT_EXCEEDED',
'ZIP_COMPRESSION_RATIO_LIMIT_EXCEEDED',
'IMPORT_MARKDOWN_SIZE_LIMIT_EXCEEDED'
].includes(error?.message)) {
throw createError({
statusCode: 413,
message: 'ZIP 내부 파일의 개수·압축률 또는 압축 해제 크기가 허용 범위를 초과했습니다.'
})
}
if ([
'INVALID_ZIP_FILE',
'UNSAFE_ZIP_ENTRY_PATH',
'DUPLICATE_ZIP_ENTRY_PATH'
].includes(error?.message)) {
throw createError({
statusCode: 400,
message: '안전하게 처리할 수 없는 ZIP 파일입니다.'
})
}
if (error?.code === '23505') {
throw createError({
statusCode: 409,
@@ -73,5 +111,10 @@ export default defineEventHandler(async (event) => {
statusCode: 500,
message: '게시물 Import를 완료하지 못했습니다.'
})
} finally {
await rm(tempDirectory, {
recursive: true,
force: true
})
}
})

View File

@@ -0,0 +1,93 @@
import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth'
import { parseAdminSiteSettingsInput } from '../../../utils/admin-site-settings-input'
import {
getSiteSettings,
updateSiteSettingsPartial
} from '../../../repositories/content-repository'
const ALLOWED_SITE_SETTINGS_FIELDS = new Set([
'title',
'description',
'siteUrl',
'logoText',
'logoUrl',
'faviconUrl',
'copyrightText',
'socialLinks',
'showPostUpdatedAt',
'postTagLimit',
'homeLatestPostLimit',
'postListPageSize',
'tagPostListPageSize',
'featuredPostLimit',
'pageSectionItemLimit',
'uploadMaxFileCount',
'uploadMaxTotalSize',
'homeCoverImageUrl',
'homeCoverDarkImageUrl',
'homeCoverTitle',
'homeCoverText',
'brandColor',
'announcementEnabled',
'announcementText',
'announcementUrl',
'announcementBackgroundColor',
'announcementAlignment',
'signupBlockedUsernames',
'adsTxt',
'customHeadCode',
'customFooterCode',
'adHomeFeedCode',
'adHomeInfeedCode',
'adSidebarCode',
'adPostSidebarCode',
'adPostTopCode',
'adPostInArticleCode',
'adPostBottomCode'
])
/**
* 관리자 사이트 설정의 전달된 필드만 수정한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 사이트 설정
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const body = await readBody(event)
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw createError({
statusCode: 400,
message: '사이트 설정 입력 형식이 올바르지 않습니다.'
})
}
const fields = Object.keys(body)
if (!fields.length || fields.some((field) => !ALLOWED_SITE_SETTINGS_FIELDS.has(field))) {
throw createError({
statusCode: 400,
message: '수정할 수 없는 사이트 설정 필드가 포함되어 있습니다.'
})
}
const currentSettings = await getSiteSettings()
const parsedBody = parseAdminSiteSettingsInput({
...currentSettings,
...body
})
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '사이트 설정 입력 형식이 올바르지 않습니다.'
})
}
const validatedPatch = Object.fromEntries(fields.map((field) => [
field,
parsedBody.data[field]
]))
return updateSiteSettingsPartial(validatedPatch)
})

View File

@@ -4,6 +4,7 @@ import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
import { getConfiguredUploadRoot } from '../../../../utils/upload-root'
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
@@ -65,7 +66,7 @@ export default defineEventHandler(async (event) => {
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
const assetSuffix = createSystemAssetSuffix()
const coverFileName = `home-cover-${assetSuffix}.webp`
const coverPath = join(directoryPath, coverFileName)

View File

@@ -4,6 +4,7 @@ import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
import { getConfiguredUploadRoot } from '../../../../utils/upload-root'
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
@@ -87,7 +88,7 @@ export default defineEventHandler(async (event) => {
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
const assetSuffix = createSystemAssetSuffix()
const logoFileName = `logo-${assetSuffix}.webp`
const faviconFileName = `favicon-${assetSuffix}.png`

View File

@@ -4,6 +4,7 @@ import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { upsertMediaMetadataCategory } from '../../../../utils/media-library'
import { getConfiguredUploadRoot } from '../../../../utils/upload-root'
const allowedImageTypes = new Set(['image/jpeg', 'image/png', 'image/webp'])
const tagCoverWidth = 720
@@ -64,7 +65,7 @@ export default defineEventHandler(async (event) => {
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'system')
const assetSuffix = createSystemAssetSuffix()
const coverFileName = `tag-cover-${assetSuffix}.webp`
const coverPath = join(directoryPath, coverFileName)

View File

@@ -1,10 +1,9 @@
import { mkdir, stat, writeFile } from 'node:fs/promises'
import { link, mkdir, mkdtemp, rm, unlink } from 'node:fs/promises'
import { extname, join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import { createError } from 'h3'
import {
buildDefaultUploadSizeLimits,
formatUploadSizeLimit,
getUploadFilesTotalSize,
getMaxUploadBytesForKind,
getUploadKind,
getUploadKindLabel,
@@ -14,6 +13,9 @@ import {
import { requireAdminSession } from '../../../utils/admin-auth'
import { getRuntimeEnvNumber } from '../../../utils/runtime-env.js'
import { upsertMediaMetadataCategory } from '../../../utils/media-library'
import { deleteMediaCatalogEntries } from '../../../repositories/media-catalog-repository'
import { saveMultipartFilesToDisk } from '../../../utils/multipart-file-upload'
import { getConfiguredUploadRoot } from '../../../utils/upload-root'
import { getSiteSettings } from '../../../repositories/content-repository'
const allowedUploadTypes = new Map([
@@ -72,7 +74,7 @@ const sanitizePathPart = (value) => String(value || '')
/**
* 파일 확장자 조회
* @param {Object} file - multipart 파일 파트
* @param {{ filename: string, type: string }} file - multipart 파일 정보
* @returns {string} 확장자
*/
const getUploadExtension = (file) => {
@@ -87,20 +89,21 @@ const getUploadExtension = (file) => {
/**
* 업로드 허용 파일인지 확인한다.
* @param {Object} file - multipart 파일 파트
* @param {{ filename: string, type: string }} file - multipart 파일 정보
* @returns {boolean} 허용 여부
*/
const isAllowedUploadFile = (file) => allowedUploadTypes.has(file.type)
|| allowedUploadExtensions.has(extname(file.filename || '').toLowerCase())
/**
* 디렉터리 안에서 비어 있는 저장 파일명을 고른다. 동일 이름이 있으면 `이름-2`, `이름-3` 식으로 넘버링한다.
* 임시 파일을 충돌하지 않는 최종 파일명으로 원자적으로 이동한다.
* @param {string} directoryPath - 저장 디렉터리 절대 경로
* @param {string} stem - 확장자 제외 파일명
* @param {string} extension - 확장자(점 포함, 예: `.png`)
* @param {string} sourcePath - 임시 파일 절대 경로
* @returns {Promise<{ fileName: string, filePath: string }>} 선택된 파일명과 절대 경로
*/
const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
const moveToUniqueDiskFileName = async (directoryPath, stem, extension, sourcePath) => {
let suffix = 1
while (suffix < 10000) {
@@ -108,13 +111,17 @@ const pickUniqueDiskFileName = async (directoryPath, stem, extension) => {
const filePath = join(directoryPath, fileName)
try {
await stat(filePath)
suffix += 1
} catch {
await link(sourcePath, filePath)
await unlink(sourcePath)
return {
fileName,
filePath
}
} catch (error) {
if (error?.code !== 'EEXIST') {
throw error
}
suffix += 1
}
}
@@ -142,79 +149,120 @@ export default defineEventHandler(async (event) => {
const settings = await getSiteSettings()
const uploadMaxFileCount = normalizeUploadMaxFileCount(settings.uploadMaxFileCount)
const uploadMaxTotalSize = normalizeUploadMaxTotalSize(settings.uploadMaxTotalSize)
const formData = await readMultipartFormData(event)
const files = (formData || []).filter((part) => part.name === 'files' && part.filename)
if (!files.length) {
throw createError({
statusCode: 400,
message: '업로드할 파일이 없습니다.'
})
}
if (files.length > uploadMaxFileCount) {
throw createError({
statusCode: 413,
message: `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount}개입니다.`
})
}
const uploadTotalSize = getUploadFilesTotalSize(files)
if (uploadTotalSize > uploadMaxTotalSize) {
throw createError({
statusCode: 413,
message: `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize)})`
})
}
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'posts', year, month)
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'posts', year, month)
await mkdir(directoryPath, { recursive: true })
const tempDirectoryPath = await mkdtemp(join(directoryPath, '.upload-'))
const movedFilePaths = []
const catalogRecordedUrls = []
const uploadedFiles = []
try {
const streamedFiles = await saveMultipartFilesToDisk(event, {
fieldName: 'files',
maxFiles: uploadMaxFileCount,
maxTotalBytes: uploadMaxTotalSize,
createFileTarget: async (file, index) => {
const uploadFile = {
filename: file.filename,
type: file.mimeType
}
for (const file of files) {
if (!isAllowedUploadFile(file)) {
throw createError({
statusCode: 400,
message: '지원하지 않는 파일 형식입니다.'
if (!isAllowedUploadFile(uploadFile)) {
throw createError({
statusCode: 400,
message: '지원하지 않는 파일 형식입니다.'
})
}
const uploadKind = getUploadKind(uploadFile.type, uploadFile.filename)
const maxFileSize = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits)
const originalStem = sanitizePathPart(uploadFile.filename.replace(/\.[^.]+$/g, '')) || 'file'
const extension = getUploadExtension(uploadFile)
return {
filePath: join(tempDirectoryPath, `${index}${extension}`),
maxBytes: maxFileSize,
metadata: {
originalStem,
extension,
uploadKind
}
}
}
})
const uploadedFiles = []
for (const file of streamedFiles) {
const { fileName, filePath } = await moveToUniqueDiskFileName(
directoryPath,
file.metadata.originalStem,
file.metadata.extension,
file.filePath
)
movedFilePaths.push(filePath)
const publicUrl = `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`
await upsertMediaMetadataCategory(publicUrl, '미분류')
catalogRecordedUrls.push(publicUrl)
uploadedFiles.push({
url: publicUrl,
name: fileName,
size: file.size
})
}
const uploadKind = getUploadKind(file.type, file.filename)
const maxFileSize = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits)
return {
files: uploadedFiles
}
} catch (error) {
await Promise.all(movedFilePaths.map((filePath) => unlink(filePath).catch(() => {})))
await deleteMediaCatalogEntries(catalogRecordedUrls).catch(() => {
console.warn('[media-catalog] 업로드 실패 정리 중 카탈로그 갱신 실패')
})
if (file.data.length > maxFileSize) {
if (error?.statusCode) {
throw error
}
if (error?.code === 'MULTIPART_TOO_MANY_FILES') {
throw createError({
statusCode: 413,
message: `${getUploadKindLabel(uploadKind)} 업로드 가능 크기를 초과했습니다. (최대 ${formatUploadSizeLimit(maxFileSize)})`
message: `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount}개입니다.`
})
}
if (error?.code === 'MULTIPART_TOTAL_SIZE_TOO_LARGE') {
throw createError({
statusCode: 413,
message: `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize)})`
})
}
if (error?.code === 'MULTIPART_FILE_TOO_LARGE') {
throw createError({
statusCode: 413,
message: `${getUploadKindLabel(error.metadata?.uploadKind)} 업로드 가능 크기를 초과했습니다. (최대 ${formatUploadSizeLimit(error.maxBytes)})`
})
}
if (['MULTIPART_FILE_NOT_FOUND', 'INVALID_MULTIPART_REQUEST', 'MULTIPART_TOO_MANY_PARTS'].includes(error?.code)) {
throw createError({
statusCode: 400,
message: '업로드할 파일이 없거나 요청 형식이 올바르지 않습니다.'
})
}
const originalStem = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'image'
const extension = getUploadExtension(file)
const { fileName, filePath } = await pickUniqueDiskFileName(directoryPath, originalStem, extension)
await writeFile(filePath, file.data)
const publicUrl = `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`
await upsertMediaMetadataCategory(publicUrl, '미분류')
uploadedFiles.push({
url: publicUrl,
name: fileName,
size: file.data.length
throw createError({
statusCode: 500,
message: '파일을 업로드하지 못했습니다.'
})
} finally {
await rm(tempDirectoryPath, {
recursive: true,
force: true
})
}
return {
files: uploadedFiles
}
})

View File

@@ -9,8 +9,9 @@ import {
setResponseHeader,
setResponseStatus
} from 'h3'
import { getUploadRoot } from '../../utils/upload-root'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const uploadRoot = getUploadRoot()
const contentTypes = new Map([
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],

View File

@@ -51,7 +51,7 @@ const signPayload = (payload) => createHmac('sha256', getSessionSecret())
/**
* 관리자 세션 토큰 생성
* @param {{ userId: string, email: string }} adminUser - 관리자 사용자 정보
* @param {{ userId: string, email: string, sessionVersion?: number }} adminUser - 관리자 사용자 정보
* @returns {string} 세션 토큰
*/
export const createAdminSessionToken = (adminUser) => {
@@ -59,6 +59,7 @@ export const createAdminSessionToken = (adminUser) => {
userId: adminUser.userId,
email: adminUser.email,
role: 'admin',
sessionVersion: Number.isInteger(adminUser.sessionVersion) ? adminUser.sessionVersion : 1,
expiresAt: Date.now() + sessionMaxAge * 1000
})).toString('base64url')
@@ -68,7 +69,7 @@ export const createAdminSessionToken = (adminUser) => {
/**
* 관리자 세션 토큰 검증
* @param {string | undefined} token - 세션 토큰
* @returns {{ userId: string, email: string, role: 'admin' } | null} 세션 정보
* @returns {{ userId: string, email: string, role: 'admin', sessionVersion: number } | null} 세션 정보
*/
export const verifyAdminSessionToken = (token) => {
if (!token) {
@@ -96,14 +97,17 @@ export const verifyAdminSessionToken = (token) => {
return {
userId: session.userId,
email: session.email,
role: 'admin'
role: 'admin',
sessionVersion: Number.isInteger(session.sessionVersion) && session.sessionVersion > 0
? session.sessionVersion
: 1
}
}
/**
* 관리자 세션 쿠키 설정
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {{ userId: string, email: string }} adminUser - 관리자 사용자 정보
* @param {{ userId: string, email: string, sessionVersion?: number }} adminUser - 관리자 사용자 정보
* @returns {void}
*/
export const setAdminSession = (event, adminUser) => {
@@ -130,14 +134,14 @@ export const clearAdminSession = (event) => {
/**
* 관리자 세션 조회
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ userId: string, email: string, role: 'admin' } | null} 세션 정보
* @returns {{ userId: string, email: string, role: 'admin', sessionVersion: number } | null} 세션 정보
*/
export const getAdminSession = (event) => verifyAdminSessionToken(getCookie(event, adminSessionCookieName))
/**
* 관리자 세션 필수 확인
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ userId: string, email: string, role: 'admin' }} 세션 정보
* @returns {{ userId: string, email: string, role: 'admin', sessionVersion: number }} 세션 정보
*/
export const requireAdminSession = (event) => {
const session = getAdminSession(event)

View File

@@ -11,6 +11,7 @@ import {
recordAnalyticsHeartbeat
} from '../repositories/analytics-repository.js'
import { shouldExcludeAdminAnalytics } from './analytics-admin-exclusion.js'
import { shouldAcceptAnalyticsRequest } from './analytics-request-limit.js'
const heartbeatInputSchema = z.object({
path: z.string().trim().min(1).max(500),
@@ -36,6 +37,10 @@ export const handleAnalyticsHeartbeat = async (event) => {
})
}
if (!shouldAcceptAnalyticsRequest(event, 'heartbeat')) {
return { ok: true }
}
const body = parsedBody.data
const userAgent = String(getRequestHeader(event, 'user-agent') || '')

View File

@@ -15,6 +15,7 @@ import {
recordAnalyticsPageview
} from '../repositories/analytics-repository.js'
import { shouldExcludeAdminAnalytics } from './analytics-admin-exclusion.js'
import { shouldAcceptAnalyticsRequest } from './analytics-request-limit.js'
const pageviewInputSchema = z.object({
path: z.string().trim().min(1).max(500),
@@ -40,6 +41,10 @@ export const handleAnalyticsPageview = async (event) => {
})
}
if (!shouldAcceptAnalyticsRequest(event, 'pageview')) {
return { ok: true }
}
const body = parsedBody.data
const userAgent = String(getRequestHeader(event, 'user-agent') || '')

View File

@@ -0,0 +1,71 @@
import { createHash } from 'node:crypto'
import { getClientIp } from './request-ip'
const ANALYTICS_WINDOW_MS = 60 * 1000
const ANALYTICS_PAGEVIEW_LIMIT = 120
const ANALYTICS_HEARTBEAT_LIMIT = 240
const ANALYTICS_BUCKET_LIMIT = 20000
const analyticsBuckets = new Map()
let lastCleanupAt = 0
/**
* 통계 제한 키에 사용할 IP 해시를 생성한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {'pageview'|'heartbeat'} kind - 통계 요청 종류
* @returns {string} 제한 버킷 키
*/
const createAnalyticsLimitKey = (event, kind) => createHash('sha256')
.update(`${kind}:${getClientIp(event)}`)
.digest('hex')
/**
* 만료된 통계 제한 버킷을 정리한다.
* @param {number} now - 현재 시각
* @returns {void}
*/
const cleanupAnalyticsBuckets = (now) => {
if (now - lastCleanupAt < ANALYTICS_WINDOW_MS && analyticsBuckets.size < ANALYTICS_BUCKET_LIMIT) {
return
}
lastCleanupAt = now
for (const [key, bucket] of analyticsBuckets) {
if (bucket.expiresAt <= now) {
analyticsBuckets.delete(key)
}
}
while (analyticsBuckets.size >= ANALYTICS_BUCKET_LIMIT) {
const oldestKey = analyticsBuckets.keys().next().value
if (!oldestKey) {
break
}
analyticsBuckets.delete(oldestKey)
}
}
/**
* 현재 IP의 통계 쓰기 요청을 허용할지 확인한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {'pageview'|'heartbeat'} kind - 통계 요청 종류
* @returns {boolean} 허용 여부
*/
export const shouldAcceptAnalyticsRequest = (event, kind) => {
const now = Date.now()
const limit = kind === 'heartbeat' ? ANALYTICS_HEARTBEAT_LIMIT : ANALYTICS_PAGEVIEW_LIMIT
const key = createAnalyticsLimitKey(event, kind)
cleanupAnalyticsBuckets(now)
const current = analyticsBuckets.get(key)
if (!current || current.expiresAt <= now) {
analyticsBuckets.set(key, {
count: 1,
expiresAt: now + ANALYTICS_WINDOW_MS
})
return true
}
current.count += 1
return current.count <= limit
}

View File

@@ -1,7 +1,8 @@
import { readdir, stat } from 'node:fs/promises'
import { extname, join, relative, sep } from 'node:path'
import { getUploadRoot } from './upload-root'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const uploadRoot = getUploadRoot()
const imageExtensions = new Set(['.avif', '.gif', '.jpg', '.jpeg', '.png', '.svg', '.webp'])
const videoExtensions = new Set(['.mp4', '.mov', '.webm'])
const audioExtensions = new Set(['.m4a', '.mp3', '.ogg', '.wav'])

View File

@@ -0,0 +1,77 @@
import { createHash } from 'node:crypto'
import { createError, setResponseHeader } from 'h3'
import { getClientIp } from './request-ip.js'
const CSP_REPORT_WINDOW_MS = 60 * 1000
const CSP_REPORT_REQUEST_LIMIT = 60
const CSP_REPORT_BUCKET_LIMIT = 10000
/** @type {Map<string, { count: number, resetAt: number }>} */
const cspReportBuckets = new Map()
let lastCleanupAt = 0
/**
* 원문 IP를 메모리에 남기지 않는 CSP 요청 제한 키를 만든다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {string} IP 해시 키
*/
const createCspReportLimitKey = (event) => createHash('sha256')
.update(`csp-report:${getClientIp(event)}`)
.digest('base64url')
/**
* 만료되거나 상한을 넘은 CSP 요청 제한 버킷을 정리한다.
* @param {number} now - 현재 시각
* @returns {void}
*/
const cleanupCspReportBuckets = (now) => {
if (now - lastCleanupAt < CSP_REPORT_WINDOW_MS && cspReportBuckets.size < CSP_REPORT_BUCKET_LIMIT) {
return
}
lastCleanupAt = now
for (const [key, bucket] of cspReportBuckets) {
if (bucket.resetAt <= now) {
cspReportBuckets.delete(key)
}
}
while (cspReportBuckets.size >= CSP_REPORT_BUCKET_LIMIT) {
const oldestKey = cspReportBuckets.keys().next().value
if (!oldestKey) {
break
}
cspReportBuckets.delete(oldestKey)
}
}
/**
* 현재 IP의 CSP 보고 요청이 분당 상한을 넘지 않았는지 확인한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {void}
*/
export const assertCspReportRequestAllowed = (event) => {
const now = Date.now()
const key = createCspReportLimitKey(event)
cleanupCspReportBuckets(now)
const current = cspReportBuckets.get(key)
if (!current || current.resetAt <= now) {
cspReportBuckets.set(key, {
count: 1,
resetAt: now + CSP_REPORT_WINDOW_MS
})
return
}
current.count += 1
if (current.count <= CSP_REPORT_REQUEST_LIMIT) {
return
}
setResponseHeader(event, 'Retry-After', String(Math.max(1, Math.ceil((current.resetAt - now) / 1000))))
throw createError({
statusCode: 429,
message: 'CSP 보고 요청이 너무 많습니다.'
})
}

View File

@@ -0,0 +1,135 @@
import { createHash } from 'node:crypto'
import { createError, setResponseHeader } from 'h3'
const LOGIN_ATTEMPT_WINDOW_MS = 15 * 60 * 1000
const LOGIN_ACCOUNT_FAILURE_LIMIT = 5
const LOGIN_IP_FAILURE_LIMIT = 20
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000
const MAX_ATTEMPT_BUCKET_COUNT = 10000
/** @type {Map<string, { count: number, resetAt: number }>} */
const attemptBuckets = new Map()
let lastCleanupAt = 0
/**
* 로그인 제한 키에 개인정보 원문이 남지 않도록 해시한다.
* @param {string} value - 원본 키
* @returns {string} 해시 키
*/
const createAttemptKey = (value) => createHash('sha256')
.update(String(value || ''))
.digest('base64url')
/**
* 만료된 로그인 제한 버킷을 정리한다.
* @param {number} now - 현재 시각
* @returns {void}
*/
const cleanupExpiredBuckets = (now) => {
if (now - lastCleanupAt < CLEANUP_INTERVAL_MS) {
return
}
lastCleanupAt = now
for (const [key, bucket] of attemptBuckets.entries()) {
if (bucket.resetAt <= now) {
attemptBuckets.delete(key)
}
}
}
/**
* 현재 로그인 제한 버킷을 조회한다.
* @param {string} key - 버킷 키
* @param {number} now - 현재 시각
* @returns {{ count: number, resetAt: number }} 버킷
*/
const getAttemptBucket = (key, now) => {
const current = attemptBuckets.get(key)
if (current && current.resetAt > now) {
return current
}
if (attemptBuckets.size >= MAX_ATTEMPT_BUCKET_COUNT) {
const oldestKey = attemptBuckets.keys().next().value
if (oldestKey) {
attemptBuckets.delete(oldestKey)
}
}
const next = {
count: 0,
resetAt: now + LOGIN_ATTEMPT_WINDOW_MS
}
attemptBuckets.set(key, next)
return next
}
/**
* 로그인 시도 제한용 계정·IP 키를 만든다.
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
* @returns {{ accountKey: string, ipKey: string }} 제한 키
*/
const createLoginAttemptKeys = (input) => ({
accountKey: `account:${createAttemptKey(`${input.scope}:${String(input.email || '').trim().toLowerCase()}`)}`,
ipKey: `ip:${createAttemptKey(`${input.scope}:${String(input.ip || '').trim()}`)}`
})
/**
* 로그인 요청이 현재 제한을 넘지 않았는지 확인한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
* @returns {void}
*/
export const assertLoginAttemptAllowed = (event, input) => {
const now = Date.now()
cleanupExpiredBuckets(now)
const keys = createLoginAttemptKeys(input)
const accountBucket = attemptBuckets.get(keys.accountKey)
const ipBucket = attemptBuckets.get(keys.ipKey)
const accountBlocked = Boolean(accountBucket && accountBucket.resetAt > now && accountBucket.count >= LOGIN_ACCOUNT_FAILURE_LIMIT)
const ipBlocked = Boolean(ipBucket && ipBucket.resetAt > now && ipBucket.count >= LOGIN_IP_FAILURE_LIMIT)
if (!accountBlocked && !ipBlocked) {
return
}
const retryAt = Math.max(
accountBlocked ? accountBucket.resetAt : 0,
ipBlocked ? ipBucket.resetAt : 0
)
const retryAfter = Math.max(1, Math.ceil((retryAt - now) / 1000))
setResponseHeader(event, 'Retry-After', String(retryAfter))
throw createError({
statusCode: 429,
message: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.'
})
}
/**
* 실패한 로그인 시도를 계정과 IP 기준으로 기록한다.
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
* @returns {void}
*/
export const recordLoginFailure = (input) => {
const now = Date.now()
cleanupExpiredBuckets(now)
const keys = createLoginAttemptKeys(input)
for (const key of [keys.accountKey, keys.ipKey]) {
const bucket = getAttemptBucket(key, now)
bucket.count += 1
}
}
/**
* 로그인에 성공한 계정의 실패 기록을 제거한다.
* @param {{ scope: string, email: string, ip: string }} input - 로그인 요청 식별값
* @returns {void}
*/
export const clearLoginAccountFailures = (input) => {
const keys = createLoginAttemptKeys(input)
attemptBuckets.delete(keys.accountKey)
}

View File

@@ -0,0 +1,22 @@
/**
* 미디어 카탈로그 조회 모드를 환경 변수로 해석한다.
*/
/**
* 카탈로그 모드 문자열을 허용 값으로 정규화한다.
* @param {unknown} value - 원본 모드
* @returns {'filesystem'|'shadow'|'database'|null} 정규화된 모드
*/
export const normalizeMediaCatalogMode = (value) => {
const mode = String(value || '').trim().toLowerCase()
return ['filesystem', 'shadow', 'database'].includes(mode) ? mode : null
}
/**
* 현재 적용 중인 미디어 카탈로그 모드를 반환한다.
* @returns {'filesystem'|'shadow'|'database'} 조회 모드
*/
export const getMediaCatalogMode = () => {
return normalizeMediaCatalogMode(process.env.MEDIA_CATALOG_MODE) || 'filesystem'
}

View File

@@ -5,9 +5,14 @@ import {
getSiteSettings,
listAdminTags,
listMediaReferencePages,
listMediaReferencePosts
listMediaReferencePosts,
listNavigationItems
} from '../repositories/content-repository'
import { getPostgresClient } from '../repositories/postgres-client'
import {
deleteMediaCatalogEntries,
recordMediaCatalogFile,
} from '../repositories/media-catalog-repository'
import {
getPageCoverThumbnailUrl,
getLegacyPostBodyThumbnailUrl,
@@ -21,13 +26,30 @@ import {
isPostCardThumbnailUrl,
isPostThumbnailSource
} from './post-thumbnail-image.js'
import { getUploadRoot, resolveUploadUrlPath } from './upload-root'
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const uploadRoot = getUploadRoot()
const mediaFilePattern = /\.(jpe?g|png|webp|gif|mp4|webm|mov|mp3|wav|ogg|m4a|pdf|zip|txt|csv|docx|xlsx|pptx)$/i
const MEDIA_ITEMS_CACHE_TTL_MS = 30 * 1000
let mediaItemsCache = null
let mediaItemsCacheExpiresAt = 0
let mediaItemsLoadPromise = null
let mediaItemsCacheRevision = 0
/** 회원 프로필 이미지 전용 논리 폴더명(디스크 경로와 별도) */
export const MEDIA_THUMBNAIL_ROOT = '썸네일'
/**
* 미디어 목록 캐시를 무효화한다.
* @returns {void}
*/
export const invalidateMediaItemsCache = () => {
mediaItemsCacheRevision += 1
mediaItemsCache = null
mediaItemsCacheExpiresAt = 0
mediaItemsLoadPromise = null
}
/**
* 회원 아바타 공개 URL 여부
* @param {string} url - 미디어 URL
@@ -197,9 +219,10 @@ const getAvatarOwnersByUrls = async (urls) => {
* 미디어 논리 폴더 메타를 upsert한다.
* @param {string} url - 미디어 URL
* @param {string} category - 논리 폴더명
* @param {{ origin?: string, role?: string }} [options] - 카탈로그 기록 옵션
* @returns {Promise<void>}
*/
export const upsertMediaMetadataCategory = async (url, category) => {
export const upsertMediaMetadataCategory = async (url, category, options = {}) => {
const sql = getPostgresClient()
if (!sql) {
@@ -207,21 +230,24 @@ export const upsertMediaMetadataCategory = async (url, category) => {
}
const normalizedCategory = normalizeMediaCategory(category)
const catalogRecorded = await recordMediaCatalogFile(url, {
category: normalizedCategory,
...options
})
await sql`
INSERT INTO media_metadata (
url,
category
)
VALUES (
${url},
${normalizedCategory}
)
ON CONFLICT (url) DO UPDATE
SET
category = EXCLUDED.category,
updated_at = now()
`
if (!catalogRecorded) {
try {
await sql`
INSERT INTO media_metadata (url, category)
VALUES (${url}, ${normalizedCategory})
ON CONFLICT (url) DO UPDATE
SET category = EXCLUDED.category, updated_at = now()
`
} catch {
console.warn('[media-catalog] 호환 메타데이터 기록 실패')
}
}
invalidateMediaItemsCache()
}
/**
@@ -252,13 +278,8 @@ const getMediaMetadataMap = async () => {
*/
export const listMediaFolders = async () => {
const sql = getPostgresClient()
const items = await readMediaDirectory(uploadRoot)
const metadataMap = await getMediaMetadataMap()
const defaultCategories = items.map((item) => {
const rawCategory = metadataMap[item.url]?.category || item.category
return normalizeStoredDisplayCategory(item.url, rawCategory)
})
const items = await listMediaItems()
const defaultCategories = items.map((item) => item.category)
if (!sql) {
return excludeThumbnailFolderPaths([...new Set(['미분류', ...defaultCategories])])
@@ -387,7 +408,7 @@ export const resolveMediaPath = (url) => {
}
const decodedUrl = decodeURIComponent(url)
const filePath = join(process.cwd(), 'public', decodedUrl)
const filePath = resolveUploadUrlPath(decodedUrl)
const relativePath = relative(uploadRoot, filePath)
if (relativePath.startsWith('..') || relativePath === '') {
@@ -541,6 +562,27 @@ const getTagMediaUsage = (url, tags) => tags
label: '태그 헤더 이미지'
}))
/**
* 네비게이션 추천 사이트 파비콘 사용처 조회
* @param {string} url - 미디어 URL
* @param {Array<Object>} navigationItems - 네비게이션 항목 목록
* @returns {Array<Object>} 사용처 목록
*/
const getNavigationMediaUsage = (url, navigationItems = []) => navigationItems
.filter((item) => item.cachedFaviconUrl === url)
.map((item) => ({
type: 'navigation',
typeLabel: '네비게이션',
id: item.id,
title: item.label,
slug: item.url || '',
adminUrl: '/admin/navigation',
publicUrl: item.url || '',
status: 'navigation',
location: 'cachedFaviconUrl',
label: '추천 사이트 아이콘'
}))
/**
* 미디어 URL이 실제 사용처에서 쓰이는지 조회한다.
* @param {string} url - 미디어 URL
@@ -548,12 +590,14 @@ const getTagMediaUsage = (url, tags) => tags
* @param {Array<Object>} pages - 페이지 목록
* @param {Object} siteSettings - 사이트 설정
* @param {Array<Object>} tags - 태그 목록
* @param {Array<Object>} [navigationItems] - 네비게이션 항목 목록
* @returns {Array<Object>} 사용처 목록
*/
const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = []) => [
const getDirectMediaUsage = (url, posts, pages, siteSettings, tags = [], navigationItems = []) => [
...getMediaUsage(url, posts, pages),
...getSiteSettingsMediaUsage(url, siteSettings),
...getTagMediaUsage(url, tags)
...getTagMediaUsage(url, tags),
...getNavigationMediaUsage(url, navigationItems)
]
/**
@@ -763,10 +807,11 @@ const getOriginalDerivativeMediaUsage = (
* @param {Object} siteSettings - 사이트 설정
* @param {Array<Object>} tags - 태그 목록
* @param {{ thumbnailOwnerByUrl: Map<string, string>, pageCoverThumbnailOwnerByUrl: Map<string, string>, bodyThumbnailOwnerByUrl: Map<string, string> }} ownerMaps - 파생 URL별 원본 URL 맵 묶음
* @param {Array<Object>} [navigationItems] - 네비게이션 항목 목록
* @returns {Array<Object>} 사용처 목록
*/
const getOriginalMediaUsage = (url, posts, pages, siteSettings, tags, ownerMaps) => [
...getDirectMediaUsage(url, posts, pages, siteSettings, tags),
const getOriginalMediaUsage = (url, posts, pages, siteSettings, tags, ownerMaps, navigationItems = []) => [
...getDirectMediaUsage(url, posts, pages, siteSettings, tags, navigationItems),
...getOriginalDerivativeMediaUsage(url, ownerMaps.thumbnailOwnerByUrl, posts, pages, siteSettings, tags, {
location: 'featuredImageThumbnail',
label: '목록 카드 썸네일',
@@ -856,6 +901,24 @@ const getPageCoverThumbnailStatus = (item, itemUrlSet, directUsage) => {
}
}
/**
* 원본 업로드 이미지의 카드·페이지 커버 썸네일 상태를 조회한다.
* 페이지 대표 이미지면 페이지 커버 상태를 우선한다.
* @param {Object} item - 미디어 항목
* @param {Set<string>} itemUrlSet - 전체 미디어 URL 집합
* @param {Array<Object>} directUsage - 원본 URL 직접 사용처
* @returns {Object|null} 썸네일 상태
*/
const getOriginalImageThumbnailStatus = (item, itemUrlSet, directUsage) => {
const isPageCoverImage = directUsage.some((usage) => usage.type === 'page' && usage.location === 'featuredImage')
if (isPageCoverImage) {
return getPageCoverThumbnailStatus(item, itemUrlSet, directUsage)
}
return getPostThumbnailStatus(item, itemUrlSet, directUsage) || getPageCoverThumbnailStatus(item, itemUrlSet, directUsage)
}
/**
* 게시물 업로드 이미지의 본문 표시용 이미지 상태를 조회한다.
* @param {Object} item - 미디어 항목
@@ -954,17 +1017,18 @@ const getSiteSettingsMediaUsage = (url, siteSettings) => {
}
/**
* 미디어 목록 조회
* 디스크와 콘텐츠 참조에서 미디어 목록을 새로 구성한다.
* @returns {Promise<Array<Object>>} 미디어 항목 목록
*/
export const listMediaItems = async () => {
const loadMediaItems = async () => {
const items = await readMediaDirectory(uploadRoot)
const metadataMap = await getMediaMetadataMap()
const [posts, pages, siteSettings, tags] = await Promise.all([
const [posts, pages, siteSettings, tags, navigationItems] = await Promise.all([
listMediaReferencePosts(),
listMediaReferencePages(),
getSiteSettings(),
listAdminTags()
listAdminTags(),
listNavigationItems()
])
const avatarOwnerByUrl = await getAvatarOwnersByUrls(items.map((item) => item.url))
const itemUrlSet = new Set(items.map((item) => item.url))
@@ -980,7 +1044,7 @@ export const listMediaItems = async () => {
const rawCategory = metadataMap[item.url]?.category ?? item.category
const category = normalizeStoredDisplayCategory(item.url, rawCategory)
const avatarOwner = avatarOwnerByUrl.get(item.url) || null
const directUsage = getOriginalMediaUsage(item.url, posts, pages, siteSettings, tags, ownerMaps)
const directUsage = getOriginalMediaUsage(item.url, posts, pages, siteSettings, tags, ownerMaps, navigationItems)
const thumbnailUsage = getPostThumbnailMediaUsage(item.url, thumbnailOwnerByUrl, posts, pages, siteSettings)
const pageCoverThumbnailUsage = getPageCoverThumbnailMediaUsage(item.url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings)
const bodyThumbnailUsage = getPostBodyThumbnailMediaUsage(item.url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
@@ -1007,7 +1071,7 @@ export const listMediaItems = async () => {
role: 'pageCover',
originalUrl: pageCoverOriginalUrl
}
: getPostThumbnailStatus(item, itemUrlSet, directUsage) || getPageCoverThumbnailStatus(item, itemUrlSet, directUsage),
: getOriginalImageThumbnailStatus(item, itemUrlSet, directUsage),
bodyThumbnailStatus: bodyOriginalUrl
? {
role: 'body',
@@ -1021,22 +1085,46 @@ export const listMediaItems = async () => {
return itemsWithUsage.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
}
/**
* 짧은 공유 캐시를 사용해 미디어 전체 스캔의 요청별 반복을 막는다.
* @param {{ force?: boolean }} [options] - 조회 옵션
* @returns {Promise<Array<Object>>} 미디어 항목 목록
*/
export const listMediaItems = async (options = {}) => {
const now = Date.now()
if (!options.force && mediaItemsCache && mediaItemsCacheExpiresAt > now) {
return mediaItemsCache
}
if (!options.force && mediaItemsLoadPromise) {
return mediaItemsLoadPromise
}
const revision = mediaItemsCacheRevision
const loadPromise = loadMediaItems().then((items) => {
if (revision === mediaItemsCacheRevision) {
mediaItemsCache = items
mediaItemsCacheExpiresAt = Date.now() + MEDIA_ITEMS_CACHE_TTL_MS
}
return items
}).finally(() => {
if (mediaItemsLoadPromise === loadPromise) {
mediaItemsLoadPromise = null
}
})
mediaItemsLoadPromise = loadPromise
return loadPromise
}
/**
* 미디어 메타데이터 삭제
* @param {string} url - 미디어 URL
* @returns {Promise<void>}
*/
const deleteMediaMetadata = async (url) => {
const sql = getPostgresClient()
if (!sql) {
return
}
await sql`
DELETE FROM media_metadata
WHERE url = ${url}
`
await deleteMediaCatalogEntries([url])
invalidateMediaItemsCache()
}
/**
@@ -1059,6 +1147,7 @@ const moveMediaMetadata = async (currentUrl, nextUrl) => {
updated_at = now()
WHERE url = ${currentUrl}
`
invalidateMediaItemsCache()
}
/**
@@ -1097,21 +1186,26 @@ export const updateMediaCategories = async (urls, category) => {
for (const url of [...new Set(urls.filter(Boolean))]) {
const mediaPath = resolveMediaPath(url)
const catalogRecorded = await recordMediaCatalogFile(url, {
category: normalizedCategory
})
await sql`
INSERT INTO media_metadata (
url,
category
)
VALUES (
${url},
${normalizedCategory}
)
ON CONFLICT (url) DO UPDATE
SET
category = EXCLUDED.category,
updated_at = now()
`
if (!catalogRecorded) {
await sql`
INSERT INTO media_metadata (
url,
category
)
VALUES (
${url},
${normalizedCategory}
)
ON CONFLICT (url) DO UPDATE
SET
category = EXCLUDED.category,
updated_at = now()
`
}
const item = await createMediaItem(mediaPath)
items.push({
@@ -1130,11 +1224,12 @@ export const updateMediaCategories = async (urls, category) => {
* @returns {Promise<void>}
*/
export const deleteMediaItem = async (url) => {
const [posts, pages, siteSettings, tags] = await Promise.all([
const [posts, pages, siteSettings, tags, navigationItems] = await Promise.all([
listMediaReferencePosts(),
listMediaReferencePages(),
getSiteSettings(),
listAdminTags()
listAdminTags(),
listNavigationItems()
])
const items = await readMediaDirectory(uploadRoot)
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
@@ -1146,7 +1241,7 @@ export const deleteMediaItem = async (url) => {
bodyThumbnailOwnerByUrl
}
const usage = [
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps),
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps, navigationItems),
...getPostThumbnailMediaUsage(url, thumbnailOwnerByUrl, posts, pages, siteSettings),
...getPageCoverThumbnailMediaUsage(url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings),
...getPostBodyThumbnailMediaUsage(url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
@@ -1191,11 +1286,12 @@ export const renameMediaItem = async (url, name) => {
})
}
const [posts, pages, siteSettings, tags] = await Promise.all([
const [posts, pages, siteSettings, tags, navigationItems] = await Promise.all([
listMediaReferencePosts(),
listMediaReferencePages(),
getSiteSettings(),
listAdminTags()
listAdminTags(),
listNavigationItems()
])
const items = await readMediaDirectory(uploadRoot)
const thumbnailOwnerByUrl = buildPostThumbnailOwnerMap(items)
@@ -1207,7 +1303,7 @@ export const renameMediaItem = async (url, name) => {
bodyThumbnailOwnerByUrl
}
const usage = [
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps),
...getOriginalMediaUsage(url, posts, pages, siteSettings, tags, ownerMaps, navigationItems),
...getPostThumbnailMediaUsage(url, thumbnailOwnerByUrl, posts, pages, siteSettings),
...getPageCoverThumbnailMediaUsage(url, pageCoverThumbnailOwnerByUrl, posts, pages, siteSettings),
...getPostBodyThumbnailMediaUsage(url, bodyThumbnailOwnerByUrl, posts, pages, siteSettings)
@@ -1260,6 +1356,7 @@ export const renameMediaItem = async (url, name) => {
const renamedItem = await createMediaItem(nextPath)
await moveMediaMetadata(url, renamedItem.url)
await recordMediaCatalogFile(renamedItem.url)
return createMediaItem(nextPath)
}

View File

@@ -1,5 +1,6 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
import { isMemberSessionCurrent } from '../repositories/member-repository'
import { getRuntimeEnvValue } from './runtime-env'
import { shouldUseSecureSessionCookie } from './session-cookie'
@@ -51,13 +52,14 @@ const signPayload = (payload) => createHmac('sha256', getSessionSecret())
/**
* 회원 세션 토큰 생성
* @param {{ userId: string, email: string }} user - 회원 정보
* @param {{ userId: string, email: string, sessionVersion?: number }} user - 회원 정보
* @returns {string} 세션 토큰
*/
export const createMemberSessionToken = (user) => {
const payload = Buffer.from(JSON.stringify({
userId: user.userId,
email: user.email,
sessionVersion: Number.isInteger(user.sessionVersion) ? user.sessionVersion : 1,
expiresAt: Date.now() + sessionMaxAge * 1000
})).toString('base64url')
@@ -67,7 +69,7 @@ export const createMemberSessionToken = (user) => {
/**
* 회원 세션 토큰 검증
* @param {string | undefined} token - 세션 토큰
* @returns {{ userId: string, email: string } | null} 세션 정보
* @returns {{ userId: string, email: string, sessionVersion: number } | null} 세션 정보
*/
export const verifyMemberSessionToken = (token) => {
if (!token) {
@@ -94,14 +96,17 @@ export const verifyMemberSessionToken = (token) => {
return {
userId: session.userId,
email: session.email
email: session.email,
sessionVersion: Number.isInteger(session.sessionVersion) && session.sessionVersion > 0
? session.sessionVersion
: 1
}
}
/**
* 회원 세션 쿠키 설정
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {{ userId: string, email: string }} user - 회원 정보
* @param {{ userId: string, email: string, sessionVersion?: number }} user - 회원 정보
* @returns {void}
*/
export const setMemberSession = (event, user) => {
@@ -128,17 +133,38 @@ export const clearMemberSession = (event) => {
/**
* 회원 세션 조회
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ userId: string, email: string } | null} 세션 정보
* @returns {{ userId: string, email: string, sessionVersion: number } | null} 세션 정보
*/
export const getMemberSession = (event) => verifyMemberSessionToken(getCookie(event, memberSessionCookieName))
/**
* DB 세션 버전까지 확인한 회원 세션을 조회한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ userId: string, email: string, sessionVersion: number } | null>} 현재 세션 정보
*/
export const getVerifiedMemberSession = async (event) => {
const session = getMemberSession(event)
if (!session) {
return null
}
const isCurrent = await isMemberSessionCurrent(session.userId, session.sessionVersion)
if (!isCurrent) {
clearMemberSession(event)
return null
}
return session
}
/**
* 회원 세션 필수 확인
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ userId: string, email: string }} 세션 정보
* @returns {Promise<{ userId: string, email: string, sessionVersion: number }>} 세션 정보
*/
export const requireMemberSession = (event) => {
const session = getMemberSession(event)
export const requireMemberSession = async (event) => {
const session = await getVerifiedMemberSession(event)
if (!session) {
throw createError({

View File

@@ -2,6 +2,7 @@ import { mkdir, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createError, readMultipartFormData } from 'h3'
import sharp from 'sharp'
import { getConfiguredUploadRoot } from './upload-root'
const allowedImageTypes = new Map([
['image/jpeg', '.jpg'],
@@ -134,7 +135,7 @@ export const uploadMemberAvatarImage = async (event) => {
const month = String(now.getMonth() + 1).padStart(2, '0')
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'members', 'avatars', year, month)
const directoryPath = join(getConfiguredUploadRoot(publicBasePath), 'members', 'avatars', year, month)
await mkdir(directoryPath, { recursive: true })

View File

@@ -1,6 +1,8 @@
import { join, relative } from 'node:path'
import { createError } from 'h3'
import { recordMediaCatalogFile } from '../repositories/media-catalog-repository'
import { getPostgresClient } from '../repositories/postgres-client'
import { getUploadRoot } from './upload-root'
const managedAvatarPrefix = '/uploads/members/avatars/'
@@ -25,8 +27,8 @@ export const resolveMemberAvatarPath = (url) => {
}
const decodedUrl = decodeURIComponent(url)
const filePath = join(process.cwd(), 'public', decodedUrl)
const uploadRoot = join(process.cwd(), 'public', 'uploads')
const uploadRoot = getUploadRoot()
const filePath = join(uploadRoot, decodedUrl.replace(/^\/uploads\//, ''))
const relativePath = relative(uploadRoot, filePath)
if (relativePath.startsWith('..') || relativePath === '') {
@@ -40,7 +42,8 @@ export const resolveMemberAvatarPath = (url) => {
}
/**
* 프로필에서 썸네일 URL을 끊을 때 `media_metadata` 행만 제거한다. 디스크 파일은 유지해 관리자 미디어(썸네일 탭)에서 미사용 자산으로 확인·삭제할 수 있게 한다.
* 프로필에서 연결을 끊어도 디스크와 카탈로그 행은 유지한다.
* 참조가 제거된 파일은 관리자 미디어의 미사용 썸네일로 계속 확인할 수 있다.
* @param {string} url - 정리 대상 URL
* @returns {Promise<void>}
*/
@@ -50,13 +53,19 @@ export const removeManagedAvatarAsset = async (url) => {
}
const sql = getPostgresClient()
if (!sql) {
return
if (sql) {
await sql`
DELETE FROM media_references
WHERE media_url = ${url}
AND reference_type = 'user'
`
}
await sql`
DELETE FROM media_metadata
WHERE url = ${url}
`
await recordMediaCatalogFile(url, {
category: '썸네일',
origin: 'upload',
role: 'avatar'
})
}

Some files were not shown because too many files have changed in this diff Show More