페이지 형식 선택과 접속 IP 기록 수정 v1.5.7

This commit is contained in:
2026-05-26 16:44:52 +09:00
parent a5ae2c3fce
commit e78e09f3fd
15 changed files with 52 additions and 23 deletions

View File

@@ -443,7 +443,7 @@ defineExpose({
</span>
</label>
<div v-if="form.renderMode === 'html_document'" class="admin-page-form__field grid gap-2 text-sm">
<div class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">페이지 형식</span>
<div class="admin-page-form__mode-control grid grid-cols-2 rounded border border-[#e3e6e8] bg-[#eff1f2] p-1">
<button
@@ -465,7 +465,7 @@ defineExpose({
</div>
</div>
<div class="admin-page-form__field grid gap-2 text-sm">
<div v-if="form.renderMode === 'html_document'" class="admin-page-form__field grid gap-2 text-sm">
<span class="admin-page-form__label font-medium">HTML 자산</span>
<label
class="admin-page-form__asset-upload inline-flex h-10 cursor-pointer items-center justify-center rounded bg-[#15171a] px-3 text-sm font-semibold text-white transition-colors hover:bg-black"

View File

@@ -1,5 +1,11 @@
# 업데이트 요약
## v1.5.7
- 일반 텍스트 페이지에서도 페이지 형식 선택을 다시 HTML로 되돌릴 수 있게 수정했다.
- 일반 텍스트 페이지에서는 HTML 자산 업로드 UI가 보이지 않도록 정리했다.
- 멤버 접속 IP 기록이 프록시 헤더를 읽도록 보정했다.
## v1.5.6
- 멤버 등급 변경이 저장 버튼을 눌렀을 때만 반영되도록 수정했다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-05-26 v1.5.7 — 페이지 형식 선택과 IP 기록 보정
페이지 형식 선택은 현재 모드와 관계없이 되돌릴 수 있어야 하므로 설정 패널에서 항상 표시한다. 일반 텍스트 모드는 HTML 문서가 아니어서 HTML 자산 업로드가 의미 없으므로 해당 업로드 UI는 HTML 문서 모드에서만 표시한다. 멤버 정보의 접속 IP는 프록시 뒤에서 기본 `getRequestIP`가 비어 저장될 수 있으므로, 로그인·회원가입·댓글 활동 등 회원 활동 기록에는 `x-forwarded-for`를 포함한 요청 IP 조회를 공통으로 사용한다.
## 2026-05-26 v1.5.6 — 멤버 권한 변경을 저장 액션과 서버 규칙으로 고정
멤버 등급은 접근 권한을 바꾸는 민감한 값이므로 셀렉트 변경 즉시 저장하면 사용자가 의도하지 않은 권한 변경이 발생할 수 있다. 따라서 멤버 상세 화면의 등급 변경은 다른 기본 정보처럼 저장 버튼을 눌렀을 때만 반영한다. 서버는 화면 상태와 무관하게 소유자·관리자만 권한을 변경할 수 있게 하고, 관리자는 다른 관리자나 소유자를 조작하지 못하며 소유자·관리자 등급도 부여하지 못하게 막는다. 마지막 소유자 보호는 트랜잭션 잠금 안에서 검사해 동시에 권한을 바꿔도 소유자가 사라지지 않도록 한다.

View File

@@ -80,7 +80,7 @@
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 일반 텍스트 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
@@ -236,6 +236,7 @@
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
| server/utils/request-ip.js | 프록시 헤더 포함 요청 IP 조회 유틸리티 |
| server/utils/member-avatar.js | 회원 전용 썸네일 경로 검증·프로필 분리 시 `media_metadata`만 제거(디스크는 관리자 미디어에서 정리) |
| server/utils/member-avatar-upload.js | 회원 썸네일 공통 업로드 검증·WebP 변환·중앙 1:1 크롭·저장 유틸 |
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |

View File

@@ -644,6 +644,7 @@ components/content/
- 고정 페이지 HTML 문서 모드는 전체 HTML 붙여넣기용 textarea를 사용하고, 공개 URL에서 Nuxt 레이아웃 없이 원문 HTML로 응답한다.
- 페이지 슬러그는 게시글처럼 한글 제목을 영문으로 로마자화해 자동 생성한다.
- 페이지 상태, 페이지 형식, Page URL, HTML 자산 업로드, 삭제 액션은 오른쪽 설정 패널에서 관리한다.
- 페이지 형식 선택은 HTML 문서/일반 텍스트 모드와 무관하게 항상 표시한다. HTML 자산 업로드는 HTML 문서 모드에서만 표시한다.
- HTML 자산 업로드는 기존 관리자 업로드 API(`/admin/api/uploads`)를 사용하며, 성공한 파일 URL을 HTML textarea 현재 커서 위치에 삽입한다. 업로드 파일은 현재 에디터 업로드 정책에 따라 `/uploads/posts/YYYY/MM/` 아래 저장되고 미디어 라이브러리 논리 폴더는 `미분류`로 기록된다.
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문을 저장한다. 대표 이미지는 페이지 작성 UI에서 사용하지 않는다.
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
@@ -685,7 +686,7 @@ components/content/
- 최초 owner 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
- 관리자 로그인 성공 시 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`를 현재 로그인으로 갱신한다.
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. 요청 IP는 프록시 헤더(`x-forwarded-for`)를 포함해 기록한다.
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필``/admin/members/:id` 멤버 편집 화면으로 이동한다.
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
@@ -712,7 +713,7 @@ components/content/
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
- 회원 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`를 현재 로그인으로 갱신한다. `/api/auth/me`는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다.
- 회원 로그인 성공 시 `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`만 사용하며, 값이 없으면 서버 오류로 실패한다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.5.7
- 관리자 페이지 작성/수정: 일반 텍스트 모드에서도 페이지 형식 선택창이 계속 보이도록 수정.
- 관리자 페이지 작성/수정: HTML 자산 업로드는 HTML 문서 모드에서만 보이도록 수정.
- 회원 활동 IP 기록: 로그인·회원가입·댓글·좋아요·이메일 OTP 요청에서 프록시 헤더(`x-forwarded-for`)를 포함해 요청 IP를 저장하도록 수정.
## v1.5.6
- 관리자 멤버 상세: 멤버 등급 선택이 즉시 저장되지 않고 저장 버튼으로만 반영되도록 수정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.6",
"version": "1.5.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.6",
"version": "1.5.7",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.6",
"version": "1.5.7",
"private": true,
"type": "module",
"imports": {

View File

@@ -1,6 +1,6 @@
import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import { createError, readBody } from 'h3'
import { getPostgresClient } from '../../../repositories/postgres-client'
import { getMemberBootstrapState, getUserByEmail } from '../../../repositories/member-repository'
import {
@@ -14,6 +14,7 @@ import {
import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp'
import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail'
import { getRuntimeEnvValue } from '../../../utils/runtime-env'
import { getClientIp } from '../../../utils/request-ip'
const bodySchema = z.object({
email: z.string().trim().email(),
@@ -113,7 +114,7 @@ export default defineEventHandler(async (event) => {
if (!user) {
const dummyHash = randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
const createdIp = String(getRequestIP(event) || '')
const createdIp = getClientIp(event)
await invalidatePendingOtpChallenges(sql, email, purpose)
await insertOtpChallenge(sql, {
email,
@@ -130,7 +131,7 @@ export default defineEventHandler(async (event) => {
const code = generateSixDigitOtp()
const codeHash = hashOtpCode({ pepper, email, purpose, code })
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
const createdIp = String(getRequestIP(event) || '')
const createdIp = getClientIp(event)
const challengeId = await insertOtpChallenge(sql, {
email,

View File

@@ -1,8 +1,9 @@
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import { createError, readBody } from 'h3'
import { getUserByEmail, touchUserActivity } from '../../repositories/member-repository'
import { setMemberSession } from '../../utils/member-auth'
import { getClientIp } from '../../utils/request-ip'
const loginSchema = z.object({
email: z.string().trim().email(),
@@ -38,7 +39,7 @@ export default defineEventHandler(async (event) => {
setMemberSession(event, { userId: user.id, email: user.email })
await touchUserActivity({
userId: user.id,
ip: String(getRequestIP(event) || '')
ip: getClientIp(event)
})
return {
@@ -48,4 +49,3 @@ export default defineEventHandler(async (event) => {
avatarUrl: user.avatarUrl || ''
}
})

View File

@@ -1,6 +1,6 @@
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import { createError, readBody } from 'h3'
import { createUser, getUserByEmail, getMemberBootstrapState, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
import { verifyAndConsumeEmailOtp } from '../../repositories/email-otp-repository'
import { setMemberSession } from '../../utils/member-auth'
@@ -8,6 +8,7 @@ import { setAdminSession } from '../../utils/admin-auth'
import { isResendConfigured } from '../../utils/resend-mail'
import { getRuntimeEnvValue } from '../../utils/runtime-env'
import { assertSignupUsernameAllowed } from '../../utils/member-username-policy'
import { getClientIp } from '../../utils/request-ip'
const signupSchema = z.object({
username: z.string().trim().min(1),
@@ -112,7 +113,7 @@ export default defineEventHandler(async (event) => {
}
await touchUserActivity({
userId: created.id,
ip: String(getRequestIP(event) || '')
ip: getClientIp(event)
})
return {

View File

@@ -1,8 +1,9 @@
import { createError, getRequestIP, readBody } from 'h3'
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { createComment } from '../../../repositories/comment-repository'
import { touchUserActivity } from '../../../repositories/member-repository'
import { requireMemberSession } from '../../../utils/member-auth'
import { getClientIp } from '../../../utils/request-ip'
const createCommentSchema = z.object({
body: z.string().trim().min(1).max(5000),
@@ -34,11 +35,10 @@ export default defineEventHandler(async (event) => {
})
await touchUserActivity({
userId: session.userId,
ip: String(getRequestIP(event) || '')
ip: getClientIp(event)
})
return {
comment
}
})

View File

@@ -1,7 +1,7 @@
import { getRequestIP } from 'h3'
import { toggleCommentLike } from '../../../../../repositories/comment-repository'
import { touchUserActivity } from '../../../../../repositories/member-repository'
import { requireMemberSession } from '../../../../../utils/member-auth'
import { getClientIp } from '../../../../../utils/request-ip'
/**
* 댓글 좋아요 토글 API
@@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
await touchUserActivity({
userId: session.userId,
ip: String(getRequestIP(event) || '')
ip: getClientIp(event)
})
return result

View File

@@ -1,10 +1,11 @@
import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import { createError, readBody } from 'h3'
import bcrypt from 'bcrypt'
import { safeCompare, setAdminSession } from '../../../../utils/admin-auth'
import { getAdminUserByEmail, getMemberBootstrapState, touchUserActivity, upsertBootstrapOwner } from '../../../../repositories/member-repository'
import { setMemberSession } from '../../../../utils/member-auth'
import { getRuntimeEnvValue } from '../../../../utils/runtime-env'
import { getClientIp } from '../../../../utils/request-ip'
const loginSchema = z.object({
email: z.string().email(),
@@ -86,7 +87,7 @@ export default defineEventHandler(async (event) => {
})
await touchUserActivity({
userId: adminUser.id,
ip: String(getRequestIP(event) || '')
ip: getClientIp(event)
})
return {

View File

@@ -0,0 +1,8 @@
import { getRequestIP } from 'h3'
/**
* 프록시 헤더를 포함해 요청 IP를 조회한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {string} 요청 IP
*/
export const getClientIp = (event) => String(getRequestIP(event, { xForwardedFor: true }) || '')