From 03fd4ee240fe2c30781ff478eabf78e21985d9f9 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Tue, 1 Sep 2026 21:02:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20GitHub=20id=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=EC=A6=9D=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - authOptions를 app/lib/auth.ts로 분리하고 getServerSession(authOptions)를 일관되게 사용 - 관리자 판정 기준을 ADMIN_EMAIL에서 ADMIN_GITHUB_ID(GitHub 숫자 id)로 교체 - 18개 API 라우트의 getServerSession + isAdminSession 반복 패턴을 authz.ts의 getAdminSession/getSession 헬퍼로 통일 - next-auth 모듈 타입을 app/types/next-auth.d.ts로 확장해 session.isAdmin, session.user.id, session.user.githubLogin에 대한 인라인 캐스팅 제거 - atelier 방문자 메시지 저장 시 author.githubId에 githubLogin 대신 session.user.id를 저장하도록 수정 (기존에는 소유권 비교(id)와 저장값(login)이 어긋나 방문자가 자신의 글을 수정/삭제할 수 없었음) - README 환경변수 안내를 ADMIN_GITHUB_ID로 갱신 Co-Authored-By: Claude Sonnet 5 --- README.md | 5 +- app/api/admin/analytics/daily-posts/route.ts | 6 +- app/api/admin/analytics/popular/route.ts | 6 +- app/api/admin/analytics/referrers/route.ts | 6 +- app/api/admin/comments/route.ts | 6 +- app/api/admin/posts/recent/route.ts | 6 +- app/api/admin/settings/llms/route.ts | 6 +- app/api/admin/stats/daily/route.ts | 6 +- app/api/admin/stats/route.ts | 6 +- app/api/admin/subscribers/route.ts | 6 +- app/api/atelier/block/route.ts | 6 +- app/api/atelier/messages/[id]/route.ts | 18 +++--- app/api/atelier/messages/[id]/thread/route.ts | 8 +-- app/api/atelier/messages/route.ts | 16 +++-- app/api/auth/[...nextauth]/route.ts | 63 +------------------ app/api/drafts/route.ts | 15 +++-- app/api/posts/[slug]/route.ts | 9 +-- app/api/posts/route.ts | 7 +-- app/api/series/[slug]/route.ts | 11 +--- app/api/series/reorder/route.ts | 7 +-- app/api/series/route.ts | 7 +-- app/api/upload/route.ts | 7 +-- app/entities/common/Layout/ProtectedRoute.tsx | 2 +- app/entities/post/detail/PostEditButton.tsx | 4 +- app/hooks/atelier/useAtelierAuthor.ts | 7 +-- app/lib/auth.ts | 58 +++++++++++++++++ app/lib/authz.ts | 17 ++++- app/types/next-auth.d.ts | 23 +++++++ 28 files changed, 166 insertions(+), 178 deletions(-) create mode 100644 app/lib/auth.ts create mode 100644 app/types/next-auth.d.ts diff --git a/README.md b/README.md index dcf8645c..a79a47ec 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ TechBlog/ ```text 1 GITHUB_ID=your_github_client_id 2 GITHUB_SECRET=your_github_client_secret - 3 ADMIN_EMAIL=your_admin_email@example.com + 3 ADMIN_GITHUB_ID=your_admin_github_numeric_id 4 NEXTAUTH_SECRET=your_nextauth_secret 5 NEXTAUTH_URL=http://localhost:3000 6 DB_URI=your_mongodb_connection_string @@ -83,3 +83,6 @@ TechBlog/ 8 NEXT_PUBLIC_URL=http://localhost:3000 9 BLOB_READ_WRITE_TOKEN=your_vercel_blob_token ``` + +`ADMIN_GITHUB_ID`는 이메일이 아닌 GitHub 계정의 숫자 id다. +`https://api.github.com/users/{GitHub 아이디}` 응답의 `id` 값을 그대로 넣으면 된다. diff --git a/app/api/admin/analytics/daily-posts/route.ts b/app/api/admin/analytics/daily-posts/route.ts index 5950b3b9..859aecc8 100644 --- a/app/api/admin/analytics/daily-posts/route.ts +++ b/app/api/admin/analytics/daily-posts/route.ts @@ -1,7 +1,6 @@ // GET /api/admin/analytics/daily-posts?date=YYYY-MM-DD import { NextRequest } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import View from '@/app/models/View'; @@ -9,9 +8,8 @@ export const dynamic = 'force-dynamic'; export async function GET(request: NextRequest) { try { - const session = await getServerSession(); // 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json({ success: false, error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/admin/analytics/popular/route.ts b/app/api/admin/analytics/popular/route.ts index 7f34e44f..425ec543 100644 --- a/app/api/admin/analytics/popular/route.ts +++ b/app/api/admin/analytics/popular/route.ts @@ -1,7 +1,6 @@ // GET /api/admin/analytics/popular?type=all|today import { NextRequest } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import View from '@/app/models/View'; @@ -47,9 +46,8 @@ const commonProject = { export async function GET(request: NextRequest) { try { - const session = await getServerSession(); // 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json( { success: false, error: 'Unauthorized' }, { status: 401 } diff --git a/app/api/admin/analytics/referrers/route.ts b/app/api/admin/analytics/referrers/route.ts index f82d7a24..412c44f3 100644 --- a/app/api/admin/analytics/referrers/route.ts +++ b/app/api/admin/analytics/referrers/route.ts @@ -1,7 +1,6 @@ // GET /api/admin/analytics/referrers?postId=xxx import { NextRequest } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import View from '@/app/models/View'; @@ -19,9 +18,8 @@ function normalizeReferrer(ref: string): string { export async function GET(request: NextRequest) { try { - const session = await getServerSession(); // 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json({ success: false, error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/admin/comments/route.ts b/app/api/admin/comments/route.ts index d1f2109c..b8c541ac 100644 --- a/app/api/admin/comments/route.ts +++ b/app/api/admin/comments/route.ts @@ -1,6 +1,5 @@ // GET /api/admin/comments - 관리자용 GitHub Issues 댓글 조회 -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; export const dynamic = 'force-dynamic'; @@ -38,8 +37,7 @@ interface GitHubComment { export async function GET() { try { // 관리자 전용 - const session = await getServerSession(); - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json( { success: false, error: 'Unauthorized' }, { status: 401 } diff --git a/app/api/admin/posts/recent/route.ts b/app/api/admin/posts/recent/route.ts index a7aceb7c..7e132353 100644 --- a/app/api/admin/posts/recent/route.ts +++ b/app/api/admin/posts/recent/route.ts @@ -1,6 +1,5 @@ // GET /api/admin/posts/recent - 관리자용 최근 게시글 조회 -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import Post from '@/app/models/Post'; @@ -8,9 +7,8 @@ export const dynamic = 'force-dynamic'; export async function GET() { try { - const session = await getServerSession(); // 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json( { success: false, error: 'Unauthorized' }, { status: 401 } diff --git a/app/api/admin/settings/llms/route.ts b/app/api/admin/settings/llms/route.ts index 4759762b..5071d182 100644 --- a/app/api/admin/settings/llms/route.ts +++ b/app/api/admin/settings/llms/route.ts @@ -1,14 +1,12 @@ -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import { generateLlmsTxt } from '@/app/lib/llmstxt'; import Post from '@/app/models/Post'; import View from '@/app/models/View'; export async function POST() { - const session = await getServerSession(); // 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json({ success: false, error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/admin/stats/daily/route.ts b/app/api/admin/stats/daily/route.ts index b521c9f6..76abab10 100644 --- a/app/api/admin/stats/daily/route.ts +++ b/app/api/admin/stats/daily/route.ts @@ -1,6 +1,5 @@ // GET /api/admin/stats/daily - 최근 14일간 일별 조회수 -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import View from '@/app/models/View'; @@ -8,9 +7,8 @@ export const dynamic = 'force-dynamic'; export async function GET() { try { - const session = await getServerSession(); // 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json({ success: false, error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts index be82b992..a017c934 100644 --- a/app/api/admin/stats/route.ts +++ b/app/api/admin/stats/route.ts @@ -1,6 +1,5 @@ // GET /api/admin/stats - 관리자용 블로그 통계 -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import Post from '@/app/models/Post'; import Series from '@/app/models/Series'; @@ -10,9 +9,8 @@ export const dynamic = 'force-dynamic'; export async function GET() { try { - const session = await getServerSession(); // 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json( { success: false, error: 'Unauthorized' }, { status: 401 } diff --git a/app/api/admin/subscribers/route.ts b/app/api/admin/subscribers/route.ts index 90930217..9daea2df 100644 --- a/app/api/admin/subscribers/route.ts +++ b/app/api/admin/subscribers/route.ts @@ -1,5 +1,4 @@ -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import Subscriber from '@/app/models/Subscriber'; @@ -7,9 +6,8 @@ export const dynamic = 'force-dynamic'; export async function GET() { try { - const session = await getServerSession(); // 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json( { success: false, error: 'Unauthorized' }, { status: 401 } diff --git a/app/api/atelier/block/route.ts b/app/api/atelier/block/route.ts index 7b5014b7..cf0563fa 100644 --- a/app/api/atelier/block/route.ts +++ b/app/api/atelier/block/route.ts @@ -1,6 +1,5 @@ // POST /api/atelier/block - 관리자 전용 사용자 차단 (fingerprint 또는 GitHub ID) -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import BlockedFingerprint from '@/app/models/BlockedFingerprint'; @@ -9,8 +8,7 @@ export const POST = async (request: Request) => { await dbConnect(); // 관리자 전용 - const session = await getServerSession(); - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json( { success: false, error: 'Unauthorized' }, { status: 401 } diff --git a/app/api/atelier/messages/[id]/route.ts b/app/api/atelier/messages/[id]/route.ts index a1f427a5..579abc44 100644 --- a/app/api/atelier/messages/[id]/route.ts +++ b/app/api/atelier/messages/[id]/route.ts @@ -1,9 +1,8 @@ // DELETE /api/atelier/messages/[id] - 관리자 또는 소유자 소프트 삭제 // PATCH /api/atelier/messages/[id] - 관리자 전용 isPublic 토글 // PUT /api/atelier/messages/[id] - 소유자 또는 관리자 메시지 수정 -import { getServerSession } from 'next-auth'; import { serializeAtelierMessage, LeanAtelierMessage } from '@/app/lib/atelierSerialize'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession, getSession, isAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import AtelierMessage from '@/app/models/AtelierMessage'; @@ -16,7 +15,7 @@ export const DELETE = async (request: Request, props: RouteParams) => { try { await dbConnect(); - const session = await getServerSession(); + const session = await getSession(); const isAdmin = isAdminSession(session); const { id } = params; @@ -37,7 +36,7 @@ export const DELETE = async (request: Request, props: RouteParams) => { // 소유권 확인: admin OR 소유자 let isOwner = false; - const sessionGithubId = (session?.user as { id?: string })?.id; + const sessionGithubId = session?.user?.id; if (sessionGithubId) { // 로그인 사용자: githubId로 비교 isOwner = message.author?.githubId === sessionGithubId; @@ -85,8 +84,7 @@ export const PATCH = async (request: Request, props: RouteParams) => { await dbConnect(); // 관리자 전용 - const session = await getServerSession(); - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json( { success: false, error: 'Unauthorized' }, { status: 401 } @@ -146,7 +144,7 @@ export const PUT = async (request: Request, props: RouteParams) => { try { await dbConnect(); - const session = await getServerSession(); + const session = await getSession(); const isAdmin = isAdminSession(session); const { id } = params; @@ -197,7 +195,7 @@ export const PUT = async (request: Request, props: RouteParams) => { // 소유권 확인: admin OR 소유자 let isOwner = false; - const sessionGithubId = (session?.user as { id?: string })?.id; + const sessionGithubId = session?.user?.id; if (sessionGithubId) { // 로그인 사용자: githubId로 비교 isOwner = message.author?.githubId === sessionGithubId; @@ -230,9 +228,7 @@ export const PUT = async (request: Request, props: RouteParams) => { } const fingerprint = request.headers.get('X-Fingerprint'); - const githubId = - (session?.user as { id?: string })?.id || null; - const serialized = serializeAtelierMessage(updated, fingerprint, githubId); + const serialized = serializeAtelierMessage(updated, fingerprint, sessionGithubId || null); return Response.json( { success: true, message: serialized }, diff --git a/app/api/atelier/messages/[id]/thread/route.ts b/app/api/atelier/messages/[id]/thread/route.ts index ccf507d5..9d3b4ccf 100644 --- a/app/api/atelier/messages/[id]/thread/route.ts +++ b/app/api/atelier/messages/[id]/thread/route.ts @@ -1,10 +1,9 @@ // GET /api/atelier/messages/[id]/thread - 스레드 답글 전체 조회 -import { getServerSession } from 'next-auth'; import { LeanAtelierMessage, serializeAtelierMessage, } from '@/app/lib/atelierSerialize'; -import { isAdminSession } from '@/app/lib/authz'; +import { getSession, isAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import AtelierMessage from '@/app/models/AtelierMessage'; @@ -25,11 +24,10 @@ export const GET = async (request: Request, props: RouteParams) => { ); } - const session = await getServerSession(); + const session = await getSession(); const isAdmin = isAdminSession(session); const viewerFingerprint = request.headers.get('X-Fingerprint') || null; - const viewerGithubId = - (session?.user as { id?: string })?.id || null; + const viewerGithubId = session?.user?.id || null; // 쿼리 구성 const query: Record = { diff --git a/app/api/atelier/messages/route.ts b/app/api/atelier/messages/route.ts index 46d41589..b2023677 100644 --- a/app/api/atelier/messages/route.ts +++ b/app/api/atelier/messages/route.ts @@ -1,14 +1,13 @@ // GET /api/atelier/messages - 커서 기반 역방향 무한 스크롤 // POST /api/atelier/messages - 메시지 전송 (관리자 / 방문자 자동 분기) import { NextRequest } from 'next/server'; -import { getServerSession } from 'next-auth'; import { decodeCursor, encodeCursor } from '@/app/lib/atelierCursor'; import { parseEffect } from '@/app/lib/atelierEffects'; import { LeanAtelierMessage, serializeAtelierMessage, } from '@/app/lib/atelierSerialize'; -import { isAdminSession } from '@/app/lib/authz'; +import { getSession, isAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import { checkRateLimit } from '@/app/lib/rateLimit'; import AtelierMessage from '@/app/models/AtelierMessage'; @@ -21,7 +20,7 @@ export const GET = async (request: NextRequest) => { try { await dbConnect(); - const session = await getServerSession(); + const session = await getSession(); const isAdmin = isAdminSession(session); const cursorParam = request.nextUrl.searchParams.get('cursor'); @@ -47,8 +46,7 @@ export const GET = async (request: NextRequest) => { } const viewerFingerprint = request.headers.get('X-Fingerprint') || null; - const viewerGithubId = - (session?.user as { id?: string })?.id || null; + const viewerGithubId = session?.user?.id || null; // hasMore 판정을 위해 limit + 1 조회 const docs = (await AtelierMessage.find(query) @@ -99,7 +97,7 @@ export const POST = async (request: Request) => { await dbConnect(); const fingerprint = request.headers.get('X-Fingerprint') || ''; - const session = await getServerSession(); + const session = await getSession(); const isAdmin = isAdminSession(session); const body = (await request.json()) as unknown; @@ -196,8 +194,6 @@ export const POST = async (request: Request) => { }; } else if (session?.user) { // GitHub 로그인 방문자 - const githubLogin = (session.user as { githubLogin?: string }) - .githubLogin; role = 'visitor'; author = { nickname: @@ -205,7 +201,9 @@ export const POST = async (request: Request) => { (typeof nickname === 'string' && nickname.trim()) || '익명', avatarUrl: session.user.image || undefined, - githubId: githubLogin, + // DELETE/PUT의 소유권 확인은 session.user.id(GitHub 숫자 id)와 비교하므로 + // githubLogin(계정명)이 아닌 id를 저장해야 한다. + githubId: session.user.id, fingerprint, }; } else { diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index de6d9d41..4c28971f 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,63 +1,6 @@ -import NextAuth, { Session } from 'next-auth'; -import { JWT } from 'next-auth/jwt'; -import GithubProvider from 'next-auth/providers/github'; +import NextAuth from 'next-auth'; +import { authOptions } from '@/app/lib/auth'; -interface AtelierSession extends Session { - isAdmin?: boolean; -} - -interface AtelierJWT extends JWT { - githubLogin?: string; - githubId?: number; - githubBio?: string; - githubCompany?: string; - githubLocation?: string; -} - -interface GitHubProfile { - login: string; - id: number; - name?: string; - email?: string; - bio?: string; - company?: string; - location?: string; -} - -const handler = NextAuth({ - providers: [ - GithubProvider({ - clientId: process.env.GITHUB_ID!, - clientSecret: process.env.GITHUB_SECRET!, - }), - ], - callbacks: { - async signIn() { - return true; - }, - async jwt({ token, account }): Promise { - if (account?.profile) { - const profile = account.profile as GitHubProfile; - token.githubLogin = profile.login; - token.githubId = profile.id; - token.githubBio = profile.bio; - token.githubCompany = profile.company; - token.githubLocation = profile.location; - } - return token as AtelierJWT; - }, - async session({ session, token }): Promise { - const jwtToken = token as AtelierJWT; - (session as AtelierSession).isAdmin = - session.user?.email === process.env.ADMIN_EMAIL; - if (jwtToken.githubLogin) { - (session.user as { githubLogin?: string }).githubLogin = - jwtToken.githubLogin; - } - return session as AtelierSession; - }, - }, - secret: process.env.NEXTAUTH_SECRET, -}); +const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; diff --git a/app/api/drafts/route.ts b/app/api/drafts/route.ts index 176d3c39..6bf585d8 100644 --- a/app/api/drafts/route.ts +++ b/app/api/drafts/route.ts @@ -1,14 +1,13 @@ -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import CloudDraft from '@/app/models/CloudDraft'; // GET /api/drafts - 사용자의 클라우드 임시저장본 조회 export async function GET(req: Request) { try { - const session = await getServerSession(); // 클라우드 드래프트는 관리자 전용 기능 - if (!isAdminSession(session) || !session?.user?.email) { + const session = await getAdminSession(); + if (!session?.user?.email) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -36,9 +35,9 @@ export async function GET(req: Request) { // POST /api/drafts - 클라우드 임시저장본 생성 또는 업데이트 export async function POST(req: Request) { try { - const session = await getServerSession(); // 관리자 전용 - if (!isAdminSession(session) || !session?.user?.email) { + const session = await getAdminSession(); + if (!session?.user?.email) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -132,9 +131,9 @@ export async function POST(req: Request) { // DELETE /api/drafts?draftId=xxx - 특정 클라우드 임시저장본 삭제 export async function DELETE(req: Request) { try { - const session = await getServerSession(); // 관리자 전용 - if (!isAdminSession(session) || !session?.user?.email) { + const session = await getAdminSession(); + if (!session?.user?.email) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/app/api/posts/[slug]/route.ts b/app/api/posts/[slug]/route.ts index 63ad08f3..1f8ba53e 100644 --- a/app/api/posts/[slug]/route.ts +++ b/app/api/posts/[slug]/route.ts @@ -1,7 +1,6 @@ // app/api/posts/[slug]/route.ts import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import { getThumbnailInMarkdown } from '@/app/lib/utils/parse'; import Post from '@/app/models/Post'; @@ -42,8 +41,7 @@ export async function PUT(req: NextRequest, props: { params: Promise<{ slug: str const params = await props.params; try { // 글 수정은 관리자 전용 - const session = await getServerSession(); - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return Response.json( { success: false, error: 'Unauthorized' }, { status: 401 } @@ -109,8 +107,7 @@ export async function DELETE(request: Request, props: { params: Promise<{ slug: const params = await props.params; try { // 글 삭제는 관리자 전용 - const session = await getServerSession(); - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index 9c05c5fe..404620f7 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -1,7 +1,6 @@ // GET /api/posts - 모든 글 조회 (페이지네이션 지원) import { QuerySelector } from 'mongoose'; -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import { getThumbnailInMarkdown } from '@/app/lib/utils/parse'; import Post from '@/app/models/Post'; @@ -113,10 +112,8 @@ export async function GET(req: Request) { // POST /api/posts - 글 작성 API export async function POST(req: Request) { try { - const session = await getServerSession(); - // 글 작성은 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return new Response('Unauthorized', { status: 401 }); } diff --git a/app/api/series/[slug]/route.ts b/app/api/series/[slug]/route.ts index 325b9639..b304801e 100644 --- a/app/api/series/[slug]/route.ts +++ b/app/api/series/[slug]/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import Series from '@/app/models/Series'; import '@/app/models/Post'; @@ -39,10 +38,8 @@ export async function GET(request: Request, props: { params: Promise<{ slug: str export async function PUT(request: Request, props: { params: Promise<{ slug: string }> }) { const params = await props.params; try { - const session = await getServerSession(); - // 시리즈 수정은 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return new Response('Unauthorized', { status: 401 }); } @@ -80,10 +77,8 @@ export async function PUT(request: Request, props: { params: Promise<{ slug: str export async function DELETE(request: Request, props: { params: Promise<{ slug: string }> }) { const params = await props.params; try { - const session = await getServerSession(); - // 시리즈 삭제는 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return new Response('Unauthorized', { status: 401 }); } diff --git a/app/api/series/reorder/route.ts b/app/api/series/reorder/route.ts index 135fb2b4..b8b05d28 100644 --- a/app/api/series/reorder/route.ts +++ b/app/api/series/reorder/route.ts @@ -1,14 +1,11 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import Series from '@/app/models/Series'; export async function PUT(request: Request) { try { - const session = await getServerSession(); - - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return new Response('Unauthorized', { status: 401 }); } diff --git a/app/api/series/route.ts b/app/api/series/route.ts index 9e8d411a..e82bbfa7 100644 --- a/app/api/series/route.ts +++ b/app/api/series/route.ts @@ -1,16 +1,13 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import dbConnect from '@/app/lib/dbConnect'; import { createPostSlug } from '@/app/lib/utils/post'; import Series from '@/app/models/Series'; export async function POST(request: Request) { try { - const session = await getServerSession(); - // 시리즈 생성은 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return new Response('Unauthorized', { status: 401 }); } diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 442e94aa..bccad29a 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,14 +1,11 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; import sharp from 'sharp'; -import { isAdminSession } from '@/app/lib/authz'; +import { getAdminSession } from '@/app/lib/authz'; import { put } from '@vercel/blob'; export async function POST(request: Request): Promise { - const session = await getServerSession(); - // 이미지 업로드는 관리자 전용 - if (!isAdminSession(session)) { + if (!(await getAdminSession())) { return new NextResponse('Unauthorized', { status: 401 }); } diff --git a/app/entities/common/Layout/ProtectedRoute.tsx b/app/entities/common/Layout/ProtectedRoute.tsx index 9754b820..6b6702da 100644 --- a/app/entities/common/Layout/ProtectedRoute.tsx +++ b/app/entities/common/Layout/ProtectedRoute.tsx @@ -11,7 +11,7 @@ interface ProtectedRouteProps { // 세션 존재 여부만 체크하면 비관리자도 통과할 수 있다. 반드시 email 까지 검사. const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const session = useSession(); - const isAdmin = (session.data as (typeof session.data & { isAdmin?: boolean }) | undefined)?.isAdmin === true; + const isAdmin = session.data?.isAdmin === true; useEffect(() => { if (session.status === 'loading') return; diff --git a/app/entities/post/detail/PostEditButton.tsx b/app/entities/post/detail/PostEditButton.tsx index 9b2f6b0b..f3c953fa 100644 --- a/app/entities/post/detail/PostEditButton.tsx +++ b/app/entities/post/detail/PostEditButton.tsx @@ -4,9 +4,7 @@ import { SessionProvider, useSession } from 'next-auth/react'; const EditButton = ({ slug }: { slug: string }) => { const { data: session } = useSession(); - const isAdmin = - (session as (typeof session & { isAdmin?: boolean }) | null)?.isAdmin === - true; + const isAdmin = session?.isAdmin === true; if (!isAdmin) return null; diff --git a/app/hooks/atelier/useAtelierAuthor.ts b/app/hooks/atelier/useAtelierAuthor.ts index 181b7d7f..e7cc318d 100644 --- a/app/hooks/atelier/useAtelierAuthor.ts +++ b/app/hooks/atelier/useAtelierAuthor.ts @@ -26,7 +26,7 @@ const useAtelierAuthor = (): UseAtelierAuthorReturn => { const storedNickname = useNicknameStore((s) => s.nickname); const setStoredNickname = useNicknameStore((s) => s.setNickname); - const isAdmin = (session as (typeof session & { isAdmin?: boolean }) | null)?.isAdmin === true; + const isAdmin = session?.isAdmin === true; const isAuthenticated = status === 'authenticated'; @@ -35,9 +35,8 @@ const useAtelierAuthor = (): UseAtelierAuthorReturn => { if (!session?.user) return null; const name = session.user.name ?? ''; const image = session.user.image ?? ''; - // NextAuth 기본 세션에는 id 가 없을 수 있음 — email 을 폴백으로 사용 - const idCandidate = - (session.user as { id?: string }).id ?? session.user.email ?? ''; + // session.user.id(GitHub id)가 없으면 email 을 폴백으로 사용 + const idCandidate = session.user.id ?? session.user.email ?? ''; if (!name && !image && !idCandidate) return null; return { name, image, id: idCandidate }; }, [session]); diff --git a/app/lib/auth.ts b/app/lib/auth.ts new file mode 100644 index 00000000..90047b71 --- /dev/null +++ b/app/lib/auth.ts @@ -0,0 +1,58 @@ +// NextAuth 설정 — API 라우트(app/api/auth/[...nextauth])와 +// 서버 컴포넌트/라우트 핸들러(getServerSession(authOptions))에서 공유한다. +import { NextAuthOptions } from 'next-auth'; +import GithubProvider from 'next-auth/providers/github'; + +interface GitHubProfile { + login: string; + id: number; + name?: string; + email?: string; + bio?: string; + company?: string; + location?: string; +} + +export const authOptions: NextAuthOptions = { + providers: [ + GithubProvider({ + clientId: process.env.GITHUB_ID!, + clientSecret: process.env.GITHUB_SECRET!, + }), + ], + callbacks: { + async signIn() { + return true; + }, + async jwt({ token, account }) { + if (account?.profile) { + const profile = account.profile as GitHubProfile; + token.githubLogin = profile.login; + token.githubId = profile.id; + token.githubBio = profile.bio; + token.githubCompany = profile.company; + token.githubLocation = profile.location; + } + return token; + }, + async session({ session, token }) { + const adminGithubId = process.env.ADMIN_GITHUB_ID; + session.isAdmin = + !!adminGithubId && + !!token.githubId && + String(token.githubId) === adminGithubId; + + if (session.user) { + if (token.githubId) { + session.user.id = String(token.githubId); + } + if (token.githubLogin) { + session.user.githubLogin = token.githubLogin; + } + } + + return session; + }, + }, + secret: process.env.NEXTAUTH_SECRET, +}; diff --git a/app/lib/authz.ts b/app/lib/authz.ts index e9f99f38..7f6ac8c0 100644 --- a/app/lib/authz.ts +++ b/app/lib/authz.ts @@ -1,7 +1,20 @@ // 관리자 세션 판별 공용 헬퍼 // NextAuth signIn 콜백이 모든 GitHub 사용자를 허용하도록 완화되었으므로, // 각 API 라우트에서 관리자 권한이 필요한 경우 반드시 이 헬퍼로 재검증해야 한다. -import { Session } from 'next-auth'; +// 관리자 여부는 GitHub id(ADMIN_GITHUB_ID) 기준으로 판정하며, +// 실제 판정 로직은 app/lib/auth.ts의 session 콜백에서 session.isAdmin 에 계산해 둔다. +import { getServerSession, Session } from 'next-auth'; +import { authOptions } from '@/app/lib/auth'; + +// 현재 요청의 세션 조회 (authOptions 포함 — isAdmin/githubLogin 등 커스텀 필드 포함) +export const getSession = (): Promise => + getServerSession(authOptions); export const isAdminSession = (session: Session | null): boolean => - session?.user?.email === process.env.ADMIN_EMAIL; + session?.isAdmin === true; + +// 관리자 전용 라우트에서: 관리자면 세션을, 아니면 null을 반환한다. +export const getAdminSession = async (): Promise => { + const session = await getSession(); + return isAdminSession(session) ? session : null; +}; diff --git a/app/types/next-auth.d.ts b/app/types/next-auth.d.ts new file mode 100644 index 00000000..b60760ae --- /dev/null +++ b/app/types/next-auth.d.ts @@ -0,0 +1,23 @@ +// next-auth 기본 타입 확장 +// GitHub 프로필 정보와 관리자 판별 플래그를 Session/JWT에 추가한다. +import { DefaultSession } from 'next-auth'; + +declare module 'next-auth' { + interface Session { + isAdmin?: boolean; + user?: { + id?: string; + githubLogin?: string; + } & DefaultSession['user']; + } +} + +declare module 'next-auth/jwt' { + interface JWT { + githubLogin?: string; + githubId?: number; + githubBio?: string; + githubCompany?: string; + githubLocation?: string; + } +} From 4819a0b4be229f2ce376262b18dbed57d349e006 Mon Sep 17 00:00:00 2001 From: JeongwooSeo Date: Tue, 1 Sep 2026 21:57:14 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20hast=20Element=20properties.classNam?= =?UTF-8?q?e=EC=9D=84=20=EB=B0=B0=EC=97=B4=20=ED=98=95=ED=83=9C=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hast에서 className은 공백으로 구분된 다중 클래스를 표현하기 위해 string[]로 취급하는데, descriptionNode/createYoutubeIframe에서 문자열을 그대로 넣고 있어 pnpm 8(Node 18) 환경의 @types/hast 해석에서 타입 에러가 발생했다. 이 PR의 관리자 인증 변경과는 무관한 기존 결함. Co-Authored-By: Claude Sonnet 5 --- app/lib/utils/rehypeUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/utils/rehypeUtils.ts b/app/lib/utils/rehypeUtils.ts index a0c62f18..1a4b53a3 100644 --- a/app/lib/utils/rehypeUtils.ts +++ b/app/lib/utils/rehypeUtils.ts @@ -39,7 +39,7 @@ export const addDescriptionUnderImage = ( type: 'element' as const, tagName: 'span', properties: { - className: 'image-description', + className: ['image-description'], }, children: [ { @@ -126,7 +126,7 @@ export const createYoutubeIframe = ( height: height.toString(), frameBorder: '0', allowFullScreen: true, - className: 'youtube-embed', + className: ['youtube-embed'], }, children: [], };