Compare commits

...

2 Commits

8 changed files with 26 additions and 26 deletions
+2 -2
View File
@@ -20,7 +20,7 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
- Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade. - Upgrade approval sets `AvailableActivateDateTime = NOW() + 1 year`. **Do not change `PlayerCount`** on upgrade.
- When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`. - When approving an extension or upgrade, close all other `Status = 1` requests for the same `MaestroID` by setting them to `Status = 2`, then set the approved request to `Status = 3`.
- **Docker containers must set `TZ: Asia/Seoul`** so that date calculations (`setHours(0,0,0,0)`, `formatDate`) match the KST-based existing data in the shared MariaDB. - **Docker containers must set `TZ: Asia/Seoul`** so that date calculations (`setHours(0,0,0,0)`, `formatDate`) match the KST-based existing data in the shared MariaDB.
- **Docker service names must be unique per environment**: Docker registers each `services:` key as a DNS alias in shared networks. The stage service is `stage-chocoadmin`**never rename it to `chocoadmin`**. Identical names cause DNS round-robin in `proxy-network`; NPM's `set $server "chocoadmin"` will randomly route requests to either container, mixing production and stage traffic. - **Docker service names must be unique per environment**: Docker registers each `services:` key as a DNS alias in shared networks. The stage service is `chocoadmin-stage`**never rename it to `chocoadmin`**. Identical names cause DNS round-robin in `proxy-network`; NPM's `set $server "chocoadmin"` will randomly route requests to either container, mixing production and stage traffic.
- **Server actions must be module-level named exports**: Inline `action={async () => { "use server"; ... }}` closures get a new action ID on every build. After redeployment the browser sends the stale ID and Next.js responds `Failed to find Server Action`. Always extract to a top-level export in a separate `actions.ts` file (e.g. `app/(admin)/actions.ts`). - **Server actions must be module-level named exports**: Inline `action={async () => { "use server"; ... }}` closures get a new action ID on every build. After redeployment the browser sends the stale ID and Next.js responds `Failed to find Server Action`. Always extract to a top-level export in a separate `actions.ts` file (e.g. `app/(admin)/actions.ts`).
- **After redeployment, reload NPM nginx**: Run `docker exec npm nginx -s reload` on the NAS to flush the upstream DNS cache. Without this NPM may continue routing to the previous container's IP. - **After redeployment, reload NPM nginx**: Run `docker exec npm nginx -s reload` on the NAS to flush the upstream DNS cache. Without this NPM may continue routing to the previous container's IP.
- **Extension requests** (`POST /api/maestros/[id]/extension-requests`) require `ActivateStatus = 2` (ACTIVE). Trial (1) and cancelled (100) accounts are rejected with 400. Approval (`PATCH /api/extension-requests/[id]`) re-checks `ActivateStatus` and throws 409 if not ACTIVE (transaction rolls back the atomic claim). - **Extension requests** (`POST /api/maestros/[id]/extension-requests`) require `ActivateStatus = 2` (ACTIVE). Trial (1) and cancelled (100) accounts are rejected with 400. Approval (`PATCH /api/extension-requests/[id]`) re-checks `ActivateStatus` and throws 409 if not ACTIVE (transaction rolls back the atomic claim).
@@ -35,7 +35,7 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone
- API routes must call `auth()` and return 401 if no session. - API routes must call `auth()` and return 401 if no session.
- Session `maxAge` is 8 hours — do not lengthen without review. - 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. - `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.
- Session cookie name `${APP_ENV}-chocoadmin.session-token` must be set in **both** `auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`). Setting only `auth.ts` causes `proxy.ts` to look for the default cookie name, which is never written → middleware redirects every request to `/login` → login redirects back to `/` → infinite `ERR_TOO_MANY_REDIRECTS`. In NextAuth v5 the JWT encryption salt equals the cookie name, so both must match exactly. - Session cookie name `chocoadmin-${APP_ENV}.session-token` must be set in **both** `auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`). Setting only `auth.ts` causes `proxy.ts` to look for the default cookie name, which is never written → middleware redirects every request to `/login` → login redirects back to `/` → infinite `ERR_TOO_MANY_REDIRECTS`. In NextAuth v5 the JWT encryption salt equals the cookie name, so both must match exactly.
- Do **not** add `__Secure-` prefix to the cookie name. Next.js 16 middleware runs in Node.js runtime (not Edge) and receives HTTP from Nginx Proxy Manager — `__Secure-` cookies are silently rejected over HTTP, producing the same redirect loop. - Do **not** add `__Secure-` prefix to the cookie name. Next.js 16 middleware runs in Node.js runtime (not Edge) and receives HTTP from Nginx Proxy Manager — `__Secure-` cookies are silently rejected over HTTP, producing the same redirect loop.
## Key files ## Key files
+1 -1
View File
@@ -70,7 +70,7 @@ declare module "next-auth/jwt" {
const appEnv = process.env.APP_ENV ?? "local"; const appEnv = process.env.APP_ENV ?? "local";
const isSecure = process.env.NODE_ENV === "production"; const isSecure = process.env.NODE_ENV === "production";
const sessionCookieName = `${appEnv}-chocoadmin.session-token`; const sessionCookieName = `chocoadmin-${appEnv}.session-token`;
export const { export const {
handlers: { GET, POST }, handlers: { GET, POST },
+2 -2
View File
@@ -1,12 +1,12 @@
services: services:
# 서비스 이름은 Docker 네트워크 alias로 등록되므로 production(chocoadmin)과 겹치면 안 된다. # 서비스 이름은 Docker 네트워크 alias로 등록되므로 production(chocoadmin)과 겹치면 안 된다.
# 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다. # 겹치면 Docker DNS가 라운드로빈으로 두 컨테이너에 트래픽을 분산시킨다.
stage-chocoadmin: chocoadmin-stage:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: chocoadmin-stage:latest image: chocoadmin-stage:latest
container_name: stage-chocoadmin container_name: chocoadmin-stage
expose: expose:
- "3000" - "3000"
env_file: env_file:
+10 -10
View File
@@ -16,8 +16,8 @@ Stage example:
DATABASE_URL="mysql://USER:PASSWORD@mariadb.jisangs.com:30001/chocomae" DATABASE_URL="mysql://USER:PASSWORD@mariadb.jisangs.com:30001/chocomae"
AUTH_SECRET="replace-with-stage-secret" AUTH_SECRET="replace-with-stage-secret"
NEXTAUTH_SECRET="replace-with-stage-secret" NEXTAUTH_SECRET="replace-with-stage-secret"
AUTH_URL="https://stage-chocoadmin.jinaju.com" AUTH_URL="https://chocoadmin-stage.jisangs.com"
NEXTAUTH_URL="https://stage-chocoadmin.jinaju.com" NEXTAUTH_URL="https://chocoadmin-stage.jisangs.com"
``` ```
Production file path: Production file path:
@@ -38,7 +38,7 @@ NEXTAUTH_URL="https://chocoadmin.jinaju.com"
`AUTH_SECRET` and `NEXTAUTH_SECRET` must be the same strong random value within each environment for Auth.js compatibility. Use different values between production and stage. `AUTH_SECRET` and `NEXTAUTH_SECRET` must be the same strong random value within each environment for Auth.js compatibility. Use different values between production and stage.
`APP_ENV` (`production` / `stage`) is set by the `docker-compose*.yml` `environment` block — do **not** put it in the env file. It drives the session cookie name (`${APP_ENV}-chocoadmin.session-token`), which keeps production and stage cookies separate even if they share a browser. `APP_ENV` (`production` / `stage`) is set by the `docker-compose*.yml` `environment` block — do **not** put it in the env file. It drives the session cookie name (`chocoadmin-${APP_ENV}.session-token`), which keeps production and stage cookies separate even if they share a browser.
When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL. When the public URL uses HTTPS, both `AUTH_URL` and `NEXTAUTH_URL` must use the exact external HTTPS URL.
@@ -53,11 +53,11 @@ openssl rand -base64 32
Both production and stage share the same Git checkout at `/volume1/docker/service/jinaju/chocoadmin`. Pull from there: Both production and stage share the same Git checkout at `/volume1/docker/service/jinaju/chocoadmin`. Pull from there:
```bash ```bash
cd /volume1/docker/service/jinaju/stage-chocoadmin cd /volume1/docker/service/jinaju/chocoadmin-stage
git pull --ff-only git pull --ff-only
``` ```
Stage uses `docker-compose.stage.yml`, the `stage-chocoadmin` container, and the external Docker network `proxy-network`. Stage uses `docker-compose.stage.yml`, the `chocoadmin-stage` container, and the external Docker network `proxy-network`.
Verify or create the network: Verify or create the network:
@@ -86,7 +86,7 @@ docker exec npm nginx -s reload
Nginx Proxy Manager settings: Nginx Proxy Manager settings:
- Scheme: `http` - Scheme: `http`
- Forward Hostname / IP: `stage-chocoadmin` - Forward Hostname / IP: `chocoadmin-stage`
- Forward Port: `3000` - Forward Port: `3000`
- Websockets Support: enabled - Websockets Support: enabled
- SSL: enabled - SSL: enabled
@@ -95,7 +95,7 @@ Nginx Proxy Manager settings:
Stage URL: Stage URL:
```text ```text
https://stage-chocoadmin.jinaju.com https://chocoadmin-stage.jisangs.com
``` ```
After changing `AUTH_URL`, `NEXTAUTH_URL`, or cookie-related settings, clear browser cookies for the stage domain or test in a private window. After changing `AUTH_URL`, `NEXTAUTH_URL`, or cookie-related settings, clear browser cookies for the stage domain or test in a private window.
@@ -181,7 +181,7 @@ Middleware redirects to `/login`, login page redirects back to `/` — infinite
1. **Check `AUTH_URL` / `NEXTAUTH_URL`** — must exactly match the public HTTPS URL including scheme. 1. **Check `AUTH_URL` / `NEXTAUTH_URL`** — must exactly match the public HTTPS URL including scheme.
2. **Check NPM scheme** — NPM must forward with scheme `http` (not `https`) to the container. The app detects HTTPS from `AUTH_URL`, not from the incoming request. 2. **Check NPM scheme** — NPM must forward with scheme `http` (not `https`) to the container. The app detects HTTPS from `AUTH_URL`, not from the incoming request.
3. **Check cookie name consistency**`auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`) must both use the same `${APP_ENV}-chocoadmin.session-token` value. If `proxy.ts` uses the default name while `auth.ts` uses a custom one, middleware never finds the session. 3. **Check cookie name consistency**`auth.ts` (`cookies.sessionToken.name`) and `proxy.ts` (`getToken({ cookieName })`) must both use the same `chocoadmin-${APP_ENV}.session-token` value. If `proxy.ts` uses the default name while `auth.ts` uses a custom one, middleware never finds the session.
4. **Check for `__Secure-` prefix** — do not add it. Middleware runs in Node.js runtime and receives HTTP from Nginx. `__Secure-` cookies are silently rejected over HTTP. 4. **Check for `__Secure-` prefix** — do not add it. Middleware runs in Node.js runtime and receives HTTP from Nginx. `__Secure-` cookies are silently rejected over HTTP.
5. **Clear browser cookies** for the domain, then test in a private window. 5. **Clear browser cookies** for the domain, then test in a private window.
6. **Recreate the container** after changing `.env.*`. 6. **Recreate the container** after changing `.env.*`.
@@ -198,9 +198,9 @@ Check the network:
docker network inspect proxy-network --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}' docker network inspect proxy-network --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
``` ```
Expected: `chocoadmin` and `stage-chocoadmin` appear with different IPs. If `chocoadmin` appears twice, a stale container is using that alias. Expected: `chocoadmin` and `chocoadmin-stage` appear with different IPs. If `chocoadmin` appears twice, a stale container is using that alias.
Fix: ensure `docker-compose.stage.yml` has `services: stage-chocoadmin:` (not `chocoadmin`), redeploy stage once with `--remove-orphans` to clean up the stale container, then reload NPM. Fix: ensure `docker-compose.stage.yml` has `services: chocoadmin-stage:` (not `chocoadmin`), redeploy stage once with `--remove-orphans` to clean up the stale container, then reload NPM.
### Failed to find Server Action after redeployment ### Failed to find Server Action after redeployment
+4 -4
View File
@@ -70,7 +70,7 @@ pnpm db:check
스테이지 도메인에서 비로그인 상태 또는 시크릿 창으로 확인한다. 스테이지 도메인에서 비로그인 상태 또는 시크릿 창으로 확인한다.
```bash ```bash
curl -I https://stage-chocoadmin.jinaju.com/maestros curl -I https://chocoadmin-stage.jisangs.com/maestros
``` ```
기대 결과: 기대 결과:
@@ -81,7 +81,7 @@ curl -I https://stage-chocoadmin.jinaju.com/maestros
브라우저에서 확인한다. 브라우저에서 확인한다.
- `https://stage-chocoadmin.jinaju.com/login` - `https://chocoadmin-stage.jisangs.com/login`
- `/maestros` - `/maestros`
- `/extension-requests` - `/extension-requests`
- `/upgrade-requests` - `/upgrade-requests`
@@ -119,7 +119,7 @@ curl -I https://stage-chocoadmin.jinaju.com/maestros
NAS에서 실행한다. NAS에서 실행한다.
```bash ```bash
cd /volume1/docker/service/jinaju/stage-chocoadmin cd /volume1/docker/service/jisangs/chocoadmin-stage
docker compose -f docker-compose.stage.yml restart docker compose -f docker-compose.stage.yml restart
docker compose -f docker-compose.stage.yml ps docker compose -f docker-compose.stage.yml ps
docker compose -f docker-compose.stage.yml logs --tail=100 docker compose -f docker-compose.stage.yml logs --tail=100
@@ -127,7 +127,7 @@ docker compose -f docker-compose.stage.yml logs --tail=100
기대 결과: 기대 결과:
- `stage-chocoadmin` 컨테이너가 `Up` 상태다. - `chocoadmin-stage` 컨테이너가 `Up` 상태다.
- 로그에 Next.js ready 메시지가 출력된다. - 로그에 Next.js ready 메시지가 출력된다.
- 브라우저에서 로그인과 주요 목록 화면이 정상 동작한다. - 브라우저에서 로그인과 주요 목록 화면이 정상 동작한다.
+2 -2
View File
@@ -570,11 +570,11 @@ chocoadmin/
- [x] **포트 충돌 수정** (`docker-compose.yml`) - [x] **포트 충돌 수정** (`docker-compose.yml`)
- Gitea가 호스트 3000 포트를 선점 → `ports: "3000:3000"``expose: "3000"` 으로 변경 - Gitea가 호스트 3000 포트를 선점 → `ports: "3000:3000"``expose: "3000"` 으로 변경
- [x] **Docker DNS alias 충돌 수정** (`docker-compose.stage.yml`) - [x] **Docker DNS alias 충돌 수정** (`docker-compose.stage.yml`)
- `services: chocoadmin:``services: stage-chocoadmin:` 으로 변경 - `services: chocoadmin:``services: chocoadmin-stage:` 으로 변경
- 동일 서비스명은 `proxy-network`에서 같은 DNS alias로 등록됨 - 동일 서비스명은 `proxy-network`에서 같은 DNS alias로 등록됨
- NPM의 `set $server "chocoadmin"` 이 두 컨테이너에 라운드로빈 → 트래픽이 production/stage 사이에 무작위 분산 - NPM의 `set $server "chocoadmin"` 이 두 컨테이너에 라운드로빈 → 트래픽이 production/stage 사이에 무작위 분산
- [x] **NextAuth v5 커스텀 쿠키 이름으로 production/stage 쿠키 분리** - [x] **NextAuth v5 커스텀 쿠키 이름으로 production/stage 쿠키 분리**
- `auth.ts`: `cookies.sessionToken.name = "${APP_ENV}-chocoadmin.session-token"` 추가 - `auth.ts`: `cookies.sessionToken.name = "chocoadmin-${APP_ENV}.session-token"` 추가
- `proxy.ts`: `getToken({ cookieName })` 추가 — `auth.ts`에만 적용하면 middleware가 세션 토큰을 찾지 못해 `ERR_TOO_MANY_REDIRECTS` 발생 - `proxy.ts`: `getToken({ cookieName })` 추가 — `auth.ts`에만 적용하면 middleware가 세션 토큰을 찾지 못해 `ERR_TOO_MANY_REDIRECTS` 발생
- `__Secure-` prefix 금지: Node.js 런타임은 Nginx에서 HTTP로 수신하므로 Secure 쿠키가 무시됨 - `__Secure-` prefix 금지: Node.js 런타임은 Nginx에서 HTTP로 수신하므로 Secure 쿠키가 무시됨
- [x] **서버 액션 인라인 클로저 제거** — 로그아웃 액션을 `app/(admin)/actions.ts` 로 추출 - [x] **서버 액션 인라인 클로저 제거** — 로그아웃 액션을 `app/(admin)/actions.ts` 로 추출
+1 -1
View File
@@ -12,7 +12,7 @@ function usesSecureAuthCookies() {
export async function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
const { pathname, search } = request.nextUrl; const { pathname, search } = request.nextUrl;
const isPublicPath = PUBLIC_PATHS.includes(pathname); const isPublicPath = PUBLIC_PATHS.includes(pathname);
const cookieName = `${process.env.APP_ENV ?? "local"}-chocoadmin.session-token`; const cookieName = `chocoadmin-${process.env.APP_ENV ?? "local"}.session-token`;
const token = await getToken({ const token = await getToken({
req: request, req: request,
secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET, secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET,
+4 -4
View File
@@ -6,13 +6,13 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' NC='\033[0m'
DEPLOY_DIR="/volume1/docker/service/jinaju/stage-chocoadmin" DEPLOY_DIR="/volume1/docker/service/jisangs/chocoadmin-stage"
COMPOSE_FILE="docker-compose.stage.yml" COMPOSE_FILE="docker-compose.stage.yml"
SERVICE="stage-chocoadmin" SERVICE="chocoadmin-stage"
URL="https://stage-chocoadmin.jinaju.com" URL="https://chocoadmin-stage.jisangs.com"
echo -e "${BLUE}==============================${NC}" echo -e "${BLUE}==============================${NC}"
echo -e "${BLUE} stage-chocoadmin 배포${NC}" echo -e "${BLUE} chocoadmin-stage 배포${NC}"
echo -e "${BLUE}==============================${NC}" echo -e "${BLUE}==============================${NC}"
echo -e "\n${YELLOW}[1/5] 디렉토리 이동${NC}" echo -e "\n${YELLOW}[1/5] 디렉토리 이동${NC}"