관리자 기능과 태그 표시 설정 추가

This commit is contained in:
2026-05-01 18:00:22 +09:00
parent 237eb2990f
commit 787747aa7f
51 changed files with 2261 additions and 128 deletions

View File

@@ -50,9 +50,63 @@ const mapTagRow = (row) => ({
id: row.id,
name: row.name,
slug: row.slug,
description: row.description
description: row.description,
sortOrder: row.sort_order,
color: row.color
})
/**
* 태그 슬러그 목록 정규화
* @param {Array<string>} tags - 태그 슬러그 목록
* @returns {Array<string>} 정규화된 태그 슬러그 목록
*/
const normalizeTagSlugs = (tags = []) => [...new Set(tags
.map((tag) => String(tag).trim().toLowerCase())
.filter(Boolean))]
/**
* 태그 슬러그를 태그명으로 변환
* @param {string} slug - 태그 슬러그
* @returns {string} 태그명
*/
const getTagNameFromSlug = (slug) => slug
.split('-')
.filter(Boolean)
.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`)
.join(' ')
/**
* 게시물 태그 연결 저장
* @param {Function} sql - PostgreSQL 트랜잭션 클라이언트
* @param {string} postId - 게시물 ID
* @param {Array<string>} tags - 태그 슬러그 목록
* @returns {Promise<void>} 저장 결과
*/
const syncPostTags = async (sql, postId, tags) => {
const tagSlugs = normalizeTagSlugs(tags)
await sql`
DELETE FROM post_tags
WHERE post_id = ${postId}
`
for (const slug of tagSlugs) {
const tagRows = await sql`
INSERT INTO tags (name, slug)
VALUES (${getTagNameFromSlug(slug)}, ${slug})
ON CONFLICT (slug) DO UPDATE
SET updated_at = now()
RETURNING id
`
await sql`
INSERT INTO post_tags (post_id, tag_id)
VALUES (${postId}, ${tagRows[0].id})
ON CONFLICT DO NOTHING
`
}
}
/**
* 공개 게시물 목록 조회
* @returns {Promise<Array>} 게시물 목록
@@ -79,6 +133,163 @@ export const listPosts = async () => {
return rows.map(mapPostRow)
}
/**
* 관리자 게시물 목록 조회
* @returns {Promise<Array>} 관리자 게시물 목록
*/
export const listAdminPosts = async () => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts()
}
const rows = await sql`
SELECT
posts.*,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
GROUP BY posts.id
ORDER BY posts.updated_at DESC
`
return rows.map(mapPostRow)
}
/**
* 관리자 게시물 상세 조회
* @param {string} id - 게시물 ID
* @returns {Promise<Object | null>} 관리자 게시물 상세
*/
export const getAdminPostById = async (id) => {
const sql = getPostgresClient()
if (!sql) {
return getSamplePosts().find((post) => post.id === id) || null
}
const rows = await sql`
SELECT
posts.*,
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
FROM posts
LEFT JOIN post_tags ON post_tags.post_id = posts.id
LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE posts.id = ${id}
GROUP BY posts.id
LIMIT 1
`
return rows[0] ? mapPostRow(rows[0]) : null
}
/**
* 관리자 게시물 생성
* @param {Object} input - 게시물 입력값
* @returns {Promise<Object>} 생성된 게시물
*/
export const createAdminPost = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql.begin(async (transaction) => {
const insertedRows = await transaction`
INSERT INTO posts (
title,
slug,
content,
excerpt,
featured_image,
status,
published_at
)
VALUES (
${input.title},
${input.slug},
${input.content},
${input.excerpt},
${input.featuredImage},
${input.status},
${input.publishedAt}
)
RETURNING *
`
await syncPostTags(transaction, insertedRows[0].id, input.tags)
return insertedRows
})
return getAdminPostById(rows[0].id)
}
/**
* 관리자 게시물 수정
* @param {string} id - 게시물 ID
* @param {Object} input - 게시물 입력값
* @returns {Promise<Object | null>} 수정된 게시물
*/
export const updateAdminPost = async (id, input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql.begin(async (transaction) => {
const updatedRows = await transaction`
UPDATE posts
SET
title = ${input.title},
slug = ${input.slug},
content = ${input.content},
excerpt = ${input.excerpt},
featured_image = ${input.featuredImage},
status = ${input.status},
published_at = ${input.publishedAt},
updated_at = now()
WHERE id = ${id}
RETURNING *
`
if (!updatedRows[0]) {
return []
}
await syncPostTags(transaction, id, input.tags)
return updatedRows
})
return rows[0] ? getAdminPostById(rows[0].id) : null
}
/**
* 관리자 게시물 삭제
* @param {string} id - 게시물 ID
* @returns {Promise<boolean>} 삭제 여부
*/
export const deleteAdminPost = async (id) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
DELETE FROM posts
WHERE id = ${id}
RETURNING id
`
return Boolean(rows[0])
}
/**
* 공개 게시물 상세 조회
* @param {string} slug - 게시물 슬러그
@@ -163,8 +374,101 @@ export const listTags = async () => {
const rows = await sql`
SELECT *
FROM tags
ORDER BY name ASC
ORDER BY sort_order ASC, name ASC
`
return rows.map(mapTagRow)
}
/**
* 관리자 태그 상세 조회
* @param {string} id - 태그 ID
* @returns {Promise<Object | null>} 태그 상세
*/
export const getAdminTagById = async (id) => {
const sql = getPostgresClient()
if (!sql) {
return getSampleTags().find((tag) => tag.id === id) || null
}
const rows = await sql`
SELECT *
FROM tags
WHERE id = ${id}
LIMIT 1
`
return rows[0] ? mapTagRow(rows[0]) : null
}
/**
* 관리자 태그 생성
* @param {Object} input - 태그 입력값
* @returns {Promise<Object>} 생성된 태그
*/
export const createAdminTag = async (input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
INSERT INTO tags (name, slug, description, sort_order, color)
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.sortOrder}, ${input.color})
RETURNING *
`
return mapTagRow(rows[0])
}
/**
* 관리자 태그 수정
* @param {string} id - 태그 ID
* @param {Object} input - 태그 입력값
* @returns {Promise<Object | null>} 수정된 태그
*/
export const updateAdminTag = async (id, input) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
UPDATE tags
SET
name = ${input.name},
slug = ${input.slug},
description = ${input.description},
sort_order = ${input.sortOrder},
color = ${input.color},
updated_at = now()
WHERE id = ${id}
RETURNING *
`
return rows[0] ? mapTagRow(rows[0]) : null
}
/**
* 관리자 태그 삭제
* @param {string} id - 태그 ID
* @returns {Promise<boolean>} 삭제 여부
*/
export const deleteAdminTag = async (id) => {
const sql = getPostgresClient()
if (!sql) {
throw new Error('DATABASE_REQUIRED')
}
const rows = await sql`
DELETE FROM tags
WHERE id = ${id}
RETURNING id
`
return Boolean(rows[0])
}

