v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.
세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
90
docs/spec.md
90
docs/spec.md
@@ -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` 값을 우선한다.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user