From c43873ce5fe0e8e263725e5f99326480d3056e49 Mon Sep 17 00:00:00 2001
From: zenn
Date: Wed, 20 May 2026 13:54:38 +0900
Subject: [PATCH] =?UTF-8?q?v1.3.5:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?=
=?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=C2=B7=EB=8C=80=EC=8B=9C=EB=B3=B4?=
=?UTF-8?q?=EB=93=9C=20=EC=B0=A8=ED=8A=B8=C2=B7=ED=86=B5=EA=B3=84=20?=
=?UTF-8?q?=EB=B3=B4=EA=B4=80=20=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
운영 HTTP에서 관리자 세션이 유지되지 않던 문제를 쿠키 공통화로 수정하고, 통계 클라이언트 분리·조회 오류·기간별 차트를 보강했다. 방문자 해시는 32일 초과분만 정리하고 일별 집계는 누적 보관한다.
Co-authored-by: Cursor
---
docs/deploy.md | 7 +
docs/history.md | 6 +
docs/map.md | 7 +-
docs/spec.md | 12 +-
docs/update.md | 7 +
lib/analytics-shared.js | 142 ++++++++++
lib/analytics.js | 137 ++--------
package-lock.json | 4 +-
package.json | 2 +-
pages/admin/index.vue | 283 ++++++++++++++------
pages/admin/login.vue | 65 ++++-
plugins/site-analytics.client.js | 2 +-
server/repositories/analytics-repository.js | 93 ++++++-
server/utils/admin-auth.js | 7 +-
server/utils/member-auth.js | 3 +-
server/utils/session-cookie.js | 24 ++
16 files changed, 571 insertions(+), 230 deletions(-)
create mode 100644 lib/analytics-shared.js
create mode 100644 server/utils/session-cookie.js
diff --git a/docs/deploy.md b/docs/deploy.md
index 0fa56f8..24d5b51 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -204,6 +204,13 @@ docker compose --env-file .env.production up -d --build
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
- 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다.
+### 통계 데이터 보관 정책
+
+- `site_analytics_daily`, `post_analytics_daily`: 사이트 전체 방문자와 게시물별 조회수의 누적 원본이므로 자동 삭제하지 않는다.
+- `analytics_daily_visitors`: 일별 중복 방문 제거용 해시만 담으므로 32일 초과 행은 통계 수집·관리자 조회 흐름에서 주기적으로 삭제한다.
+- `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다.
+- 관리자 대시보드 차트는 최대 365일 범위를 조회하며, 차트 범위를 넘는 집계도 누적 통계 원본으로 보관한다.
+
### 개발/운영 DB 분리 검증 절차
검증 전제는 실제 비밀번호나 전체 `DATABASE_URL`을 화면 공유, 문서, 커밋 메시지에 노출하지 않는 것이다. 확인할 때는 호스트, 포트, DB 이름, 파일명만 대조한다.
diff --git a/docs/history.md b/docs/history.md
index ab702d4..e0ae594 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,5 +1,11 @@
# 의사결정 이력
+## 2026-05-20 v1.3.5
+
+### 관리자 로그인·대시보드 차트·통계 보관 후속
+
+v1.3.4 통계 확장 이후 운영에서 로그인 쿠키·클라이언트 번들·차트 조회 오류가 겹쳐 후속 정리가 필요했다. 세션 쿠키는 공통 유틸로 묶고, 통계 상수는 `analytics-shared`로 분리했다. 대시보드는 기간별 `trends` 차트와 접속자 목록 가독성을 맞췄다. 저장 용량은 일별 집계는 누적 원본으로 두고 방문자 해시만 32일 초과 시 정리한다.
+
## 2026-05-15 v1.1.18
### 에디터 미디어 UX·발행일·수정일 표시 설정
diff --git a/docs/map.md b/docs/map.md
index 3e99562..06b77d1 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -26,7 +26,8 @@
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
-| lib/analytics.js | 통계 추적 경로 필터·봇 UA·일별 visitor hash |
+| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
+| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
## Nuxt 모듈
@@ -115,7 +116,7 @@
| 파일 | 화면 |
|------|------|
-| pages/admin/index.vue | 대시보드(실시간 접속자·체류·스크롤·인기 게시물 참여 지표) |
+| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물 참여 지표) |
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
@@ -242,7 +243,7 @@
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
-| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회 |
+| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회·방문자 해시 보관 정리 |
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 |
| server/api/analytics/pageview.post.js | 공개 통계 수집 API |
| server/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API |
diff --git a/docs/spec.md b/docs/spec.md
index 584a2d4..2e91f04 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -366,7 +366,11 @@ components/content/
- 봇 User-Agent는 서버에서 무시.
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송.
- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다.
-- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·경로·마지막 활동)을 조회한다.
+- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간)을 조회한다.
+- 관리자 차트는 최대 365일 범위를 조회한다.
+- `site_analytics_daily`, `post_analytics_daily`는 사이트 전체 방문자와 게시물별 조회수 누적 원본이므로 자동 삭제하지 않는다.
+- `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다.
+- `analytics_active_sessions`는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다.
---
@@ -429,7 +433,7 @@ components/content/
- `POST /admin/api/auth/login` - 로그인
- `POST /admin/api/auth/logout` - 로그아웃
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
-- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달)
+- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별 `trends`). `days`는 대시보드에서 7/30/90/180/365로 전환한다.
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·읽음·평균 체류·스크롤 구간)
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함)
- `GET /admin/api/posts` - 글 목록
@@ -614,8 +618,8 @@ components/content/
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
- DB에 owner/admin 계정이 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다.
- 최초 owner 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
-- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
-- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
+- 관리자 로그인 성공 시 httpOnly 세션 쿠키(`sori_admin_session`)를 `/` 경로에 설정한다. Secure는 실제 HTTPS 요청(`x-forwarded-proto` 포함)일 때만 사용한다.
+- `/admin/login` 로그인 제출 버튼은 제출 중일 때만 비활성화한다. 빈 값은 브라우저 `required`와 서버 검증으로 처리하며, 자동완성 값은 제출 직전 동기화한다.
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다.
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다.
diff --git a/docs/update.md b/docs/update.md
index 01baf1e..12c6a13 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,12 @@
# 업데이트 이력
+## v1.3.5
+
+- 관리자 로그인: 자동완성 값 동기화·제출 중만 버튼 비활성. 운영 HTTP에서 Secure 쿠키 미저장으로 로그인 루프되던 문제 수정. `server/utils/session-cookie.js`로 path `/`·`x-forwarded-proto` Secure 통일.
+- 통계: `lib/analytics-shared.js` 분리로 클라이언트 `node:crypto` 오류 수정. 통계 조회 시작일 JS 계산으로 `date >= integer` 오류 수정.
+- 관리자 대시보드: 상단 요약 한 줄·7일~12개월 차트·접속자 목록(게시물 제목·유지시간). `trends` 일자별 0 채움.
+- 통계 보관: 일별 집계 누적 보관, 방문자 해시 32일 초과 정리, 실시간 세션 90초 TTL.
+
## v1.3.4
- 통계 확장: 체류시간·스크롤 구간(25/50/75/100%)·실시간 접속 세션. 마이그레이션 `031_analytics_engagement_and_realtime.sql`.
diff --git a/lib/analytics-shared.js b/lib/analytics-shared.js
new file mode 100644
index 0000000..b57403f
--- /dev/null
+++ b/lib/analytics-shared.js
@@ -0,0 +1,142 @@
+/** @type {RegExp} 추적 제외 경로 */
+const EXCLUDED_PATH_PATTERN = /^\/(admin|signin|signup|forgot-password|settings)(\/|$)/
+
+/** @type {RegExp} 봇 User-Agent 패턴 */
+const BOT_USER_AGENT_PATTERN = /bot|crawl|spider|slurp|preview|headless|lighthouse|bytespider|facebookexternalhit/i
+
+/**
+ * 오늘 날짜(UTC)를 YYYY-MM-DD로 반환한다.
+ * @returns {string} 날짜 문자열
+ */
+export const getAnalyticsDayKey = () => {
+ const now = new Date()
+ return now.toISOString().slice(0, 10)
+}
+
+/**
+ * 기준일에서 지정 일수만큼 이전 날짜를 YYYY-MM-DD로 반환한다.
+ * @param {string} dayKey - 기준일(YYYY-MM-DD)
+ * @param {number} daysBefore - 며칠 전(0이면 같은 날)
+ * @returns {string} 시작일
+ */
+export const getAnalyticsDayBefore = (dayKey, daysBefore) => {
+ const offset = Math.max(Number(daysBefore) || 0, 0)
+ const base = new Date(`${dayKey}T00:00:00.000Z`)
+ base.setUTCDate(base.getUTCDate() - offset)
+ return base.toISOString().slice(0, 10)
+}
+
+/**
+ * 통계 추적 대상 경로인지 확인한다.
+ * @param {string} path - 요청 경로
+ * @returns {boolean} 추적 가능 여부
+ */
+export const isTrackableAnalyticsPath = (path) => {
+ const normalized = (path || '').trim()
+ if (!normalized.startsWith('/')) {
+ return false
+ }
+
+ if (EXCLUDED_PATH_PATTERN.test(normalized)) {
+ return false
+ }
+
+ return true
+}
+
+/**
+ * 봇 User-Agent 여부
+ * @param {string} userAgent - User-Agent
+ * @returns {boolean} 봇 여부
+ */
+export const isBotUserAgent = (userAgent) => {
+ const value = (userAgent || '').trim()
+ if (!value) {
+ return false
+ }
+
+ return BOT_USER_AGENT_PATTERN.test(value)
+}
+
+/**
+ * 게시물 slug 정규화
+ * @param {string} slug - slug
+ * @returns {string} 정규화된 slug
+ */
+export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim()
+
+/** @type {number} heartbeat 체류시간 상한(초) */
+export const ANALYTICS_MAX_DURATION_SECONDS = 1800
+
+/** @type {number} engaged_views 집계 최소 체류(초) */
+export const ANALYTICS_ENGAGED_MIN_SECONDS = 10
+
+/** @type {number} 현재 접속자 판정 TTL(초) */
+export const ANALYTICS_ACTIVE_SESSION_TTL_SECONDS = 90
+
+/** @type {number} 관리자 차트 최대 조회 기간(일) */
+export const ANALYTICS_CHART_MAX_DAYS = 365
+
+/** @type {number} 일별 방문자 해시 보관 기간(일) */
+export const ANALYTICS_VISITOR_HASH_RETENTION_DAYS = 32
+
+/** @type {number} 통계 정리 최소 실행 간격(ms) */
+export const ANALYTICS_RETENTION_PURGE_INTERVAL_MS = 6 * 60 * 60 * 1000
+
+/** @type {number[]} 스크롤 구간 임계값 */
+export const ANALYTICS_SCROLL_THRESHOLDS = [0.25, 0.5, 0.75, 1]
+
+/**
+ * 체류시간(초)을 상한 내로 보정한다.
+ * @param {number} seconds - 체류시간
+ * @returns {number} 보정된 초
+ */
+export const clampAnalyticsDurationSeconds = (seconds) => {
+ const value = Number(seconds)
+ if (!Number.isFinite(value) || value < 0) {
+ return 0
+ }
+
+ return Math.min(Math.floor(value), ANALYTICS_MAX_DURATION_SECONDS)
+}
+
+/**
+ * 스크롤 비율을 0~1로 보정한다.
+ * @param {number} ratio - 스크롤 비율
+ * @returns {number} 보정된 비율
+ */
+export const clampAnalyticsScrollRatio = (ratio) => {
+ const value = Number(ratio)
+ if (!Number.isFinite(value) || value < 0) {
+ return 0
+ }
+
+ return Math.min(value, 1)
+}
+
+/**
+ * 새로 통과한 스크롤 구간 컬럼명 목록을 반환한다.
+ * @param {number} previousRatio - 이전 최대 스크롤
+ * @param {number} nextRatio - 갱신된 최대 스크롤
+ * @returns {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} 신규 구간
+ */
+export const getNewScrollBucketColumns = (previousRatio, nextRatio) => {
+ const previous = clampAnalyticsScrollRatio(previousRatio)
+ const next = clampAnalyticsScrollRatio(nextRatio)
+ const columns = []
+
+ if (previous < 0.25 && next >= 0.25) {
+ columns.push('scroll_25')
+ }
+ if (previous < 0.5 && next >= 0.5) {
+ columns.push('scroll_50')
+ }
+ if (previous < 0.75 && next >= 0.75) {
+ columns.push('scroll_75')
+ }
+ if (previous < 1 && next >= 1) {
+ columns.push('scroll_100')
+ }
+
+ return columns
+}
diff --git a/lib/analytics.js b/lib/analytics.js
index a1657ce..f5bcc14 100644
--- a/lib/analytics.js
+++ b/lib/analytics.js
@@ -1,51 +1,22 @@
import { createHash } from 'node:crypto'
-/** @type {RegExp} 추적 제외 경로 */
-const EXCLUDED_PATH_PATTERN = /^\/(admin|signin|signup|forgot-password|settings)(\/|$)/
-
-/** @type {RegExp} 봇 User-Agent 패턴 */
-const BOT_USER_AGENT_PATTERN = /bot|crawl|spider|slurp|preview|headless|lighthouse|bytespider|facebookexternalhit/i
-
-/**
- * 오늘 날짜(UTC)를 YYYY-MM-DD로 반환한다.
- * @returns {string} 날짜 문자열
- */
-export const getAnalyticsDayKey = () => {
- const now = new Date()
- return now.toISOString().slice(0, 10)
-}
-
-/**
- * 통계 추적 대상 경로인지 확인한다.
- * @param {string} path - 요청 경로
- * @returns {boolean} 추적 가능 여부
- */
-export const isTrackableAnalyticsPath = (path) => {
- const normalized = (path || '').trim()
- if (!normalized.startsWith('/')) {
- return false
- }
-
- if (EXCLUDED_PATH_PATTERN.test(normalized)) {
- return false
- }
-
- return true
-}
-
-/**
- * 봇 User-Agent 여부
- * @param {string} userAgent - User-Agent
- * @returns {boolean} 봇 여부
- */
-export const isBotUserAgent = (userAgent) => {
- const value = (userAgent || '').trim()
- if (!value) {
- return false
- }
-
- return BOT_USER_AGENT_PATTERN.test(value)
-}
+export {
+ ANALYTICS_ACTIVE_SESSION_TTL_SECONDS,
+ ANALYTICS_CHART_MAX_DAYS,
+ ANALYTICS_ENGAGED_MIN_SECONDS,
+ ANALYTICS_MAX_DURATION_SECONDS,
+ ANALYTICS_RETENTION_PURGE_INTERVAL_MS,
+ ANALYTICS_SCROLL_THRESHOLDS,
+ ANALYTICS_VISITOR_HASH_RETENTION_DAYS,
+ clampAnalyticsDurationSeconds,
+ clampAnalyticsScrollRatio,
+ getAnalyticsDayBefore,
+ getAnalyticsDayKey,
+ getNewScrollBucketColumns,
+ isBotUserAgent,
+ isTrackableAnalyticsPath,
+ normalizePostSlugForAnalytics
+} from './analytics-shared.js'
/**
* 일 단위 익명 방문자 해시를 생성한다. 원문 IP·UA는 저장하지 않는다.
@@ -57,80 +28,6 @@ export const createDailyVisitorHash = ({ day, ip, userAgent, secret }) => {
return createHash('sha256').update(payload).digest('hex')
}
-/**
- * 게시물 slug 정규화
- * @param {string} slug - slug
- * @returns {string} 정규화된 slug
- */
-export const normalizePostSlugForAnalytics = (slug) => (slug || '').trim()
-
-/** @type {number} heartbeat 체류시간 상한(초) */
-export const ANALYTICS_MAX_DURATION_SECONDS = 1800
-
-/** @type {number} engaged_views 집계 최소 체류(초) */
-export const ANALYTICS_ENGAGED_MIN_SECONDS = 10
-
-/** @type {number} 현재 접속자 판정 TTL(초) */
-export const ANALYTICS_ACTIVE_SESSION_TTL_SECONDS = 90
-
-/** @type {number[]} 스크롤 구간 임계값 */
-export const ANALYTICS_SCROLL_THRESHOLDS = [0.25, 0.5, 0.75, 1]
-
-/**
- * 체류시간(초)을 상한 내로 보정한다.
- * @param {number} seconds - 체류시간
- * @returns {number} 보정된 초
- */
-export const clampAnalyticsDurationSeconds = (seconds) => {
- const value = Number(seconds)
- if (!Number.isFinite(value) || value < 0) {
- return 0
- }
-
- return Math.min(Math.floor(value), ANALYTICS_MAX_DURATION_SECONDS)
-}
-
-/**
- * 스크롤 비율을 0~1로 보정한다.
- * @param {number} ratio - 스크롤 비율
- * @returns {number} 보정된 비율
- */
-export const clampAnalyticsScrollRatio = (ratio) => {
- const value = Number(ratio)
- if (!Number.isFinite(value) || value < 0) {
- return 0
- }
-
- return Math.min(value, 1)
-}
-
-/**
- * 새로 통과한 스크롤 구간 컬럼명 목록을 반환한다.
- * @param {number} previousRatio - 이전 최대 스크롤
- * @param {number} nextRatio - 갱신된 최대 스크롤
- * @returns {Array<'scroll_25' | 'scroll_50' | 'scroll_75' | 'scroll_100'>} 신규 구간
- */
-export const getNewScrollBucketColumns = (previousRatio, nextRatio) => {
- const previous = clampAnalyticsScrollRatio(previousRatio)
- const next = clampAnalyticsScrollRatio(nextRatio)
- const columns = []
-
- if (previous < 0.25 && next >= 0.25) {
- columns.push('scroll_25')
- }
- if (previous < 0.5 && next >= 0.5) {
- columns.push('scroll_50')
- }
- if (previous < 0.75 && next >= 0.75) {
- columns.push('scroll_75')
- }
- if (previous < 1 && next >= 1) {
- columns.push('scroll_100')
- }
-
- return columns
-}
-
/**
* 실시간 세션 해시를 생성한다.
* @param {{ clientSessionId: string, visitorHash: string, secret: string }} input - 해시 입력
diff --git a/package-lock.json b/package-lock.json
index ba5537b..5764ef2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sori.studio",
- "version": "1.2.4",
+ "version": "1.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
- "version": "1.2.4",
+ "version": "1.3.4",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/package.json b/package.json
index 5c2676f..91ff71b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "1.3.4",
+ "version": "1.3.5",
"private": true,
"type": "module",
"imports": {
diff --git a/pages/admin/index.vue b/pages/admin/index.vue
index 9acecbb..54fa31a 100644
--- a/pages/admin/index.vue
+++ b/pages/admin/index.vue
@@ -7,7 +7,20 @@ const { data: posts } = await useFetch('/admin/api/posts', {
default: () => []
})
+const analyticsRangeOptions = [
+ { label: '7일', days: 7 },
+ { label: '30일', days: 30 },
+ { label: '3개월', days: 90 },
+ { label: '6개월', days: 180 },
+ { label: '12개월', days: 365 }
+]
+const selectedAnalyticsDays = ref(30)
+const analyticsQuery = computed(() => ({
+ days: selectedAnalyticsDays.value
+}))
+
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
+ query: analyticsQuery,
default: () => ({
todayVisitors: 0,
visitorsLast7Days: 0,
@@ -15,12 +28,18 @@ const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/adm
onlineNow: 0,
loggedInNow: 0,
avgEngagedSeconds: 0,
- scroll50Reach: 0
+ scroll50Reach: 0,
+ trends: []
})
})
-const { data: topPosts } = await useFetch('/admin/api/analytics/posts', {
- query: { days: 30, limit: 5 },
+const topPostsQuery = computed(() => ({
+ days: selectedAnalyticsDays.value,
+ limit: 5
+}))
+
+const { data: topPosts, refresh: refreshTopPosts } = await useFetch('/admin/api/analytics/posts', {
+ query: topPostsQuery,
default: () => []
})
@@ -38,6 +57,12 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
+const analyticsRangeLabel = computed(() => {
+ return analyticsRangeOptions.find((option) => option.days === selectedAnalyticsDays.value)?.label || '30일'
+})
+const trendRows = computed(() => analyticsSummary.value.trends || [])
+const trendStartDay = computed(() => trendRows.value[0]?.day || '')
+const trendEndDay = computed(() => trendRows.value[trendRows.value.length - 1]?.day || '')
/**
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
@@ -56,24 +81,104 @@ const formatEngagedDuration = (seconds) => {
}
/**
- * 마지막 활동 시각을 상대 표시로 변환한다.
- * @param {string|null} iso - ISO 시각
- * @returns {string} 상대 시각
+ * 선택 기간의 추세 합계를 반환한다.
+ * @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
+ * @returns {number} 합계 또는 평균
*/
-const formatLastSeen = (iso) => {
- if (!iso) {
- return '-'
+const getTrendSummaryValue = (key) => {
+ const rows = trendRows.value
+ if (key === 'avgEngagedSeconds') {
+ const nonZeroRows = rows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
+ if (!nonZeroRows.length) {
+ return 0
+ }
+
+ const total = nonZeroRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
+ return Math.round(total / nonZeroRows.length)
}
- const diffMs = Date.now() - new Date(iso).getTime()
- const diffSec = Math.max(Math.floor(diffMs / 1000), 0)
+ return rows.reduce((sum, row) => sum + Number(row[key] || 0), 0)
+}
- if (diffSec < 60) {
- return `${diffSec}초 전`
+/**
+ * 추세 값 표시 문자열을 반환한다.
+ * @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
+ * @param {number} value - 값
+ * @returns {string} 표시 문자열
+ */
+const formatTrendValue = (key, value) => {
+ if (key === 'avgEngagedSeconds') {
+ return formatEngagedDuration(value)
}
- const diffMin = Math.floor(diffSec / 60)
- return `${diffMin}분 전`
+ return `${Number(value || 0)}`
+}
+
+const chartMetrics = computed(() => [
+ {
+ key: 'visitors',
+ title: '방문자수',
+ label: `${getTrendSummaryValue('visitors')}명`
+ },
+ {
+ key: 'avgEngagedSeconds',
+ title: '평균 체류시간',
+ label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds'))
+ },
+ {
+ key: 'scroll50Reach',
+ title: '50% 스크롤 도달',
+ label: `${getTrendSummaryValue('scroll50Reach')}회`
+ }
+])
+
+/**
+ * 차트 막대 높이 비율을 반환한다.
+ * @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
+ * @param {Object} row - 추세 행
+ * @returns {number} 높이 %
+ */
+const getTrendBarHeight = (key, row) => {
+ const rows = trendRows.value
+ const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
+
+ if (maxValue <= 0) {
+ return 3
+ }
+
+ const value = Number(row[key] || 0)
+ return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
+}
+
+/**
+ * 추세 시작·종료 날짜 라벨을 반환한다.
+ * @param {string} day - YYYY-MM-DD
+ * @returns {string} 날짜 라벨
+ */
+const formatTrendDayLabel = (day) => {
+ if (!day) {
+ return ''
+ }
+
+ const [, month, date] = day.split('-')
+ return `${month}.${date}`
+}
+
+/**
+ * 현재 접속자가 보고 있는 화면명을 반환한다.
+ * @param {Object} session - 접속 세션
+ * @returns {string} 화면명
+ */
+const getSessionViewingTitle = (session) => {
+ if (session.postTitle) {
+ return session.postTitle
+ }
+
+ if (session.path === '/') {
+ return '홈'
+ }
+
+ return session.path || '알 수 없음'
}
let refreshTimer = null
@@ -81,6 +186,7 @@ let refreshTimer = null
onMounted(() => {
refreshTimer = window.setInterval(() => {
refreshSummary()
+ refreshTopPosts()
refreshRealtime()
}, 30000)
})
@@ -90,6 +196,11 @@ onUnmounted(() => {
window.clearInterval(refreshTimer)
}
})
+
+watch(selectedAnalyticsDays, () => {
+ refreshSummary()
+ refreshTopPosts()
+})
@@ -103,51 +214,80 @@ onUnmounted(() => {
-
-
-
- 현재 접속자
-
-
- {{ realtime.summary.onlineNow }}
-
-
- 로그인 {{ realtime.summary.loggedInNow }} · 익명 {{ realtime.summary.anonymousNow }}
-
-
-
-
- 오늘 방문
-
-
- {{ analyticsSummary.todayVisitors }}
-
-
- 7일 {{ analyticsSummary.visitorsLast7Days }}
-
-
-
-
- 평균 체류
-
-
- {{ formatEngagedDuration(analyticsSummary.avgEngagedSeconds) }}
-
-
- 30일 기준
-
-
-
-
- 50% 스크롤
-
-
- {{ analyticsSummary.scroll50Reach }}
-
-
- 30일 조회 {{ analyticsSummary.pageViewsLast30Days }}
-
-
+
+
+ 현재 접속자
+ {{ realtime.summary.onlineNow }}
+
+
+ 오늘 접속자
+ {{ analyticsSummary.todayVisitors }}
+
+
+ 게시물 수
+ {{ posts.length }}
+ 발행 {{ publishedCount }} · 초안 {{ draftCount }}
+
+
+
+
+
+
+
+
+
+
+ {{ metric.title }}
+
+
+ {{ metric.label }}
+
+
+
+
+ {{ formatTrendDayLabel(trendStartDay) }}
+ {{ analyticsRangeLabel }}
+ {{ formatTrendDayLabel(trendEndDay) }}
+
+
+
@@ -185,13 +325,12 @@ onUnmounted(() => {
{{ session.isLoggedIn ? session.user?.username : '익명 방문자' }}
- {{ session.path }}
+ {{ getSessionViewingTitle(session) }}
-
{{ formatLastSeen(session.lastSeenAt) }}
-
- {{ formatEngagedDuration(session.durationSeconds) }}
+
+ 접속 유지 {{ formatEngagedDuration(session.durationSeconds) }}
@@ -204,23 +343,9 @@ onUnmounted(() => {
-
-
-
- 게시물
-
-
- {{ posts.length }}
-
-
- 발행 {{ publishedCount }} · 초안 {{ draftCount }}
-
-
-
-
- 인기 게시물 (30일)
+ 인기 게시물 ({{ analyticsRangeLabel }})
Boolean(form.email.trim()) && Boolean(form.password))
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
default: () => ({
hasUsers: true,
@@ -23,26 +20,68 @@ const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
})
})
+/**
+ * 브라우저/비밀번호 관리자 자동완성 값을 로그인 폼 상태에 반영한다.
+ * @returns {void}
+ */
+const syncAdminLoginAutofill = () => {
+ const emailValue = emailInput.value?.value || ''
+ const passwordValue = passwordInput.value?.value || ''
+
+ if (emailValue && emailValue !== form.email) {
+ form.email = emailValue
+ }
+ if (passwordValue && passwordValue !== form.password) {
+ form.password = passwordValue
+ }
+}
+
/**
* 관리자 로그인 제출
* @returns {Promise} 로그인 처리 결과
*/
const submitLogin = async () => {
+ syncAdminLoginAutofill()
+
pending.value = true
errorMessage.value = ''
try {
await $fetch('/admin/api/auth/login', {
method: 'POST',
- body: form
+ body: {
+ email: form.email.trim(),
+ password: form.password
+ },
+ credentials: 'include'
})
+
+ if (import.meta.client) {
+ window.location.assign('/admin')
+ return
+ }
+
await navigateTo('/admin')
- } catch {
- errorMessage.value = '이메일 또는 비밀번호를 확인해 주세요.'
+ } catch (error) {
+ const statusCode = Number(error?.statusCode || error?.response?.status || 0)
+
+ if (statusCode === 401) {
+ errorMessage.value = '이메일 또는 비밀번호를 확인해 주세요.'
+ } else if (statusCode >= 500) {
+ errorMessage.value = '서버 또는 데이터베이스 연결을 확인해 주세요.'
+ } else {
+ errorMessage.value = '로그인에 실패했습니다. 잠시 후 다시 시도해 주세요.'
+ }
} finally {
pending.value = false
}
}
+
+onMounted(() => {
+ syncAdminLoginAutofill()
+ window.setTimeout(syncAdminLoginAutofill, 100)
+ window.setTimeout(syncAdminLoginAutofill, 500)
+})
@@ -68,21 +107,29 @@ const submitLogin = async () => {
@@ -91,7 +138,7 @@ const submitLogin = async () => {
diff --git a/plugins/site-analytics.client.js b/plugins/site-analytics.client.js
index 2a38085..33b5463 100644
--- a/plugins/site-analytics.client.js
+++ b/plugins/site-analytics.client.js
@@ -1,7 +1,7 @@
import {
ANALYTICS_MAX_DURATION_SECONDS,
isTrackableAnalyticsPath
-} from '../lib/analytics.js'
+} from '../lib/analytics-shared.js'
/** @type {string} 탭 단위 클라이언트 세션 storage 키 */
const CLIENT_SESSION_STORAGE_KEY = 'sori_analytics_client_session'
diff --git a/server/repositories/analytics-repository.js b/server/repositories/analytics-repository.js
index 4c42e35..a339098 100644
--- a/server/repositories/analytics-repository.js
+++ b/server/repositories/analytics-repository.js
@@ -1,10 +1,14 @@
import {
ANALYTICS_ACTIVE_SESSION_TTL_SECONDS,
+ ANALYTICS_CHART_MAX_DAYS,
ANALYTICS_ENGAGED_MIN_SECONDS,
+ ANALYTICS_RETENTION_PURGE_INTERVAL_MS,
+ ANALYTICS_VISITOR_HASH_RETENTION_DAYS,
clampAnalyticsDurationSeconds,
clampAnalyticsScrollRatio,
createDailyVisitorHash,
createRealtimeSessionHash,
+ getAnalyticsDayBefore,
getAnalyticsDayKey,
getNewScrollBucketColumns
} from '../../lib/analytics.js'
@@ -12,6 +16,9 @@ import { getPostgresClient } from './postgres-client.js'
import { getRuntimeEnvValue } from '../utils/runtime-env.js'
import { getMemberSession } from '../utils/member-auth.js'
+/** @type {number} 마지막 통계 보관 정리 실행 시각 */
+let lastAnalyticsRetentionPurgeAt = 0
+
/**
* 통계 해시용 시크릿을 반환한다.
* @returns {string} 시크릿
@@ -124,6 +131,7 @@ export const recordAnalyticsPageview = async (input) => {
}
if (!postId) {
+ await purgeAnalyticsRetention(sql)
return { ok: true }
}
@@ -151,6 +159,8 @@ export const recordAnalyticsPageview = async (input) => {
`
}
+ await purgeAnalyticsRetention(sql)
+
return { ok: true }
}
@@ -198,6 +208,29 @@ const purgeStaleActiveSessions = async (sql) => {
`
}
+/**
+ * 오래된 방문자 해시를 정리한다.
+ * @param {import('postgres').Sql} sql - DB 클라이언트
+ * @returns {Promise}
+ */
+const purgeAnalyticsRetention = async (sql) => {
+ const now = Date.now()
+
+ if (now - lastAnalyticsRetentionPurgeAt < ANALYTICS_RETENTION_PURGE_INTERVAL_MS) {
+ return
+ }
+
+ const today = getAnalyticsDayKey()
+ const visitorHashCutoffDay = getAnalyticsDayBefore(today, ANALYTICS_VISITOR_HASH_RETENTION_DAYS)
+
+ await sql`
+ DELETE FROM analytics_daily_visitors
+ WHERE day < ${visitorHashCutoffDay}::date
+ `
+
+ lastAnalyticsRetentionPurgeAt = now
+}
+
/**
* 스크롤 구간 카운터를 증가시킨다.
* @param {import('postgres').Sql} sql - DB 클라이언트
@@ -359,6 +392,7 @@ export const recordAnalyticsHeartbeat = async (input) => {
}
await purgeStaleActiveSessions(sql)
+ await purgeAnalyticsRetention(sql)
return { ok: true }
}
@@ -370,7 +404,7 @@ export const recordAnalyticsHeartbeat = async (input) => {
*/
export const getAnalyticsSummary = async (options = {}) => {
const sql = getPostgresClient()
- const days = Math.min(Math.max(Number(options.days) || 30, 1), 365)
+ const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS)
if (!sql) {
return {
@@ -381,12 +415,17 @@ export const getAnalyticsSummary = async (options = {}) => {
loggedInNow: 0,
avgEngagedSeconds: 0,
scroll50Reach: 0,
+ trends: [],
days
}
}
const today = getAnalyticsDayKey()
+ const last7StartDay = getAnalyticsDayBefore(today, 6)
+ const last30StartDay = getAnalyticsDayBefore(today, 29)
+ const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
await purgeStaleActiveSessions(sql)
+ await purgeAnalyticsRetention(sql)
const todayRows = await sql`
SELECT visitors, page_views, engaged_views, total_engaged_seconds
@@ -398,13 +437,13 @@ export const getAnalyticsSummary = async (options = {}) => {
const last7Rows = await sql`
SELECT COALESCE(SUM(visitors), 0)::int AS visitors
FROM site_analytics_daily
- WHERE day >= (${today}::date - 6)
+ WHERE day >= ${last7StartDay}::date
`
const pageViewRows = await sql`
SELECT COALESCE(SUM(page_views), 0)::int AS page_views
FROM site_analytics_daily
- WHERE day >= (${today}::date - 29)
+ WHERE day >= ${last30StartDay}::date
`
const engagementRows = await sql`
@@ -412,13 +451,13 @@ export const getAnalyticsSummary = async (options = {}) => {
COALESCE(SUM(total_engaged_seconds), 0)::int AS total_engaged_seconds,
COALESCE(SUM(engaged_views), 0)::int AS engaged_views
FROM site_analytics_daily
- WHERE day >= (${today}::date - ${days - 1})
+ WHERE day >= ${rangeStartDay}::date
`
const scrollRows = await sql`
SELECT COALESCE(SUM(scroll_50), 0)::int AS scroll_50
FROM post_analytics_daily
- WHERE day >= (${today}::date - ${days - 1})
+ WHERE day >= ${rangeStartDay}::date
`
const onlineRows = await sql`
@@ -429,6 +468,33 @@ export const getAnalyticsSummary = async (options = {}) => {
WHERE last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
`
+ const trendRows = await sql`
+ WITH days AS (
+ SELECT generate_series(${rangeStartDay}::date, ${today}::date, interval '1 day')::date AS day
+ ),
+ post_daily AS (
+ SELECT
+ day,
+ COALESCE(SUM(scroll_50), 0)::int AS scroll_50
+ FROM post_analytics_daily
+ WHERE day >= ${rangeStartDay}::date
+ GROUP BY day
+ )
+ SELECT
+ days.day,
+ COALESCE(site_analytics_daily.visitors, 0)::int AS visitors,
+ CASE
+ WHEN COALESCE(site_analytics_daily.engaged_views, 0) > 0
+ THEN ROUND(site_analytics_daily.total_engaged_seconds::numeric / site_analytics_daily.engaged_views)::int
+ ELSE 0
+ END AS avg_engaged_seconds,
+ COALESCE(post_daily.scroll_50, 0)::int AS scroll_50
+ FROM days
+ LEFT JOIN site_analytics_daily ON site_analytics_daily.day = days.day
+ LEFT JOIN post_daily ON post_daily.day = days.day
+ ORDER BY days.day ASC
+ `
+
const engagedViews = Number(engagementRows[0]?.engaged_views || 0)
const totalEngagedSeconds = Number(engagementRows[0]?.total_engaged_seconds || 0)
@@ -442,6 +508,12 @@ export const getAnalyticsSummary = async (options = {}) => {
? Math.round(totalEngagedSeconds / engagedViews)
: 0,
scroll50Reach: Number(scrollRows[0]?.scroll_50 || 0),
+ trends: trendRows.map((row) => ({
+ day: row.day ? new Date(row.day).toISOString().slice(0, 10) : '',
+ visitors: Number(row.visitors || 0),
+ avgEngagedSeconds: Number(row.avg_engaged_seconds || 0),
+ scroll50Reach: Number(row.scroll_50 || 0)
+ })),
days
}
}
@@ -462,6 +534,7 @@ export const getAnalyticsRealtimeSummary = async () => {
}
await purgeStaleActiveSessions(sql)
+ await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
@@ -495,6 +568,7 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
}
await purgeStaleActiveSessions(sql)
+ await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
@@ -504,10 +578,12 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
analytics_active_sessions.duration_seconds,
analytics_active_sessions.max_scroll_ratio,
analytics_active_sessions.last_seen_at,
+ posts.title AS post_title,
users.id AS user_id,
users.username,
users.avatar_url
FROM analytics_active_sessions
+ LEFT JOIN posts ON posts.id = analytics_active_sessions.post_id
LEFT JOIN users ON users.id = analytics_active_sessions.user_id
WHERE analytics_active_sessions.last_seen_at >= now() - (${ANALYTICS_ACTIVE_SESSION_TTL_SECONDS} * interval '1 second')
ORDER BY analytics_active_sessions.last_seen_at DESC
@@ -518,6 +594,7 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
sessionHash: row.session_hash,
path: row.path,
postSlug: row.post_slug || '',
+ postTitle: row.post_title || '',
durationSeconds: Number(row.duration_seconds || 0),
maxScrollRatio: Number(row.max_scroll_ratio || 0),
lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at).toISOString() : null,
@@ -539,7 +616,7 @@ export const getAnalyticsActiveSessions = async (options = {}) => {
*/
export const getAnalyticsTopPosts = async (options = {}) => {
const sql = getPostgresClient()
- const days = Math.min(Math.max(Number(options.days) || 30, 1), 365)
+ const days = Math.min(Math.max(Number(options.days) || 30, 1), ANALYTICS_CHART_MAX_DAYS)
const limit = Math.min(Math.max(Number(options.limit) || 5, 1), 20)
if (!sql) {
@@ -547,6 +624,8 @@ export const getAnalyticsTopPosts = async (options = {}) => {
}
const today = getAnalyticsDayKey()
+ const rangeStartDay = getAnalyticsDayBefore(today, days - 1)
+ await purgeAnalyticsRetention(sql)
const rows = await sql`
SELECT
@@ -563,7 +642,7 @@ export const getAnalyticsTopPosts = async (options = {}) => {
COALESCE(SUM(post_analytics_daily.scroll_100), 0)::int AS scroll_100
FROM post_analytics_daily
INNER JOIN posts ON posts.id = post_analytics_daily.post_id
- WHERE post_analytics_daily.day >= (${today}::date - ${days - 1})
+ WHERE post_analytics_daily.day >= ${rangeStartDay}::date
GROUP BY posts.id, posts.title, posts.slug
ORDER BY views DESC, reads DESC, posts.published_at DESC NULLS LAST
LIMIT ${limit}
diff --git a/server/utils/admin-auth.js b/server/utils/admin-auth.js
index 9630783..c61470f 100644
--- a/server/utils/admin-auth.js
+++ b/server/utils/admin-auth.js
@@ -1,6 +1,7 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
import { getRuntimeEnvValue } from './runtime-env'
+import { shouldUseSecureSessionCookie } from './session-cookie'
const adminSessionCookieName = 'sori_admin_session'
const sessionMaxAge = 60 * 60 * 12
@@ -109,8 +110,8 @@ export const setAdminSession = (event, adminUser) => {
setCookie(event, adminSessionCookieName, createAdminSessionToken(adminUser), {
httpOnly: true,
sameSite: 'lax',
- secure: process.env.NODE_ENV === 'production',
- path: '/admin',
+ secure: shouldUseSecureSessionCookie(event),
+ path: '/',
maxAge: sessionMaxAge
})
}
@@ -122,7 +123,7 @@ export const setAdminSession = (event, adminUser) => {
*/
export const clearAdminSession = (event) => {
deleteCookie(event, adminSessionCookieName, {
- path: '/admin'
+ path: '/'
})
}
diff --git a/server/utils/member-auth.js b/server/utils/member-auth.js
index 6dc44b1..2d14798 100644
--- a/server/utils/member-auth.js
+++ b/server/utils/member-auth.js
@@ -1,6 +1,7 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
import { getRuntimeEnvValue } from './runtime-env'
+import { shouldUseSecureSessionCookie } from './session-cookie'
const memberSessionCookieName = 'sori_member_session'
const sessionMaxAge = 60 * 60 * 24 * 14
@@ -107,7 +108,7 @@ export const setMemberSession = (event, user) => {
setCookie(event, memberSessionCookieName, createMemberSessionToken(user), {
httpOnly: true,
sameSite: 'lax',
- secure: process.env.NODE_ENV === 'production',
+ secure: shouldUseSecureSessionCookie(event),
path: '/',
maxAge: sessionMaxAge
})
diff --git a/server/utils/session-cookie.js b/server/utils/session-cookie.js
new file mode 100644
index 0000000..cc194f7
--- /dev/null
+++ b/server/utils/session-cookie.js
@@ -0,0 +1,24 @@
+import { getRequestHeader, getRequestURL } from 'h3'
+
+/**
+ * HTTPS 요청 여부를 판별해 Secure 쿠키 사용 여부를 반환한다.
+ * 운영 환경이라도 HTTP로 접속하면 쿠키가 저장되지 않는 문제를 막는다.
+ * @param {import('h3').H3Event} event - 요청 이벤트
+ * @returns {boolean} Secure 쿠키 사용 여부
+ */
+export const shouldUseSecureSessionCookie = (event) => {
+ const forwarded = String(getRequestHeader(event, 'x-forwarded-proto') || '')
+ .split(',')[0]
+ .trim()
+ .toLowerCase()
+
+ if (forwarded) {
+ return forwarded === 'https'
+ }
+
+ try {
+ return getRequestURL(event).protocol === 'https:'
+ } catch {
+ return false
+ }
+}