View File

@@ -0,0 +1,43 @@
import { z } from 'zod'
import { createError, readBody } from 'h3'
import { safeCompare, setAdminSession } from '../../../../utils/admin-auth'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
})
/**
* 관리자 로그인 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ email: string }>} 관리자 세션 정보
*/
export default defineEventHandler(async (event) => {
const parsedBody = loginSchema.safeParse(await readBody(event))
const config = useRuntimeConfig()
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '로그인 요청 형식이 올바르지 않습니다.'
})
}
const body = parsedBody.data
if (
!safeCompare(body.email, config.adminEmail) ||
!safeCompare(body.password, config.adminPassword)
) {
throw createError({
statusCode: 401,
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
})
}
setAdminSession(event, body.email)
return {
email: body.email
}
})

View File

@@ -0,0 +1,14 @@
import { clearAdminSession } from '../../../../utils/admin-auth'
/**
* 관리자 로그아웃 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ ok: boolean }} 로그아웃 결과
*/
export default defineEventHandler((event) => {
clearAdminSession(event)
return {
ok: true
}
})

View File

@@ -0,0 +1,8 @@
import { requireAdminSession } from '../../../../utils/admin-auth'
/**
* 관리자 세션 조회 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ email: string }} 관리자 세션 정보
*/
export default defineEventHandler((event) => requireAdminSession(event))

View File

