전체 코드, 구조 검토 - P2 코드 수정 진행

This commit is contained in:
2026-07-04 15:59:50 +09:00
parent 2231ac4bfe
commit 0a9aee58e0
9 changed files with 83 additions and 25 deletions
+11
View File
@@ -30,6 +30,8 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
- Config lives in `auth.ts` (project root), not inside `app/`.
- Unauthenticated requests are caught by `proxy.ts` (middleware) and redirected to `/login`.
- API routes must call `auth()` and return 401 if no session.
- Session `maxAge` is 8 hours — do not lengthen without review.
- `auth.ts` tracks failed login attempts per admin name in an in-memory `Map`. After 5 consecutive failures the account is locked out for 15 minutes. Counter clears on success.
## Key files
@@ -85,6 +87,15 @@ Always record a log entry after a successful DB change. Use these `Type` values:
- P0-1: `getActionTarget` in `lib/extension-requests.ts` replaced with inline `select` (no `include: { maestro: true }`) — prevents `Password` from appearing in debug result logs.
- P0-2: Approve/cancel/reject flows in both `lib/extension-requests.ts` and `lib/upgrade-requests.ts` now use an atomic `updateMany({ Status: REQUESTED → target })` guard; `count === 0` triggers 404/409 — eliminates concurrent double-processing.
- P0-3: `docker-compose.yml` and `docker-compose.stage.yml` now set `TZ: Asia/Seoul`; `lib/maestros.ts` date-change log remark uses `formatDate()` instead of `toISOString().slice(0,10)` for KST consistency.
- P1-1/P1-2: `createExtensionRequest` and `approveExtensionRequest` require `ActivateStatus === ACTIVE`; `UpgradeRequestsSection` and `createUpgradeRequest` allow TRIAL same-tier upgrade.
- P1-3: `approveUpgradeRequest` re-verifies upgrade direction against maestro's current `AccountType` at approval time.
- P1-4/P1-5: `createExtensionRequest` wrapped in `db.$transaction`; both create functions write `request_extension_maestro` / `request_upgrade_maestro` log entries.
- P1-6: Extension/upgrade approval routes now `await` email send and include `emailSent: boolean` in response; table UI shows inline amber warning when `emailSent === false`.
- P2-2: `updateMaestro` now returns 400 instead of silently skipping when `availableActivateDateTime` cannot be parsed.
- P2-5: `withApiHandler` catch block returns `{ message: "Internal server error" }` JSON 500 instead of re-throwing (which produced HTML responses).
- P2-6: NextAuth session `maxAge` set to 8 hours (`auth.ts`).
- P2-7: In-memory brute-force protection added to `auth.ts` — 5 failures → 15-minute lockout per admin name.
- P2-8: Default `status` filter for extension/upgrade request lists changed from `undefined` (all) to `REQUEST_STATUSES.REQUESTED`; reset links updated to `?status=1`.
## Planned phases (not yet implemented)
+1 -1
View File
@@ -118,7 +118,7 @@ export default async function ExtensionRequestsPage({
size: "default",
className: "h-9",
})}
href="/extension-requests"
href={`/extension-requests?status=${REQUEST_STATUSES.REQUESTED}`}
>
</Link>
+1 -1
View File
@@ -120,7 +120,7 @@ export default async function UpgradeRequestsPage({
size: "default",
className: "h-9",
})}
href="/upgrade-requests"
href={`/upgrade-requests?status=${REQUEST_STATUSES.REQUESTED}`}
>
</Link>
+36 -1
View File
@@ -7,6 +7,32 @@ import { logger } from "@/lib/logger";
const AUTH_CTX = "auth";
type AttemptRecord = { count: number; until: number };
const loginAttempts = new Map<string, AttemptRecord>();
const MAX_ATTEMPTS = 5;
const LOCKOUT_MS = 15 * 60 * 1000;
function isLockedOut(adminName: string): boolean {
const record = loginAttempts.get(adminName);
if (!record || record.count < MAX_ATTEMPTS) return false;
if (record.until > Date.now()) return true;
loginAttempts.delete(adminName);
return false;
}
function recordFailure(adminName: string): void {
const record = loginAttempts.get(adminName) ?? { count: 0, until: 0 };
record.count += 1;
if (record.count >= MAX_ATTEMPTS) {
record.until = Date.now() + LOCKOUT_MS;
}
loginAttempts.set(adminName, record);
}
function clearAttempts(adminName: string): void {
loginAttempts.delete(adminName);
}
const credentialsSchema = z.object({
adminName: z.string().trim().min(1),
password: z.string().min(1),
@@ -53,6 +79,7 @@ export const {
},
session: {
strategy: "jwt",
maxAge: 8 * 60 * 60,
},
providers: [
Credentials({
@@ -67,8 +94,14 @@ export const {
return null;
}
const { db } = await import("@/lib/db");
const { adminName, password } = parsedCredentials.data;
if (isLockedOut(adminName)) {
logger.warn(AUTH_CTX, "login blocked (rate limit)", { adminName });
return null;
}
const { db } = await import("@/lib/db");
const admins = await db.$queryRaw<AdminAuthRow[]>`
SELECT AdminID, Name, Email, AccountType, ActivateStatus
FROM admin
@@ -81,10 +114,12 @@ export const {
const activateStatus = Number(admin?.ActivateStatus);
if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) {
recordFailure(adminName);
logger.warn(AUTH_CTX, "login failed", { adminName });
return null;
}
clearAttempts(adminName);
logger.info(AUTH_CTX, "login success", { adminName, adminID });
return {
id: String(adminID),
+11 -3
View File
@@ -156,7 +156,7 @@
## 5. 이행 순서 제안
1. **Step 1 (P0, 코드 수정 소규모)**
1. **Step 1 (P0, 코드 수정 소규모) ✅ 완료 (2026-07-04)**
- P0-1: extension `getActionTarget` select 전환 + db.ts Password 마스킹
- P0-2: 승인/취소/거절 4개 함수 조건부 UPDATE 가드
- P0-3: docker-compose `TZ: Asia/Seoul` + 날짜 문자열 변환 통일
@@ -172,8 +172,16 @@
- API route 2곳: `void sendXxxDoneEmail``await emailSent`, 응답에 `emailSent: boolean` 포함
- 테이블 UI 2곳: 응답 body를 단일 파싱으로 통일, 승인 성공 후 `emailSent === false`이면 인라인 경고 표시 (`router.refresh()` 후에도 client state 유지됨)
- stage 실측(체험→유료, 유료→유료 메일 검증)은 별도 수동 테스트 항목으로 남김
4. **Step 4 (P2 일괄)**
- 소규모 항목 묶음 처리 후 AGENTS.md / phase 문서 갱신
4. **Step 4 (P2 일괄) ✅ 완료 (2026-07-04)**
- P2-2: `updateMaestro`에서 파싱 불가 날짜 silent skip → 400 반환으로 교체 (`lib/maestros.ts`)
- P2-5: `withApiHandler` catch 블록 `throw error` → JSON 500 응답으로 교체 (`lib/api-handler.ts`)
- P2-6: NextAuth `session.maxAge: 8 * 60 * 60` 추가 (`auth.ts`)
- P2-7: 인메모리 브루트포스 방어 추가 (`auth.ts`) — 5회 실패 시 15분 잠금, 성공 시 카운터 초기화
- P2-8: 연장·업그레이드 신청 목록 `status` 기본값 `undefined`(전체) → `REQUEST_STATUSES.REQUESTED` 변경; 결과 타입 `status?: number | "all"``status: number | "all"` 업데이트; 초기화 링크 `?status=1` 포함으로 변경
- P2-4: 이메일 중복 검사 추가 안 함 (기존 서비스 정책 유지)
- P2-9: SQL 파라미터 PII 로깅 정책 — 이름·이메일 등이 debug 레벨에서 노출됨. stage에서 실데이터 사용 시 phase9 마스킹 규칙과 대조해 마스킹 대상 확정 (별도 작업)
- P2-10: 변경 없음 (신청 시점 스냅샷 유지)
- AGENTS.md 인증 섹션 및 구현 phase 목록 갱신
각 Step 완료 시 AGENTS.md의 규칙 표(로그 타입, 검증 규칙)를 함께 갱신한다.
+4 -1
View File
@@ -48,7 +48,10 @@ export function withApiHandler<P = Record<string, never>>(
error: String(error),
duration: Date.now() - t0,
});
throw error;
return NextResponse.json(
{ message: "Internal server error" },
{ status: 500 }
);
}
};
}
+2 -2
View File
@@ -22,7 +22,7 @@ const extensionRequestSearchSchema = z.object({
q: z.string().trim().catch(""),
status: z
.union([z.literal("all"), z.coerce.number().int().min(0)])
.optional()
.default(REQUEST_STATUSES.REQUESTED)
.catch(REQUEST_STATUSES.REQUESTED),
});
@@ -53,7 +53,7 @@ export type ExtensionRequestListResult = {
totalCount: number;
totalPages: number;
q: string;
status?: number | "all";
status: number | "all";
};
export async function getExtensionRequests(
+15 -14
View File
@@ -207,20 +207,21 @@ export async function updateMaestro(
}
if (payload.availableActivateDateTime !== undefined) {
const newDate = new Date(payload.availableActivateDateTime);
if (!isNaN(newDate.getTime())) {
const prevMin = Math.floor(
maestro.AvailableActivateDateTime.getTime() / 60000
);
const newMin = Math.floor(newDate.getTime() / 60000);
if (prevMin !== newMin) {
updates.AvailableActivateDateTime = newDate;
const prevStr = formatDate(maestro.AvailableActivateDateTime);
const newStr = formatDate(newDate);
logs.push({
type: "update_maestro_available_date",
remark: `${prevStr} -> ${newStr}`,
});
}
if (isNaN(newDate.getTime())) {
throw new ApiError("유효하지 않은 날짜 형식입니다.", 400);
}
const prevMin = Math.floor(
maestro.AvailableActivateDateTime.getTime() / 60000
);
const newMin = Math.floor(newDate.getTime() / 60000);
if (prevMin !== newMin) {
updates.AvailableActivateDateTime = newDate;
const prevStr = formatDate(maestro.AvailableActivateDateTime);
const newStr = formatDate(newDate);
logs.push({
type: "update_maestro_available_date",
remark: `${prevStr} -> ${newStr}`,
});
}
}
if (
+2 -2
View File
@@ -25,7 +25,7 @@ const upgradeRequestSearchSchema = z.object({
q: z.string().trim().catch(""),
status: z
.union([z.literal("all"), z.coerce.number().int().min(0)])
.optional()
.default(REQUEST_STATUSES.REQUESTED)
.catch(REQUEST_STATUSES.REQUESTED),
});
@@ -57,7 +57,7 @@ export type UpgradeRequestListResult = {
totalCount: number;
totalPages: number;
q: string;
status?: number | "all";
status: number | "all";
};
export async function getUpgradeRequests(