diff --git a/AGENTS.md b/AGENTS.md index 68695f4d..2ec944c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ # AGENTS Knowledge Base +## Mandatory Rule + +- PR 제목과 본문은 반드시 한국어로 작성한다. + ## Project Overview - Monorepo managed with `pnpm` + `turbo`. diff --git a/apps/web/src/app/api/ai-inspector-requests/route.ts b/apps/web/src/app/api/ai-inspector-requests/route.ts index dc5c76db..898d5f46 100644 --- a/apps/web/src/app/api/ai-inspector-requests/route.ts +++ b/apps/web/src/app/api/ai-inspector-requests/route.ts @@ -22,6 +22,8 @@ const requestBodySchema = z.object({ selection: selectionSchema, }); +type AdminVerificationStatus = "authorized" | "unauthorized" | "forbidden" | "error"; + const decodeTokenUserId = (accessToken: string): string | null => { try { const payloadSegment = accessToken.split(".")[1]; @@ -43,7 +45,57 @@ const decodeTokenUserId = (accessToken: string): string | null => { } }; -const verifyAdminRole = async (accessToken: string): Promise => { +const parseRoleValue = (rawValue: unknown): string => { + if (typeof rawValue !== "string") { + return ""; + } + + return rawValue.trim().toUpperCase(); +}; + +const hasAdminRole = (rawValue: unknown): boolean => { + const normalizedRole = parseRoleValue(rawValue); + return normalizedRole === UserRole.ADMIN || normalizedRole === "ROLE_ADMIN"; +}; + +const getRoleFromMyResponse = (data: unknown): unknown => { + if (!data || typeof data !== "object") { + return ""; + } + + const root = data as Record; + if (typeof root.role === "string") { + return root.role; + } + + const nestedData = root.data; + if (nestedData && typeof nestedData === "object") { + const nested = nestedData as Record; + if (typeof nested.role === "string") { + return nested.role; + } + } + + return ""; +}; + +const decodeTokenRole = (accessToken: string): unknown => { + try { + const payloadSegment = accessToken.split(".")[1]; + if (!payloadSegment) { + return ""; + } + + const normalized = payloadSegment.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as { role?: string }; + return payload.role ?? ""; + } catch { + return ""; + } +}; + +const verifyAdminRole = async (accessToken: string): Promise => { const apiServerUrl = process.env.NEXT_PUBLIC_API_SERVER_URL?.trim(); if (!apiServerUrl) { throw new Error("NEXT_PUBLIC_API_SERVER_URL is not configured."); @@ -57,14 +109,28 @@ const verifyAdminRole = async (accessToken: string): Promise => { cache: "no-store", }); + if (response.status === 401) { + return "unauthorized"; + } + if (!response.ok) { - return false; + return "forbidden"; + } + + const data = (await response.json().catch(() => null)) as unknown; + const responseRole = getRoleFromMyResponse(data); + if (hasAdminRole(responseRole)) { + return "authorized"; + } + + // Fallback: /my 인증(200)은 통과했는데 응답 role 스키마가 다른 경우를 대비. + if (hasAdminRole(decodeTokenRole(accessToken))) { + return "authorized"; } - const data = (await response.json()) as { role?: string }; - return data.role === UserRole.ADMIN; + return "forbidden"; } catch { - return false; + return "error"; } }; @@ -96,14 +162,25 @@ async function POST(request: NextRequest) { return NextResponse.json({ message: "요청 본문을 읽을 수 없습니다." }, { status: 400 }); } - let isAdmin = false; + let verificationStatus: AdminVerificationStatus = "forbidden"; try { - isAdmin = await verifyAdminRole(accessToken); + verificationStatus = await verifyAdminRole(accessToken); } catch { return NextResponse.json({ message: "서버 인증 설정 오류입니다." }, { status: 500 }); } - if (!isAdmin) { + if (verificationStatus === "unauthorized") { + return NextResponse.json({ message: "로그인 세션이 만료되었습니다. 다시 로그인해주세요." }, { status: 401 }); + } + + if (verificationStatus === "error") { + return NextResponse.json( + { message: "관리자 권한 확인에 실패했습니다. 잠시 후 다시 시도해주세요." }, + { status: 503 }, + ); + } + + if (verificationStatus !== "authorized") { return NextResponse.json({ message: "관리자 권한이 필요합니다." }, { status: 403 }); } diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 5fd9a1cd..4062638a 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,11 +1,54 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { isTokenExpired } from "@/utils/jwtUtils"; const loginNeedPages = ["/mentor", "/my", "/community"]; // 로그인 필요페이지 +const blockedExactPaths = new Set([ + "/database.php", + "/db.php", + "/config.php", + "/phpinfo.php", + "/xmlrpc.php", + "/wp-login.php", +]); +const blockedPathPrefixes = ["/wp-admin", "/phpmyadmin", "/pma", "/.env", "/.git", "/vendor"]; + +const isStageHostname = (hostname: string) => hostname.includes("stage"); + +const isProbePath = (pathname: string) => { + if (blockedExactPaths.has(pathname)) { + return true; + } + + if (pathname.endsWith(".php")) { + return true; + } + + return blockedPathPrefixes.some((prefix) => pathname.startsWith(prefix)); +}; export function middleware(request: NextRequest) { const url = request.nextUrl.clone(); + const pathname = url.pathname; + + if (pathname === "/robots.txt" && isStageHostname(url.hostname)) { + return new NextResponse("User-agent: *\nDisallow: /\n", { + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "public, max-age=600", + "X-Robots-Tag": "noindex, nofollow, noarchive", + }, + }); + } + + if (isProbePath(pathname)) { + return new NextResponse("Not Found", { + status: 404, + headers: { + "Cache-Control": "no-store", + }, + }); + } // localhost 환경에서는 미들웨어 적용 X // if (url.hostname === "localhost") { @@ -14,14 +57,13 @@ export function middleware(request: NextRequest) { // HTTP-only 쿠키의 refreshToken 확인 const refreshToken = request.cookies.get("refreshToken")?.value; - const hasValidRefreshToken = Boolean(refreshToken && !isTokenExpired(refreshToken)); // 정확한 경로 매칭 const needLogin = loginNeedPages.some((path) => { - return url.pathname === path || url.pathname.startsWith(`${path}/`); + return pathname === path || pathname.startsWith(`${path}/`); }); - if (needLogin && !hasValidRefreshToken) { + if (needLogin && !refreshToken) { url.pathname = "/login"; url.searchParams.delete("reason"); return NextResponse.redirect(url);