@@ -0,0 +1,13 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { listAdminPosts } from '../../../repositories/content-repository'
/**
* 관리자 게시물 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} 게시물 목록
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
return listAdminPosts()
})

View File

@@ -0,0 +1,35 @@
import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth'
import { parseAdminPostInput } from '../../../utils/admin-post-input'
import { createAdminPost } from '../../../repositories/content-repository'
/**
* 관리자 게시물 생성 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 생성된 게시물
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const parsedBody = parseAdminPostInput(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '게시물 입력 형식이 올바르지 않습니다.'
})
}
try {
return await createAdminPost(parsedBody.data)
} catch (error) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
message: '이미 사용 중인 슬러그입니다.'
})
}
throw error
}
})

View File

@@ -0,0 +1,26 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { deleteAdminPost } from '../../../../repositories/content-repository'
/**
* 관리자 게시물 삭제 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ id: string }>} 삭제된 게시물 ID
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = getRouterParam(event, 'id')
const deleted = await deleteAdminPost(id)
if (!deleted) {
throw createError({
statusCode: 404,
message: '게시물을 찾을 수 없습니다.'
})
}
return {
id
}
})

View File

@@ -0,0 +1,24 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { getAdminPostById } from '../../../../repositories/content-repository'
/**
* 관리자 게시물 상세 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 게시물 상세
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = getRouterParam(event, 'id')
const post = await getAdminPostById(id)
if (!post) {
throw createError({
statusCode: 404,
message: '게시물을 찾을 수 없습니다.'
})
}
return post
})

View File

@@ -0,0 +1,45 @@
import { createError, getRouterParam, readBody } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { parseAdminPostInput } from '../../../../utils/admin-post-input'
import { updateAdminPost } from '../../../../repositories/content-repository'
/**
* 관리자 게시물 수정 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 게시물
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = getRouterParam(event, 'id')
const parsedBody = parseAdminPostInput(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '게시물 입력 형식이 올바르지 않습니다.'
})
}
try {
const post = await updateAdminPost(id, parsedBody.data)
if (!post) {
throw createError({
statusCode: 404,
message: '게시물을 찾을 수 없습니다.'
})
}
return post
} catch (error) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
message: '이미 사용 중인 슬러그입니다.'
})
}
throw error
}
})

View File

@@ -0,0 +1,13 @@
import { requireAdminSession } from '../../../utils/admin-auth'
import { listTags } from '../../../repositories/content-repository'
/**
* 관리자 태그 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Array>} 태그 목록
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
return listTags()
})

View File

@@ -0,0 +1,35 @@
import { createError, readBody } from 'h3'
import { requireAdminSession } from '../../../utils/admin-auth'
import { parseAdminTagInput } from '../../../utils/admin-tag-input'
import { createAdminTag } from '../../../repositories/content-repository'
/**
* 관리자 태그 생성 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 생성된 태그
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const parsedBody = parseAdminTagInput(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '태그 입력 형식이 올바르지 않습니다.'
})
}
try {
return await createAdminTag(parsedBody.data)
} catch (error) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
message: '이미 사용 중인 태그 슬러그입니다.'
})
}
throw error
}
})

View File

@@ -0,0 +1,26 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { deleteAdminTag } from '../../../../repositories/content-repository'
/**
* 관리자 태그 삭제 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ id: string }>} 삭제된 태그 ID
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = getRouterParam(event, 'id')
const deleted = await deleteAdminTag(id)
if (!deleted) {
throw createError({
statusCode: 404,
message: '태그를 찾을 수 없습니다.'
})
}
return {
id
}
})

View File

@@ -0,0 +1,24 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { getAdminTagById } from '../../../../repositories/content-repository'
/**
* 관리자 태그 상세 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 태그 상세
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = getRouterParam(event, 'id')
const tag = await getAdminTagById(id)
if (!tag) {
throw createError({
statusCode: 404,
message: '태그를 찾을 수 없습니다.'
})
}
return tag
})

View File

@@ -0,0 +1,45 @@
import { createError, getRouterParam, readBody } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { parseAdminTagInput } from '../../../../utils/admin-tag-input'
import { updateAdminTag } from '../../../../repositories/content-repository'
/**
* 관리자 태그 수정 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 태그
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const id = getRouterParam(event, 'id')
const parsedBody = parseAdminTagInput(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '태그 입력 형식이 올바르지 않습니다.'
})
}
try {
const tag = await updateAdminTag(id, parsedBody.data)
if (!tag) {
throw createError({
statusCode: 404,
message: '태그를 찾을 수 없습니다.'
})
}
return tag
} catch (error) {
if (error?.code === '23505') {
throw createError({
statusCode: 409,
message: '이미 사용 중인 태그 슬러그입니다.'
})
}
throw error
}
})

147
server/utils/admin-auth.js Normal file
View File

@@ -0,0 +1,147 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
const adminSessionCookieName = 'sori_admin_session'
const sessionMaxAge = 60 * 60 * 12
/**
* 세션 서명 비밀값 조회
* @returns {string} 세션 서명 비밀값
*/
const getSessionSecret = () => {
const config = useRuntimeConfig()
if (!config.adminPassword) {
throw createError({
statusCode: 500,
message: '관리자 비밀번호 환경 변수가 없습니다.'
})
}
return config.adminPassword
}
/**
* 문자열 안전 비교
* @param {string} left - 비교 문자열
* @param {string} right - 비교 대상 문자열
* @returns {boolean} 일치 여부
*/
export const safeCompare = (left, right) => {
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
if (leftBuffer.length !== rightBuffer.length) {
return false
}
return timingSafeEqual(leftBuffer, rightBuffer)
}
/**
* 세션 페이로드 서명
* @param {string} payload - 인코딩된 세션 페이로드
* @returns {string} 세션 서명
*/
const signPayload = (payload) => createHmac('sha256', getSessionSecret())
.update(payload)
.digest('base64url')
/**
* 관리자 세션 토큰 생성
* @param {string} email - 관리자 이메일
* @returns {string} 세션 토큰
*/
export const createAdminSessionToken = (email) => {
const payload = Buffer.from(JSON.stringify({
email,
expiresAt: Date.now() + sessionMaxAge * 1000
})).toString('base64url')
return `${payload}.${signPayload(payload)}`
}
/**
* 관리자 세션 토큰 검증
* @param {string | undefined} token - 세션 토큰
* @returns {{ email: string } | null} 세션 정보
*/
export const verifyAdminSessionToken = (token) => {
if (!token) {
return null
}
const [payload, signature] = token.split('.')
if (!payload || !signature || !safeCompare(signature, signPayload(payload))) {
return null
}
let session = null
try {
session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
} catch {
return null
}
if (!session.email || !session.expiresAt || session.expiresAt < Date.now()) {
return null
}
return {
email: session.email
}
}
/**
* 관리자 세션 쿠키 설정
* @param {import('h3').H3Event} event - 요청 이벤트
* @param {string} email - 관리자 이메일
* @returns {void}
*/
export const setAdminSession = (event, email) => {
setCookie(event, adminSessionCookieName, createAdminSessionToken(email), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/admin',
maxAge: sessionMaxAge
})
}
/**
* 관리자 세션 쿠키 삭제
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {void}
*/
export const clearAdminSession = (event) => {
deleteCookie(event, adminSessionCookieName, {
path: '/admin'
})
}
/**
* 관리자 세션 조회
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ email: string } | null} 세션 정보
*/
export const getAdminSession = (event) => verifyAdminSessionToken(getCookie(event, adminSessionCookieName))
/**
* 관리자 세션 필수 확인
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {{ email: string }} 세션 정보
*/
export const requireAdminSession = (event) => {
const session = getAdminSession(event)
if (!session) {
throw createError({
statusCode: 401,
message: '관리자 로그인이 필요합니다.'
})
}
return session
}

