관리자 기능과 태그 표시 설정 추가
This commit is contained in:
@@ -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])
|
||||
}
|
||||
|
||||
43
server/routes/admin/api/auth/login.post.js
Normal file
43
server/routes/admin/api/auth/login.post.js
Normal 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
|
||||
}
|
||||
})
|
||||
14
server/routes/admin/api/auth/logout.post.js
Normal file
14
server/routes/admin/api/auth/logout.post.js
Normal 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
|
||||
}
|
||||
})
|
||||
8
server/routes/admin/api/auth/me.get.js
Normal file
8
server/routes/admin/api/auth/me.get.js
Normal 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))
|
||||
13
server/routes/admin/api/posts.get.js
Normal file
13
server/routes/admin/api/posts.get.js
Normal 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()
|
||||
})
|
||||
35
server/routes/admin/api/posts.post.js
Normal file
35
server/routes/admin/api/posts.post.js
Normal 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
|
||||
}
|
||||
})
|
||||
26
server/routes/admin/api/posts/[id].delete.js
Normal file
26
server/routes/admin/api/posts/[id].delete.js
Normal 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
|
||||
}
|
||||
})
|
||||
24
server/routes/admin/api/posts/[id].get.js
Normal file
24
server/routes/admin/api/posts/[id].get.js
Normal 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
|
||||
})
|
||||
45
server/routes/admin/api/posts/[id].put.js
Normal file
45
server/routes/admin/api/posts/[id].put.js
Normal 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
|
||||
}
|
||||
})
|
||||
13
server/routes/admin/api/tags.get.js
Normal file
13
server/routes/admin/api/tags.get.js
Normal 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()
|
||||
})
|
||||
35
server/routes/admin/api/tags.post.js
Normal file
35
server/routes/admin/api/tags.post.js
Normal 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
|
||||
}
|
||||
})
|
||||
26
server/routes/admin/api/tags/[id].delete.js
Normal file
26
server/routes/admin/api/tags/[id].delete.js
Normal 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
|
||||
}
|
||||
})
|
||||
24
server/routes/admin/api/tags/[id].get.js
Normal file
24
server/routes/admin/api/tags/[id].get.js
Normal 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
|
||||
})
|
||||
45
server/routes/admin/api/tags/[id].put.js
Normal file
45
server/routes/admin/api/tags/[id].put.js
Normal 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
147
server/utils/admin-auth.js
Normal 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
|
||||
}
|
||||
20
server/utils/admin-post-input.js
Normal file
20
server/utils/admin-post-input.js
Normal 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)
|
||||
16
server/utils/admin-tag-input.js
Normal file
16
server/utils/admin-tag-input.js
Normal 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)
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user