View File

@@ -0,0 +1,20 @@
import { z } from 'zod'
import { postStatusSchema } from './content-schema'
export const adminPostInputSchema = z.object({
title: z.string().trim().min(1),
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
content: z.string().default(''),
excerpt: z.string().default(''),
featuredImage: z.string().trim().nullable().default(null),
status: postStatusSchema.default('draft'),
publishedAt: z.string().datetime().nullable().default(null),
tags: z.array(z.string().trim().min(1)).default([])
})
/**
* 관리자 게시물 입력값 정리
* @param {unknown} body - 요청 본문
* @returns {import('zod').SafeParseReturnType<unknown, Object>} 검증 결과
*/
export const parseAdminPostInput = (body) => adminPostInputSchema.safeParse(body)

View File

@@ -0,0 +1,16 @@
import { z } from 'zod'
export const adminTagInputSchema = z.object({
name: z.string().trim().min(1),
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
description: z.string().default(''),
sortOrder: z.number().int().min(0).default(0),
color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).default('#15171a')
})
/**
* 관리자 태그 입력값 정리
* @param {unknown} body - 요청 본문
* @returns {import('zod').SafeParseReturnType<unknown, Object>} 검증 결과
*/
export const parseAdminTagInput = (body) => adminTagInputSchema.safeParse(body)

View File

@@ -30,5 +30,7 @@ export const tagSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
slug: z.string().min(1),
description: z.string().default('')
description: z.string().default(''),
sortOrder: z.number().int().default(0),
color: z.string().default('#15171a')
})

View File

@@ -48,13 +48,17 @@ const sampleTags = [
id: '44444444-4444-4444-8444-444444444444',
name: 'NOTE',
slug: 'note',
description: '생각과 기록을 모아두는 태그입니다.'
description: '생각과 기록을 모아두는 태그입니다.',
sortOrder: 10,
color: '#f97316'
},
{
id: '55555555-5555-4555-8555-555555555555',
name: 'DEV',
slug: 'dev',
description: '개발과 제작 과정을 기록하는 태그입니다.'
description: '개발과 제작 과정을 기록하는 태그입니다.',
sortOrder: 20,
color: '#06b6d4'
}
]