generated from ttipo/project-base
Compare commits
52 Commits
18f10cece4
...
be473078cc
| Author | SHA1 | Date | |
|---|---|---|---|
| be473078cc | |||
| 52c6ebecb6 | |||
| ec1919e4bf | |||
| a39f8bd630 | |||
| 894550732e | |||
| 7ce234750d | |||
| b4d30482fe | |||
| 41b5fdbe22 | |||
| b6aa73c611 | |||
| 4d95fc28d8 | |||
| 3634cee955 | |||
| 19c20eb617 | |||
| ac418e3aa9 | |||
| 181d2a4cbf | |||
| 458f12980d | |||
| 50165bd3b7 | |||
| f725908053 | |||
| e27e9feb0b | |||
| 2d9c6b87d6 | |||
| af1e581be1 | |||
| 05fc3909dd | |||
| cbfccaa499 | |||
| 32576b0ad1 | |||
| 5b6a97e5e1 | |||
| 192807f943 | |||
| 090ee361a3 | |||
| 7585b3bfac | |||
| 419b2009ab | |||
| ca0e489ab1 | |||
| ff6f757fa4 | |||
| 5710151c0b | |||
| a79f0791d0 | |||
| 1868665592 | |||
| fe8a99e728 | |||
| 490a85290d | |||
| 0282b6b7bd | |||
| bbd88e707a | |||
| fbfad8d0ca | |||
| 6735bba43d | |||
| 2701d04fc2 | |||
| 13888af2c2 | |||
| d807cb4441 | |||
| 2a854073f7 | |||
| f77179a508 | |||
| 8fc73aa541 | |||
| 70103e4968 | |||
| d9ad6fc178 | |||
| a5afbc040e | |||
| 5ed9fb922a | |||
| 468a4377ee | |||
| 71f2e01d83 | |||
| 238d0ba838 |
@@ -9,13 +9,19 @@
|
||||
|
||||
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
|
||||
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
|
||||
운영은 `TRUST_PROXY`(앞단 프록시 홉 수 — 외부 리버스 프록시 뒤면 2, 없으면 1)도 필수다. 앱 네트워크 대역 `APP_NETWORK_SUBNET` 은
|
||||
호스트의 다른 도커·사내 네트워크와 겹치지 않는 값으로 둔다 (메일 엔진이 이 대역만 신뢰한다 — [[docker]])
|
||||
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
|
||||
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
|
||||
— **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다**
|
||||
- [ ] 프로젝트 식별 값 교체 (`PROJECT_NAME` 등 루트 env 키)
|
||||
- [ ] 의존성 설치: `cd backend && npm ci`, `cd frontend && npm ci`, `cd flutter && flutter pub get`
|
||||
- [ ] Docker 개발 환경 기동으로 동작 확인 — backend + db ([[docker]])
|
||||
(Redis 와 프론트엔드 컨테이너는 기본 비활성이다 — 필요하면 루트 `.env` 에 `COMPOSE_PROFILES=redis,frontend`)
|
||||
- [ ] Docker 개발 환경 기동으로 동작 확인 — backend + db + 메일 엔진(mail·mailpit) ([[docker]])
|
||||
(예시 env 의 기본값이 `COMPOSE_PROFILES=mail` 이다. Redis 와 프론트엔드 컨테이너는 기본 비활성 — 필요하면 `COMPOSE_PROFILES=mail,redis,frontend`)
|
||||
- [ ] 메일 엔진 초기 설정 — `node --env-file=.env.development mail/scripts/setup.mjs` (처음 한 번, env 를 바꾸면 다시 — 여러 번 실행해도 안전)
|
||||
운영은 **처음 setup(bootstrap) 전에** 엔진 관리자 아이디 `MAIL_ADMIN_LOCAL_PART` 를 정한다 — 옮겨 올 실제 메일함(예: `admin@`)과 겹치지 않게.
|
||||
bootstrap 뒤에는 바꿀 수 없다(setup 이 막는다). 저장소는 `sudo` 없이 클론한다 — `data/` 가 사용자 소유여야 setup 이 상태 파일을 쓴다
|
||||
직원 로그인 = 메일 계정이라 이 단계 전에는 로그인할 수 없다. 개발 테스트 계정·관리 화면·Mailpit 은 [`mail/README.md`](../../mail/README.md)
|
||||
- [ ] 프론트엔드 dev 서버는 **호스트에서** 기동 — `cd frontend && npm run dev` (→ <http://localhost:5173>)
|
||||
컨테이너 vite 는 바인드 마운트 때문에 첫 서빙까지 수 분이 걸려 profile 뒤로 숨겨 두었다 ([[docker]])
|
||||
|
||||
|
||||
@@ -13,11 +13,21 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
│ (docker compose --env-file 로 로드)
|
||||
├─▶ backend : env_file + environment 로 DB/Redis/CORS/포트 주입
|
||||
│ (backend/.env.{APP_ENV} 는 "존재"만 하면 됨, 내용 최소)
|
||||
├─▶ frontend : BACKEND_API_URL → build arg(VITE_API_BASE_URL) 로 주입
|
||||
├─▶ frontend : 빌드 arg VITE_API_BASE_URL=/api 고정 (같은 주소 배포 — env 로 바꾸지 않는다)
|
||||
│ 런타임 env MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB → nginx 본문 한도,
|
||||
│ NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT} (frontend/nginx-env.envsh — 값이 틀리면 기동 중단)
|
||||
│ (호스트에서 `npm run dev` 로 띄울 때는 compose 를 거치지 않으므로
|
||||
│ vite.config.ts 가 이 파일을 `loadEnv` 로 직접 읽어 FRONTEND_PORT/BACKEND_PORT 사용)
|
||||
├─▶ network : APP_NETWORK_SUBNET → app-network 서브넷 고정 (메일 엔진의 신뢰 대역 — 사설 대역 /24~/28, frontend 는 붙지 않는다)
|
||||
│ EDGE_NETWORK_SUBNET → edge(frontend ↔ backend) 서브넷 고정 (TRUST_PROXY 주소 목록용)
|
||||
├─▶ db : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
|
||||
└─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
|
||||
├─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
|
||||
├─▶ uploads : 운영은 named volume <PROJECT_NAME>_uploads (env 없음), 개발만 UPLOAD_DATA_DIR 바인드 마운트
|
||||
├─▶ mail : MAIL_PUBLIC_URL·MAIL_RECOVERY_ADMIN → STALWART_* (backend 에는 MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*·MAIL_ATTACHMENT_*·
|
||||
│ MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS)
|
||||
│ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포")
|
||||
│ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다
|
||||
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
|
||||
```
|
||||
|
||||
규칙:
|
||||
@@ -27,24 +37,41 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
- **compose 는 정의되지 않은 변수를 빈 문자열로 주입한다.** 코드에서 `??` 기본값만 쓰면 `Number('')===0` 같은 함정에 빠진다 —
|
||||
숫자 설정값은 `shared/utils/env.ts` 의 `parsePositiveInt()` 처럼 빈 문자열까지 방어하는 파서를 거친다.
|
||||
(기존 `.env` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
|
||||
- **숫자 값은 앞자리 0 없는 10진수로만 쓴다** — `08`·`+5`·`5.0`·`1e3`·`0x10` 은 env 검증이 기동을 막는다
|
||||
(기간 `015m`, `TRUST_PROXY` 홉 수·CIDR 접두도 같다. 규칙은 `shared/utils/env.ts` 의 `isPositiveIntString()`).
|
||||
같은 값을 읽는 nginx 템플릿(`frontend/nginx-env.envsh`)이 셸 산술 때문에 `08` 을 거부하므로 백엔드도 같은 규칙을 쓴다 (2026-10-27).
|
||||
nginx 본문 한도가 되는 `MAIL_ATTACHMENT_MAX_SIZE_MB`·`UPLOAD_MAX_SIZE_MB` 는 템플릿과 같이 **6자리(999999) 이하**만 받는다
|
||||
(백엔드만 뜨고 frontend 가 뜨지 않는 조합을 막는다 — `env.validation.ts` 의 `NGINX_SHARED_INT_KEYS`).
|
||||
- `docker-compose.yml` 의 `env_file` 지시자 때문에 `backend/.env.{APP_ENV}` 파일은 비어 있어도 **존재해야 한다.**
|
||||
- **프론트엔드 dev 서버는 compose 를 거치지 않는다** — 호스트에서 직접 띄우므로([[docker]])
|
||||
`vite.config.ts` 가 루트 `.env.{mode}` 를 `loadEnv` 로 읽어 포트·프록시 대상을 정한다.
|
||||
포트를 `frontend/.env` 나 `vite.config.ts` 에 중복 작성하지 말 것 — 루트가 SSOT 다.
|
||||
(읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — `VITE_` 없는 값의 번들 노출 금지, [[security]])
|
||||
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
|
||||
- **루트 env 를 Node 스크립트가 직접 읽을 때**(`node --env-file=…`) 두 가지를 주의한다.
|
||||
- Node 는 **셸에 이미 있는 변수를 덮어쓰지 않는다** — 셸에 `APP_ENV=development` 가 남은 채 `.env.production` 을 주면 개발 값으로 돈다.
|
||||
스크립트는 env 파일 값과 셸 값이 다르면 중단해야 한다 (`mail/scripts/setup.mjs` 의 `findShellOverrides()`).
|
||||
- Node 는 따옴표 없는 값의 `#` 부터를 **공백이 없어도** 주석으로 자르고, compose 는 그대로 읽는다.
|
||||
비밀번호 등은 영문·숫자로만 만든다 (`#`·따옴표·`$`·공백 금지).
|
||||
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
|
||||
- **설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast).** 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다.
|
||||
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
|
||||
- 숫자 항목은 형식 검사 (모든 환경)
|
||||
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` 필수
|
||||
- 운영 환경 필수: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME`, `JWT_SECRET`, `MAIL_JMAP_URL`/`MAIL_DOMAIN`/`MAIL_GATEWAY_*`,
|
||||
`CORS_ORIGIN`(화면의 공개 주소 — 상태 변경 요청의 출처 검사), `TRUST_PROXY`(백엔드 앞 프록시 홉 수 — 운영에서 `true`·`0`·`false` 는 거부)
|
||||
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
|
||||
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
|
||||
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
|
||||
로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓸 때만 `ALLOW_LOCALHOST_URL=true` 로 건너뛴다
|
||||
(`BACKEND_API_URL` 은 앱이 쓰지 않지만 이 검사를 위해 compose 가 backend 에 주입한다)
|
||||
(`BACKEND_API_URL` 은 앱도 프론트 빌드도 쓰지 않는다 — 같은 주소 배포라 "화면 주소 + /api" 이고, 이 검사를 위해서만 compose 가 backend 에 주입한다)
|
||||
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
|
||||
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
|
||||
|
||||
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `BACKEND_API_URL`, `CORS_ORIGIN`, `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`. 실제 목록은 `.env.development.example` 참조.
|
||||
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`,
|
||||
> `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`),
|
||||
> 메일 `MAIL_IMAGE`(패치까지 고정)·`MAIL_DOMAIN`·`MAIL_HOSTNAME`·`MAIL_PUBLIC_URL`·`MAIL_ADMIN_LOCAL_PART`(관리자 아이디, 기본 `admin` — setup·backend 공용, bootstrap 전에만)·
|
||||
> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·
|
||||
> `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)·
|
||||
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)
|
||||
> (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조.
|
||||
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
|
||||
|
||||
@@ -34,6 +34,8 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
|
||||
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
|
||||
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
|
||||
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
|
||||
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." |
|
||||
| AUTH_005 | 400 | 로그인한 채 본인 확인 실패 — 비밀번호 변경의 지금 비밀번호가 틀림. **401 이 아니다** — 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." |
|
||||
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
|
||||
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
|
||||
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
|
||||
@@ -43,7 +45,8 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
|
||||
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
|
||||
|
||||
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
|
||||
- 웹: `useApi.ts` 의 `toAuthCode()` (`authStore.clearAuth()` 호출)
|
||||
웹은 그 전에 `/auth/refresh` 를 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절).
|
||||
- 웹: `useApi.ts` 의 `toAuthCode()` — `authStore.expireSession()` 을 불러 세션 만료 대화상자(`SessionExpiredDialog.vue`)를 띄운다
|
||||
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
|
||||
|
||||
## 도메인 코드 추가
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
|
||||
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
|
||||
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
|
||||
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
|
||||
앞단 nginx 도 접근 로그에 쿼리를 남기지 않는다 — `/api` 와 화면 요청 모두(메일 검색은 화면 주소 `/mail/search?q=…` 에도 검색어가 들어간다, [[docker]]).
|
||||
**예외**: nginx 오류 로그(413 · 502 · 504 등)는 장애 원인 줄을 잃지 않게 요청 줄 · referrer 를 쿼리째 남긴다 — 오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다
|
||||
|
||||
```typescript
|
||||
// ❌ 금지
|
||||
|
||||
@@ -13,14 +13,35 @@
|
||||
|
||||
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
|
||||
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
|
||||
- 리프레시 토큰은 서버 세션에 **해시만** 저장하고 **1회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다.
|
||||
동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게)
|
||||
- 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다.
|
||||
확인 뒤에 세면 동시에 들어온 요청 수백 건이 모두 한도 검사를 통과한다 (구현 [[nestjs]])
|
||||
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
|
||||
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001)
|
||||
- **메일 게이트웨이(대리 접근) 계정은 관리자 계정까지 대리할 수 있다** (Stalwart 0.16 확인) — 게이트웨이 비밀번호는 관리자급 비밀로 다룬다.
|
||||
백엔드는 엔진 관리자 계정을 로그인·대리 접근 대상에서 막는다: 정적 목록(엔진에 묻기 전) + 로그인·리프레시 때 엔진 권한 확인 ([[nestjs]])
|
||||
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004)
|
||||
- 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다.
|
||||
로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다.
|
||||
|
||||
## 입력 · 출력
|
||||
|
||||
- 입력값은 DTO(`class-validator`) 검증을 거친 뒤 서비스 계층에 전달한다. 전역 `ValidationPipe` 의 `whitelist`/`forbidNonWhitelisted` 를 끄지 않는다
|
||||
- JSON 본문은 DTO 에 닿기 전에 **모양부터 잰다** — 배열 하나 1,000개 · 값 10,000개 · 깊이 32단, UTF-8 만 (`app.setup.ts` 의 `JSON_SHAPE_LIMITS`, `shared/utils/json-shape.ts`).
|
||||
DTO 의 개수 한도로는 막지 못한다 — 변환이 항목마다 객체를 만들고, 검사는 개수 한도에 걸려도 항목마다 중첩 검사를 해서 1MB 본문 하나로 서버가 수 초 멈췄다 (2026-10-28 리뷰).
|
||||
class-validator 의 `ArrayUnique` 는 항목 수의 제곱으로 느려 쓰지 않는다 (`common/decorators/array-distinct.decorator.ts` 의 `ArrayDistinct`)
|
||||
- TypeORM ORM 쿼리 사용으로 SQL Injection 차단 (Raw 쿼리 시 파라미터 바인딩 필수)
|
||||
- 외부로 나가는 데이터(보내는 메일의 헤더 등)에 비교용 지문을 실어야 하면 평범한 해시가 아니라 **받는 쪽이 모르는 비밀로 HMAC** 한다
|
||||
(예: 메일마다 다른 무작위 `Idempotency-Key`). 평범한 해시는 받은 사람이 후보 값(숨은참조 주소 등)을 대입해 맞춰 볼 수 있다.
|
||||
- **서버 서명 키(`JWT_SECRET` 등)나 거기서 파생한 키를 쓰지 않는다** — 평문을 아는 받은 사람이 (평문, HMAC) 쌍으로
|
||||
서명 키 후보를 오프라인에서 대입해 볼 수 있고, 맞히면 토큰을 위조할 수 있다 (2026-10-26 리뷰)
|
||||
- 에러 응답에 스택·쿼리·내부 호스트명을 싣지 않는다 — 미정의 예외는 항상 `SYS_001` 로 수렴시킨다 ([[error-codes]])
|
||||
- 사용자가 쓴 HTML(메일 서식 본문)은 **서버가 허용 목록으로 다시 정화**한 뒤 내보낸다 — 클라이언트 편집기의 출력을 믿지 않는다.
|
||||
style 은 속성마다 값 형식을 처음부터 끝까지(`^…$`) 맞춘다 (`modules/mail/html/sanitize-outgoing-html.ts`, 계약 2절 "서식 본문")
|
||||
- 받은 메일·서식 본문처럼 **밖에서 온 HTML 은 정화하기 전에 비용부터 잰다** — 정화기(htmlparser2)는 중첩 깊이의 제곱으로 느려져
|
||||
메일 한 통으로 서버 전체를 수 분 멈출 수 있다 (2026-10-27 리뷰). 넘으면 정화하지 않는다 (`modules/mail/html/html-parse-budget.ts`, [[nestjs]])
|
||||
- 브라우저에서 외부 HTML 을 고쳐 쓸 때는 떨어진 문서(`<template>`·`DOMParser`) 안에서 끝낸다 — 화면 문서로 옮기면
|
||||
`img onerror` 같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 ([[vue3]] 체크리스트 "외부 HTML")
|
||||
### 파일 업로드
|
||||
|
||||
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
@@ -32,6 +53,9 @@
|
||||
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
|
||||
`../../etc/passwd` 같은 경로 탈출이 가능하다. 원본 이름은 표시용 메타데이터로만 보관한다.
|
||||
- 크기 한도를 설정한다 (`UPLOAD_MAX_SIZE_MB`, 기본 5MB). 초과 시 `FILE_002`(413).
|
||||
- 큰 업로드(메일 첨부 등)는 **메모리가 아니라 임시 디스크**로 받아 스트리밍으로 넘기고, 성공·실패와 관계없이 지운다.
|
||||
메모리에 받으면 동시 업로드 수 × 한도만큼 상주해 컨테이너가 OOM 으로 죽고 모든 요청이 끊긴다.
|
||||
임시 디스크를 tmpfs 로 두면 메모리 한도에 잡혀 같은 문제가 된다.
|
||||
- 업로드 디렉터리를 **정적 파일로 그대로 서빙하지 않는다.** 내려줄 때는 컨트롤러를 거쳐
|
||||
`Content-Type` 을 서버가 지정하고 `Content-Disposition: attachment` 를 붙인다.
|
||||
|
||||
@@ -45,6 +69,14 @@
|
||||
- 보안 헤더(`helmet`) 적용
|
||||
- CORS 는 화이트리스트 방식. 차단 시 `callback(null, false)` 로 **헤더를 부여하지 않아** 브라우저가 막게 한다
|
||||
(`callback(new Error(...))` 를 쓰면 500(SYS_001)이 되어 원인 파악이 어려워진다)
|
||||
- 쿠키로 인증하므로 상태를 바꾸는 요청은 **출처를 검사**한다 (`OriginGuard`, 전역 — 계약 `docs/mail/api-contract.md` 머리말).
|
||||
비교 기준은 `Host` 가 아니라 허용 목록(`CORS_ORIGIN`)이다. 프록시 뒤에서는 `Host` 가 바뀌어 정상 요청까지 막힌다.
|
||||
- 클라이언트 IP 는 `TRUST_PROXY`(홉 수 또는 주소 목록)로만 해석한다. 모든 홉을 믿으면(`true`) `X-Forwarded-For` 위조로
|
||||
IP 기준 한도(로그인 실패·rate limit)가 무력화된다. 입구는 프론트 nginx 하나로 둔다 —
|
||||
프록시를 건너뛰어 백엔드에 닿는 경로가 있으면 홉 수가 어긋나 위조가 가능해진다.
|
||||
- **인터넷을 향한 컨테이너(프론트 nginx)는 백엔드에만 닿게** 한다 — DB·메일 엔진과 같은 네트워크에 두지 않는다
|
||||
(`edge`: frontend↔backend / `app-network`: backend↔db↔mail, [[docker]]). 메일 엔진의 신뢰 대역은 `app-network` 뿐이다.
|
||||
- 컨테이너가 쓰는 데이터(업로드 등)는 **named volume** 에 둔다 — 호스트 바인드 마운트는 root 소유로 생겨 비-root 컨테이너가 쓰지 못한다.
|
||||
- Rate Limiting: **`@nestjs/throttler` 하나로 일원화**되어 있다 (`app.module.ts` 의 `APP_GUARD`).
|
||||
초과 시 429 → 전역 필터가 **`REQ_001`** 로 매핑한다.
|
||||
- 기본 정책은 루트 `.env` 의 `THROTTLE_TTL_MS`/`THROTTLE_LIMIT` (미설정 시 60초/100회)
|
||||
@@ -85,7 +117,32 @@
|
||||
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
|
||||
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
|
||||
|
||||
> **현재 유예 중인 항목**: 없음 (`npm audit` 0건)
|
||||
> **현재 유예 중인 항목** (2026-10-27 점검):
|
||||
> - frontend `eslint-plugin-vue@9` → `postcss-selector-parser` (moderate, 선택자 파싱 CPU 과다 사용)
|
||||
> - 도달 경로: 개발 시 린트에서만 돈다. 입력은 우리 소스 코드뿐이라 외부 공격 경로가 없다. 운영 번들·이미지에 포함되지 않는다.
|
||||
> - 미루는 이유: 수정판이 `eslint-plugin-vue@10`(메이저)이라 린트 규칙이 바뀌고 설정 이관이 필요하다.
|
||||
> - 다시 볼 시점: ESLint 스택을 올릴 때, 늦어도 2027-01.
|
||||
> - backend (2026-10-27 기준 7건 — high 3·moderate 4, 운영 의존성만 보면 6건 — 아래 5종):
|
||||
>
|
||||
> | 항목 | 도달 경로 | 미루는 이유 | 다시 볼 시점 |
|
||||
> | --- | --- | --- | --- |
|
||||
> | `body-parser` 2.3.0 (high, 압축 본문 DoS) | 없음 — Nest 기본 파서를 끄고 JSON 파서만 `inflate:false` 로 둔다. 압축 본문은 해제 전에 415 (실서버 확인) | 수정판 2.4.0 이 2026-10-22 배포 (2주 미만) | 2026-11-05 에 lock 갱신 (express 5.3.0 이 `^2.3.0` 이라 범위 안) |
|
||||
> | `multer` 2.4.0 (high, 비동기 fileFilter DoS) | 없음 — 우리 fileFilter(upload·mail)는 모두 동기 | 2.5.0 이 2026-10-22 배포 (2주 미만). `@nestjs/platform-express` 최신 12.2.0 도 2.4.0 고정이라 override 를 올릴 근거가 아직 없음 | 2026-11-05 이후 override `^2.5.0` 여부 결정 |
|
||||
> | `js-yaml` 5.3.0 (moderate, `@nestjs/swagger` 하위) | 없음 — swagger 는 `dump` 만 쓰고 우리 코드는 YAML 을 파싱하지 않는다 | swagger 11.x 가 5.3.0 으로 정확히 고정 (12.x 는 5.5.0 이지만 메이저) | swagger 11.x 가 ≥5.4.1 을 선언한 릴리스 + 2주. 또는 override `^5.4.3` 검토 (swagger 12 가 5.x 를 쓰는 것이 근거) |
|
||||
> | `fast-uri` 3.1.8 (moderate, 개발 전용) | 없음 — Nest CLI 의 ajv 에서만 쓰이고 운영 이미지에 없다 | 3.1.9 가 2026-10-19 배포 (2주 미만) | 2026-11-02 |
|
||||
> | `sanitize-html` 2.18.0 (moderate, 버린 iframe 의 대체 내용 재귀 — GHSA-c9rx) | 없음 — 우리 설정은 iframe 을 내용째 버려 대체 내용을 다시 정화하지 않는다 (5만 단계 중첩으로 확인, 2만 단계 회귀 테스트로 고정) | 수정판 2.18.1 이 2026-10-14 배포 (2주 미만) | 2026-10-28 이후 2.18.1 |
|
||||
>
|
||||
> 2026-10-27 Node 24 전환과 함께 sanitize-html 고정(2.17.5)을 풀어 이전 권고 4건을 해결했다.
|
||||
> 2.18 이 쓰는 htmlparser2 계열은 ESM 전용이라 Jest 설정에서 CJS 로 변환한다 ([[nestjs]] "의존성 추가/제거").
|
||||
>
|
||||
> 참고: `@nestjs/platform-express` 11.2.7(2026-09-30) 이상은 multer 2.4.0 을 직접 선언한다 — 올리면 아래 multer override 를 지울 수 있다.
|
||||
>
|
||||
> 같은 점검에서 정리한 것: frontend 22건 → 2건 (axios 1.20.0 · vue 3.5.43 · vite 8.3.4 · oxfmt 0.72.0 으로 갱신, lock 은 Linux 컨테이너에서 생성),
|
||||
> backend 12건 → 7건 (critical 0 — proxy-addr 2.0.8 등).
|
||||
> 새 버전은 배포 후 2주 이상 지난 패치 버전을 골랐다 (공급망 위험 — 갓 나온 버전은 피한다).
|
||||
> **예외: 우리에게 닿는 취약점을 고치는 보안 릴리스**는 2주를 기다리지 않고 CI 가 통과하는 대로 올린다 — 알려진 취약점을 2주 더 두는 위험이 더 크다
|
||||
> (예: Node 24.22.1 — 2026-10-20 보안 릴리스를 6일 만에 고정, 고정 위치는 [[docker]]).
|
||||
> 도달 경로가 없다고 확인한 취약점이면 평소처럼 2주를 기다린다 (예: 위 표의 sanitize-html 2.18.1).
|
||||
|
||||
### 전이 의존성 취약점은 overrides 로 끊는다
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
|
||||
- **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다.
|
||||
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
|
||||
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
|
||||
- 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 프로세스 CPU 시간이나 비용 수치.
|
||||
CPU 시간 한도는 **지금 값의 10배쯤** 둔다 — CPU 시간도 부하에서 는다(2026-10-28: CI 와 같은 Linux 컨테이너에서 메모리가 모자라고 테스트가 함께 돌 때
|
||||
평소의 4~7배 — 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다). 막으려는 옛 경로(제곱 등)는 그 한도를 확실히 넘어야 한다 — 둘 사이가 좁으면 입력을 키운다.
|
||||
벽시계는 다른 작업이 CPU 를 나눠 쓰면 몇 배로 늘어 CI 에서 흔들린다 (2026-10-27: 혼자 0.8초인 정화가 부하에서 9초).
|
||||
비동기 결과를 기다릴 때도 고정 대기(`setTimeout(20)`) 대신 조건이 될 때까지 짧게 다시 보고 넉넉한 기한을 둔다.
|
||||
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
|
||||
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
|
||||
- 테스트 설명(it/test 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
|
||||
|
||||
@@ -18,6 +18,7 @@ description: Docker Compose 기반 로컬·운영 오케스트레이션 실행/
|
||||
|
||||
```bash
|
||||
# 코드만 수정했을 때 (의존성 변경 없음) — backend + db 가 뜬다
|
||||
# (예시 env 의 기본값 COMPOSE_PROFILES=mail 이면 메일 엔진 mail + 개발용 mailpit 도 함께 — 아래 "메일" 절)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
|
||||
# 의존성 변경 시에만 --build
|
||||
@@ -29,8 +30,8 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
|
||||
|
||||
## 운영 환경
|
||||
|
||||
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다.
|
||||
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]).
|
||||
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다 (예시: `https://portal.arasystem.it.kr`).
|
||||
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]). `CORS_ORIGIN`·`TRUST_PROXY` 는 운영 필수다.
|
||||
> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다.
|
||||
|
||||
```bash
|
||||
@@ -41,6 +42,109 @@ sudo docker compose --env-file .env.production up -d --build
|
||||
sudo docker compose --env-file .env.production logs -f backend
|
||||
```
|
||||
|
||||
### 같은 주소(origin) 배포 — 입구는 프론트 nginx 하나
|
||||
|
||||
```
|
||||
클라이언트 ─TLS─▶ 외부 리버스 프록시 ─(proxy)─▶ <PROJECT_NAME>-frontend :8080 ─┬─ / 화면(SPA)
|
||||
└─ /api/ ─(edge)─▶ <PROJECT_NAME>-backend :3000
|
||||
│ (app-network = 메일 엔진의 신뢰 대역)
|
||||
├─▶ db · redis
|
||||
└─▶ mail :8080 (JMAP·관리 API)
|
||||
```
|
||||
|
||||
| 네트워크 | 붙는 컨테이너 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `proxy` (external) | 외부 리버스 프록시 · frontend | 다른 프로젝트와 함께 쓸 수 있다. 운영 전용 (dev 오버라이드가 지운다) |
|
||||
| `<PROJECT_NAME>_edge` | frontend · backend | `EDGE_NETWORK_SUBNET`(기본 `172.16.72.0/24`) 고정 — TRUST_PROXY 주소 목록용 |
|
||||
| `<PROJECT_NAME>_app-network` | backend · db · redis · mail (dev 는 mailpit 도) | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) 고정 — **메일 엔진의 신뢰 대역**. frontend 는 붙이지 않는다 |
|
||||
|
||||
- 브라우저는 화면과 같은 주소의 `/api` 를 부른다 (docs/mail/api-contract.md 머리말). 프론트 빌드는 `VITE_API_BASE_URL=/api` 로 고정이다.
|
||||
**backend 는 proxy 네트워크에 붙지 않는다** — 바깥에서는 nginx 의 `/api` 로만 들어온다.
|
||||
- **인터넷을 향한 frontend 는 app-network 에 붙지 않는다** (edge 로만 backend 에 닿는다). 붙으면 nginx 가 `mail:8080`(평문 관리 API)에
|
||||
닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다. mail setup 이 앱 네트워크에 붙은 컨테이너를 확인한다 (운영은 그 밖의 컨테이너가 있으면 중단).
|
||||
- **외부 리버스 프록시는 `<PROJECT_NAME>-frontend:8080` 으로 붙인다** (proxy 네트워크). 서비스 이름(`frontend`)은 다른 프로젝트와 겹칠 수 있다.
|
||||
같은 이유로 nginx 의 백엔드 주소도 `http://<PROJECT_NAME>-backend:<BACKEND_PORT>` 다 (compose 의 `NGINX_BACKEND_UPSTREAM`) —
|
||||
proxy 네트워크에 `backend` 라는 다른 컨테이너가 있으면 그쪽으로 풀릴 수 있기 때문이다.
|
||||
- `frontend/nginx.conf` 는 **템플릿**이다. 컨테이너가 뜰 때 이미지 entrypoint 가 `frontend/nginx-env.envsh` 로 값을 만들고
|
||||
`${NGINX_*}` 만 envsubst 로 채운다 (nginx 의 `$uri` 등은 그대로). 값은 루트 env 에서 온다:
|
||||
- 본문 한도: `/api/mail-attachments` = `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB(기본 21m), `/api/files` = `UPLOAD_MAX_SIZE_MB` + 1MB(기본 6m),
|
||||
**메일 발송 `POST /api/messages` 와 임시 저장 `/api/drafts` = 4m 고정**(서식 본문·긴 인용 — 계약 2절, 백엔드도 이 요청들만 4MB 파서.
|
||||
발송은 정확히 이 경로만이라 `/api/messages/{id}` 는 1m. 임시 저장은 `/api/drafts` 아래 전부 — 본문이 있는 것은 `POST`·`PUT` 뿐이다),
|
||||
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
|
||||
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
|
||||
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
|
||||
응답 전체가 아니라 백엔드에서 "두 번 읽는 사이"의 시간이다 — 실시간 알림은 1시간 이어져도 25초마다 오는 ping 이 새로 세게 한다(머리글이 오지 않으면 90초에 504).
|
||||
- **실시간 알림(SSE) `/api/mail-events`**(계약 2절 "실시간 알림"): 이 경로만 버퍼링 · 캐시 · 압축을 끄고 `X-Accel-Buffering` 을 앞으로 넘긴다
|
||||
(외부 프록시가 nginx 계열이면 그쪽도 이 응답만 버퍼링을 끈다). 버퍼링을 켜 두면 머리글을 첫 이벤트까지, 백엔드가 청크 없이 닫아 끝내는 응답이면 끝까지 붙잡는다(2026-10-28 확인).
|
||||
머리글 · 오류 JSON · 접근 로그는 `/api` 와 같다. ⚠️ 서버의 ping 간격을 바꾸면 90s 도 함께 본다.
|
||||
열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다(이미지 기본값 워커마다 1024).
|
||||
열린 연결이 있으면 frontend 컨테이너를 멈출 때 nginx 가 기다리다 유예 시간을 다 쓰고 강제 종료된다 — 종료 코드 137 은 이 때문이고 오류가 아니다(화면은 다시 잇는다).
|
||||
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
|
||||
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
|
||||
해시가 없는 공개 파일(`frontend/public` → 루트, 예: `favicon.ico`)은 같은 이름으로 바뀌므로 `no-cache`(매번 재검증, 안 바뀌면 304).
|
||||
보안 헤더는 어느 경우에도 붙는다(`add_header_inherit merge`).
|
||||
- 헤더: `X-Forwarded-For` 는 이어 붙이고, `X-Request-Id` 는 nginx 의 `$request_id` 로 덮어써 백엔드 traceId 와 nginx 로그 `rid=` 가 같다.
|
||||
- 로그: 접근 로그는 화면·`/api` 모두 쿼리 없이 남긴다 — 검색어 `q` 가 `/api` 주소뿐 아니라 화면 주소(`/mail/search?q=…`)에도 들어간다 (계약 2절 "검색").
|
||||
`log_format main_rid` 하나를 server 단계에만 걸어, 오류용 이름 있는 location 과 location 을 고르기 전에 거부된 요청(헤더가 너무 큰 400)도 같은 형식이다.
|
||||
요청 줄은 쿼리만 뺀 원래 요청 줄(SPA 폴백 뒤에도 `/index.html` 이 아니라 부른 화면 경로)이고, Referer 도 `?` 앞까지만 남긴다.
|
||||
화면 응답은 `Referrer-Policy: strict-origin` 이라 브라우저가 같은 주소의 요청에도 origin 만 싣는다 — 화면 주소의 검색어·`?draft=` id 가
|
||||
Referer 로 오류 로그·외부 프록시에 가지 않게. 접근 로그의 Referer 자르기는 그대로 둔다(배포 전에 연 탭·브라우저가 아닌 클라이언트는 주소째 보낼 수 있다).
|
||||
Referer 를 읽는 코드는 없다 — 출처 검사(`OriginGuard`)는 `Sec-Fetch-Site`·`Origin` 을 본다.
|
||||
새 메일 확인 `GET /api/mail-state`(탭마다 1분에 한 번)는 성공(2xx·304)하면 남기지 않는다 — 로깅 규칙 "반복되는 정상 흐름".
|
||||
실패(401·502 등)는 남긴다 (server 단계 `access_log … if=$log_access` 하나 — `/api/` 블록을 나누지 않는다).
|
||||
실시간 알림 `/api/mail-events` 는 성공도 남긴다 — 연결이 끝날 때 한 줄(브라우저마다 대략 1시간에 한 번)이고 `rt=` 가 이어진 시간이다.
|
||||
시작된 스트림은 어떻게 끝나도 200 이라, 끊김은 짧은 `rt=` 와 오류 로그의 `upstream prematurely closed connection` 으로 본다.
|
||||
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다.
|
||||
`Origin`·`Sec-Fetch-Site`·`Idempotency-Key`·`Cookie` 는 그대로 넘긴다. `/api` 응답에는 화면용 보안 헤더를 붙이지 않는다(백엔드 helmet 이 정한다).
|
||||
- 백엔드 주소는 요청마다 도커 DNS 로 다시 풀어(`resolver`), 백엔드를 다시 만들어도 nginx 를 재시작할 필요가 없다.
|
||||
- **nginx 가 직접 끝내는 `/api` 오류도 표준 실패 형식(JSON)이다** — 본문 한도 초과 413 `FILE_002`, 백엔드 응답 없음 502·시간 초과 504
|
||||
(그리고 500·503) `SYS_001`. `message` 는 비우고(클라이언트가 코드로 사전 문구를 쓴다) `traceId` 는 nginx 요청 ID 다 (응답 헤더 `X-Request-Id`·로그 `rid=` 와 같다).
|
||||
상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 손대지 않는다 (`proxy_intercept_errors` 꺼짐).
|
||||
- **`/api/health` 는 바깥에서 404 다** (`RES_001` JSON — 없는 경로와 같다). 실패하면 DB 오류 문자열 등 원인이 담기기 때문이다
|
||||
(대소문자·끝 `/` 도 막는다). 내부에서는 백엔드에 직접 묻는다:
|
||||
```bash
|
||||
# 상태 코드와 원본 본문 (503 이어도 원인이 보인다) — 백엔드 이미지에 node 가 있다
|
||||
sudo docker exec "${PROJECT_NAME}-backend" node -e "fetch('http://127.0.0.1:' + process.env.PORT + '/api/health').then(async (r) => console.log(r.status, await r.text()))"
|
||||
# 모니터링 도구를 컨테이너로 둔다면 edge 네트워크에 붙여 http://<PROJECT_NAME>-backend:<BACKEND_PORT>/api/health 를 본다
|
||||
```
|
||||
- 설정 문법 검사 (이미지로, 화면 빌드 없이): `docker build --target nginx-config -t <tag> frontend && docker run --rm -e PROJECT_NAME=x <tag> nginx -t`
|
||||
(CI 의 infra 잡이 같은 검사를 한다). 값을 바꿔 렌더링 결과를 보려면
|
||||
`docker run --rm -e PROJECT_NAME=x -e MAIL_ATTACHMENT_MAX_SIZE_MB=30 <tag> nginx -T | grep client_max_body_size`.
|
||||
`PROJECT_NAME`(또는 `NGINX_BACKEND_UPSTREAM`) 없이 띄우면 일부러 기동을 멈춘다.
|
||||
- **템플릿 값이 잘못되면 frontend 가 뜨지 않는다** (`frontend/nginx-env.envsh`, fail-fast) — `docker logs <PROJECT_NAME>-frontend` 의 `nginx-env.envsh:` 줄을 본다.
|
||||
- `MAIL_ATTACHMENT_MAX_SIZE_MB`·`UPLOAD_MAX_SIZE_MB`: 빈 값은 기본값(20·5), 그 밖은 0 으로 시작하지 않는 6자리 이하 양의 정수만 (`08`·`010` 거부 — 셸 산술이 8진수로 읽는다)
|
||||
- `NGINX_BACKEND_UPSTREAM`: `http://호스트[:포트]` 만. 경로(끝의 `/` 포함)를 붙이면 모든 `/api` 요청이 그 경로 하나로 가므로 거부한다
|
||||
- **외부 리버스 프록시 요구사항** (클라이언트 → 외부 프록시 → nginx → 백엔드 — 이 compose 의 운영 경로는 이것뿐이다.
|
||||
frontend 는 호스트 포트를 열지 않고 TLS 도 하지 않는다):
|
||||
- **`X-Forwarded-For` 를 이어 붙이거나 `$remote_addr` 로 새로 넣는다.** 클라이언트가 보낸 값을 그대로 넘기면 백엔드가 보는 IP 가 위조된다
|
||||
(로그인 실패 한도가 IP 기준이다).
|
||||
- **`TRUST_PROXY`**: proxy 네트워크를 이 프로젝트의 외부 프록시만 쓰면 홉 수 `2`.
|
||||
**다른 프로젝트와 함께 쓰면 주소 목록을 권장한다** — `<EDGE_NETWORK_SUBNET>,<외부 프록시 IP>` (예: `172.16.72.0/24,172.20.0.10`).
|
||||
홉 수는 "오른쪽에서 몇 번째 주소"만 믿으므로, 같은 네트워크의 다른 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면
|
||||
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
|
||||
- **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고
|
||||
사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다).
|
||||
- **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"):
|
||||
- 응답 버퍼링을 끈다 — nginx 계열(nginx · Nginx Proxy Manager · OpenResty)은 우리가 넘기는 `X-Accel-Buffering: no` 를 기본으로 따른다
|
||||
(`proxy_ignore_headers` 로 이 머리글을 무시하게 했다면 `proxy_buffering off` 를 직접 건다). 다른 제품은 `text/event-stream` 을 바로 흘려보내는지 확인한다.
|
||||
- `text/event-stream` 은 압축하지 않는다 — nginx 의 gzip 은 백엔드의 `Cache-Control: no-transform` 을 보지 않는다.
|
||||
- 유휴(읽기) 제한은 ping 25초보다 넉넉하면 된다(위 "150s 이상"이면 충분). 응답 **전체** 시간에 상한을 두는 설정(요청 최대 시간 · 일부 로드밸런서의 고정 제한)이
|
||||
있으면 1시간 + 여유(3900s) 이상 — 짧으면 그 주기로 끊겨 다시 잇기를 되풀이한다(동작은 하지만 그때마다 잠시 1분 확인으로 돌아간다).
|
||||
- 클라이언트가 끊으면 우리 쪽 연결도 바로 닫는다(nginx 기본 `proxy_ignore_client_abort off`). 남은 연결이 쌓이면 직원당 5개 한도로 429 `REQ_001` 이 난다.
|
||||
- 클라이언트 쪽(TLS 를 끝내는 곳)은 HTTP/2 를 권한다 — HTTP/1.1 이면 브라우저의 주소당 연결 6개 가운데 하나를 1시간 차지한다. 외부 프록시 → frontend:8080 은 HTTP/1.1 그대로.
|
||||
- 최대 연결 수를 "직원 수 × 브라우저 수 × 2 + 평소 요청"보다 넉넉히 둔다(열린 연결마다 외부 프록시도 연결 둘을 잡는다).
|
||||
- **IPv6 를 받는지 확인한다** (운영 확인 항목). 백엔드는 IPv6 클라이언트를 /64 단위로 묶어 센다 — 외부 프록시가 IPv6 를 받는다면
|
||||
`X-Forwarded-For` 에 원래 IPv6 주소가 그대로 들어가는지 본다.
|
||||
- **접근 로그에 쿼리를 남기지 않는다** (운영 확인 항목). 화면 주소(`/mail/search?q=…` · `?draft=<id>`)와 API 주소(`/api/messages?q=…` ·
|
||||
`/api/recipient-suggestions?q=…`)에 검색어 · 임시 저장 id 가 들어간다 — 프론트 nginx 와 같은 기준([[logging]]).
|
||||
화면의 `Referrer-Policy: strict-origin` 덕분에 Referer 로는 새지 않지만, 새로고침 · 직접 들어온 요청 줄은 외부 프록시가 그대로 받는다.
|
||||
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
|
||||
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
|
||||
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
|
||||
백업은 메일 볼륨과 같은 방식: `sudo docker run --rm -v "${PROJECT_NAME}_uploads:/uploads:ro" -v "$PWD/backup:/backup" alpine tar czf "/backup/uploads-$(date +%Y%m%d).tar.gz" -C / uploads`.
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드는 호스트에서 (개발 환경)
|
||||
@@ -49,6 +153,9 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
cd frontend && npm install && npm run dev # → http://localhost:5173
|
||||
```
|
||||
|
||||
- **호스트 Node 는 24 LTS 를 권장한다** — 이미지·CI(`24.22.1` 고정, 아래 규칙 "Node 버전")와 같은 메이저다. 프론트 `engines` 가 `^20.19.0 || >=22.12.0` 이라
|
||||
20.19 이상이면 돌아가지만, 버전이 다르면 이미지·CI 에서만 나는 문제를 늦게 발견한다. (메일 setup 스크립트는 `--env-file` 때문에 20.6 이상)
|
||||
|
||||
- **포트는 루트 `.env.{APP_ENV}` 가 SSOT 다** ([[env-structure]]). `vite.config.ts` 가 루트 env 를 직접 읽어
|
||||
`FRONTEND_PORT` 로 열고, `/api` 프록시 대상은 `http://localhost:${BACKEND_PORT}`(백엔드 컨테이너가
|
||||
호스트에 노출한 포트)로 잡는다. 포트를 바꿀 때 루트 env 한 줄만 고치면 된다.
|
||||
@@ -97,7 +204,8 @@ dc ps
|
||||
# 종료 — 컨테이너 중지·삭제 (DB/Redis 데이터 볼륨은 보존)
|
||||
dc down
|
||||
|
||||
# ⚠️ 데이터까지 삭제 — DB/Redis 볼륨 제거 (복구 불가, 신중히)
|
||||
# ⚠️ 데이터까지 삭제 — 명명 볼륨 제거 (복구 불가, 신중히)
|
||||
# 메일 볼륨(<PROJECT_NAME>_mail-config·_mail-data)이 지워져 메일함·계정이 전부 사라진다 (운영은 업로드 볼륨 _uploads 도)
|
||||
dc down -v
|
||||
|
||||
# 특정 서비스만 재빌드·기동 (예: 의존성 바뀐 backend)
|
||||
@@ -134,8 +242,13 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
|
||||
```
|
||||
|
||||
- 러너가 없으면 워크플로는 **"Waiting for a runner"** 로 대기만 한다.
|
||||
- compose 프로젝트 이름은 `RUNNER_NAME` 이다(`name:`) — 폴더 이름 `ci` 로 두면 같은 템플릿을 쓰는 다른 프로젝트의 러너와 겹쳐 `up` 이 그쪽 컨테이너를 다시 만든다.
|
||||
같은 PC 에 러너가 여럿이면 `RUNNER_NAME` 을 겹치지 않게 짓는다(이 저장소는 `arasystem-runner`).
|
||||
- `RUNNER_LABELS` 의 라벨이 워크플로의 `runs-on` 과 **일치해야** 잡을 집는다.
|
||||
- 러너는 도커 소켓에 접근하므로 사실상 호스트 관리자 권한을 갖는다 — 신뢰할 수 있는 워크플로만 실행되게 한다.
|
||||
- `ci.yml` 의 **infra 잡**(compose `config -q` · `docker build --check` · nginx 단계 `nginx -t`)은 잡 컨테이너 안에서 docker 를 쓴다.
|
||||
act_runner 기본 설정(`container.docker_host` 비움)이 호스트 도커 소켓을 잡 컨테이너에 붙여 주고, 잡 이미지 `gitea/runner-images:ubuntu-latest` 에
|
||||
docker CLI·compose 가 들어 있다 (2026-10-27 확인: docker 29.7, compose 5.4). 러너 설정에서 `docker_host: "-"` 로 소켓을 막았다면 이 잡만 실패한다.
|
||||
|
||||
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
|
||||
|
||||
@@ -164,8 +277,70 @@ dc --profile redis up -d
|
||||
|
||||
---
|
||||
|
||||
## 메일 (Stalwart · Mailpit) — profile `mail`
|
||||
|
||||
직원 로그인 = 메일 계정이라 **예시 env 는 개발·운영 모두 `COMPOSE_PROFILES=mail`** 이다.
|
||||
구조·운영 배포·도메인 전환 절차는 [`mail/README.md`](../../../mail/README.md) 가 SSOT 다.
|
||||
|
||||
```bash
|
||||
# 처음 띄운 뒤 한 번 (env 를 바꿨을 때도) — 여러 번 실행해도 안전하다
|
||||
node --env-file=.env.development mail/scripts/setup.mjs
|
||||
```
|
||||
|
||||
- 첫 실행은 bootstrap 후 **mail 컨테이너를 스스로 재시작**한다. 셸에 `APP_ENV` 등이 남아 env 파일 값과 다르면 중단한다
|
||||
(Node 의 `--env-file` 은 기존 환경변수를 덮어쓰지 않는다).
|
||||
- 개발: 관리 화면 `http://localhost:${MAIL_ADMIN_PORT}/admin/`, Mailpit `http://localhost:${MAILPIT_UI_PORT}` (둘 다 127.0.0.1 한정).
|
||||
setup 이 외부 발송 경로를 Mailpit 으로 돌리고 리로드한 **뒤에야** 외부 주소 메일이 Mailpit 에 쌓인다 — 그 전에는 실제로 발송된다.
|
||||
- 백엔드는 `MAIL_JMAP_URL`(http://mail:8080)·`MAIL_DOMAIN`·`MAIL_GATEWAY_USER`·`MAIL_GATEWAY_PASSWORD`·`MAIL_ATTACHMENT_MAX_SIZE_MB`(기본 20)·
|
||||
`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`(기본 25) 를 compose 로 받는다.
|
||||
- 데이터는 named volume `<PROJECT_NAME>_mail-config`·`<PROJECT_NAME>_mail-data` (UID 2000 소유가 필요해 바인드 마운트를 쓰지 않는다).
|
||||
- setup 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회.
|
||||
- setup 은 엔진의 받는 쪽 한도 "같은 보낸 도메인 → 한 받는 사람"(기본 시간당 25)을 **인증 없는 세션에만** 건다 — 사내 메일은 보낸 도메인이 같아 동료 모두 합쳐
|
||||
한 사람에게 25통을 넘으면 웹메일 발송이 거절됐다(2026-10-28). 보낸 IP 마다 초당 연결 5개 한도는 그대로. 한도 값 · 직원마다 내보내는 양의 한도는 운영 전에 정한다 (mail/README.md "엔진 한도").
|
||||
신뢰 대역은 고정한 app-network 서브넷(`APP_NETWORK_SUBNET`) 안으로만 받는다 — 더 넓으면 실패한다.
|
||||
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
|
||||
**운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다.
|
||||
- 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다
|
||||
(파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포").
|
||||
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
|
||||
**bootstrap 전에만 정할 수 있다** — 바꾼 값이 엔진의 관리자와 다르면 setup 이 멈춘다. 다른 관리자 계정은 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다.
|
||||
- ⚠️ **게이트웨이 비밀번호 = 관리자급 비밀.** Stalwart 0.16 은 대리 접근 대상을 제한하지 못해, 신뢰 대역 안에서는 게이트웨이로 관리자 계정도
|
||||
대리 접근된다 (setup 이 매번 `[주의]` 로 보여 준다). 막는 일은 백엔드가 한다 — mail/README.md "보안".
|
||||
|
||||
---
|
||||
|
||||
## 규칙
|
||||
|
||||
- **이미지 버전 고정 — 떠 있는 태그(`stable-alpine`·`v0.16`·`24-alpine`)를 쓰지 않는다.** 그날의 최신이 들어오면 호스트마다 버전이 달라지고,
|
||||
설정이 기대하는 기능이 없는 옛 이미지가 남은 호스트에서는 기동에 실패한다 (예: nginx.conf 의 `add_header_inherit` 는 1.29.3 이상).
|
||||
|
||||
| 이미지 | 지금 값 | 함께 고치는 곳 (값이 모두 같아야 한다) |
|
||||
| --- | --- | --- |
|
||||
| Node | `node:24.22.1-alpine3.24` | 아래 "Node 버전" |
|
||||
| nginx (운영 frontend) | `nginxinc/nginx-unprivileged:1.30.6-alpine3.24` | `frontend/Dockerfile` 의 `FROM … AS nginx-config`, 이 문서. 1.29.3 아래로 내리지 않는다 (`add_header_inherit`) |
|
||||
| Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다 |
|
||||
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
|
||||
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
|
||||
|
||||
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
|
||||
새 태그가 있는지와 digest 는 `docker buildx imagetools inspect <이미지>:<태그>` 로 확인한다 (2026-10-27: Stalwart `v0.16`·`v0.16.27`, nginx `stable-alpine`·`1.30.6-alpine3.24` 가 각각 같은 digest).
|
||||
- `MAIL_IMAGE` 를 바꾸면(같은 digest 라도 태그 문자열이 다르면) 다음 `up` 에서 mail 컨테이너가 다시 만들어진다 — 데이터는 named volume 이라 그대로다.
|
||||
- **Node 버전 — 24 LTS 를 패치까지 고정한다 (지금 `24.22.1`, 이미지 `node:24.22.1-alpine3.24`).**
|
||||
메이저 태그(`node:24-alpine`)는 빌드할 때마다 그날의 최신 패치를 받아 갓 나온 버전이 그대로 들어오고, 이미지와 CI 의 패치가 어긋날 수 있다.
|
||||
- **함께 고치는 곳** (값이 모두 같아야 한다):
|
||||
- `backend/Dockerfile` 의 `ARG NODE_VERSION` (builder·production 이 함께 쓴다)
|
||||
- `frontend/Dockerfile` 의 `ARG NODE_VERSION` (build-stage)
|
||||
- `.github/workflows/ci.yml` 의 `env.NODE_VERSION` (backend·frontend·error-codes-sync 세 잡이 함께 쓴다)
|
||||
- `mail/README.md` 의 운영 setup (B) 가 받는 공식 Node 배포 파일 (`node-v<버전>-linux-x64.tar.xz`)
|
||||
- 이 문서 (위 버전 표기, "프론트엔드는 호스트에서"의 호스트 권장 버전)
|
||||
- 메이저를 바꾸거나 alpine 이 3.24 에서 바뀌면 Dockerfile 두 개의 `-alpine3.24` 도 함께 고친다
|
||||
(새 태그가 있는지 `docker pull node:<버전>-alpine<알파인>` 으로 먼저 확인한다)
|
||||
- **갱신 규칙**: 일반 패치 릴리스는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다).
|
||||
**보안 릴리스는 CI 가 통과하는 대로** 올린다 (알려진 취약점 수정이 2주 대기보다 우선한다).
|
||||
릴리스 날짜·보안 여부는 <https://nodejs.org/dist/index.json> 의 `date`·`security` 로 확인한다.
|
||||
24.22.1 은 2026-10-20 보안 릴리스라 보안 릴리스 규칙으로 바로 고정했다.
|
||||
- 올린 뒤에는 운영 이미지 두 개 빌드, CI 와 같은 명령(아래 "의존성은 컨테이너 안에서"처럼 임시 폴더 복사본에서 lint·type-check·test·build),
|
||||
dev 재빌드(`dc up -d --build -V`)로 확인한다.
|
||||
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
|
||||
- **의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.**
|
||||
dev 는 `/app/node_modules` 를 익명 볼륨으로 마운트하는데, `--build` 만으로는 **예전 볼륨이 그대로 재사용된다.**
|
||||
@@ -174,6 +349,51 @@ dc --profile redis up -d
|
||||
dc up -d --build --renew-anon-volumes backend
|
||||
```
|
||||
(`-V` 는 같은 옵션의 축약형. DB 볼륨은 named volume 이라 영향받지 않는다)
|
||||
- **의존성은 컨테이너 안에서, npm 은 `11.6.2` 로** ([[nestjs]] 의 Windows 주의 항목).
|
||||
lock 을 바꾸는(`npm install`/`uninstall`) npm 과 검사하는(`npm ci` — 이미지 빌드·CI) npm 을 같은 버전으로 고정했다.
|
||||
npm 은 버전마다 lock 의 중첩 항목(chokidar·readdirp·@emnapi 등)을 다르게 정리해서, 10.x·11.6·11.2x 를 섞어 쓰면 lock 이 매번 바뀐다.
|
||||
- 컨테이너(`dc exec backend npm …`)는 이미지에 고정된 버전이다. 호스트에서 lock 을 만질 일이 있으면 `npm i -g npm@11.6.2` 로 맞춘다.
|
||||
- 바꿀 때는 `backend/Dockerfile`·`frontend/Dockerfile` 의 `ARG NPM_VERSION`, `.github/workflows/ci.yml` 의 `NPM_VERSION`, 이 문서를 **함께** 고친다.
|
||||
- ⚠️ **형식만 맞추려고 lock 을 통째로 다시 쓰지 않는다** (`npm install --package-lock-only`).
|
||||
2026-10-26 backend 에서 npm 11.6.2 로 이렇게 다시 쓰자 `@emnapi/core`·`@emnapi/runtime` 이 빠지면서
|
||||
`npm ci` 가 `Invalid: lock file's @emnapi/wasi-threads@1.2.1 does not satisfy @emnapi/wasi-threads@1.2.3` 로 실패했다(되돌림).
|
||||
lock 은 의존성을 실제로 바꿀 때만 컨테이너 안 `npm install <패키지>` 로 고치고, 고친 뒤에는 **임시 폴더에 복사해 `npm ci`** 로 확인한다
|
||||
(저장소 폴더를 그대로 마운트하면 호스트의 Windows `node_modules` 를 읽어 엉뚱하게 실패한다).
|
||||
- ⚠️ **2026-10-27 에는 컨테이너의 `npm install <패키지>` 도 같은 항목을 지웠다** — backend 에 `htmlparser2@^12.0.0` 을 직접 의존성으로 더하자
|
||||
`@emnapi/core`·`@emnapi/runtime` 과 중첩 `chokidar` 가 빠져 `npm ci` 가 위와 같은 오류로 실패했다.
|
||||
그 패키지는 이미 lock 에 같은 판으로 있었으므로(sanitize-html 의 의존성) npm 이 루트 `dependencies` 에 더한 한 줄만 원래 lock 에 옮기고,
|
||||
임시 복사본에서 `npm ci`·`npm ci --omit=dev` 와 `require` 로 확인했다 — **lock 에 없던 패키지에는 쓸 수 없는 방법**이다.
|
||||
- **원인과 해결 (2026-10-27)**: npm 11.6.1~11.6.2(arborist 9.1.5~9.1.6)의 버그다. `npm ci` 는 lock 의 의존 표시를 그대로 믿지만
|
||||
`npm install` 은 늘 다시 계산하는데, 들어오는 peer 간선이 하나라도 있는 패키지를 일반 의존성이어도 `peer` 로 남기고 `peer`+`optional` 인 패키지를 모두 지운다.
|
||||
jest 가 쓰는 `unrs-resolver@1.12.x` 가 그 모양이다 — 선택 설치인 wasm32 바인딩의 일반 의존성 `@emnapi/core`·`@emnapi/runtime` 이
|
||||
`@napi-rs/wasm-runtime@^1.1.4` 의 필수 peer 이기도 하다. 그래서 backend lock 의 `unrs-resolver` 를 **1.11.1** 로 내렸다
|
||||
(바인딩이 peer 없는 `@napi-rs/wasm-runtime@0.2.12` 를 쓴다. 개발 전용, `npm audit` 결과 같음).
|
||||
- **npm 이 11.6.2 인 동안 `unrs-resolver` 를 1.12.x 로 올리지 않는다** — `npm update` 로 범위 안 최신을 받아도 1.12.2 가 되어 다시 깨진다.
|
||||
npm 을 11.6.3 이상으로 올리면 원인이 사라진다(그때 lock 의 `peer` 표시가 한 번 크게 바뀌지만 `npm ci` 는 통과한다).
|
||||
- 중첩 `chokidar@4.0.3`(@angular-devkit 의 optional peer — 루트는 chokidar 5)은 npm 이 판과 무관하게 일부러 지우는 항목이라 lock 에 없다
|
||||
(`npm ls` 의 chokidar invalid·readdirp extraneous 표시는 정상).
|
||||
- lock 을 바꾼 뒤에는 임시 복사본에서 `npm ci` 와 함께 **인자 없는 `npm install` 이 lock 을 바이트 그대로 두는지**도 본다.
|
||||
frontend lock 은 같은 문제가 없다(2026-10-27 확인).
|
||||
- **dev backend 의 `/app/dist` 는 익명 볼륨이다.** 호스트에서 `npm run build` 를 하면 바인드 마운트된 `backend/dist` 가 지워졌다
|
||||
다시 생기면서 컨테이너의 Nest watch 가 끊겨 앱이 옛 코드로 계속 돈다 — 그래서 산출물을 호스트와 나눴다.
|
||||
`nest-cli.json` 의 `deleteOutDir` 는 마운트 지점을 지우려다 EBUSY 로 죽으므로, dev `command` 는 기동 때 볼륨 내용만 비우고
|
||||
`deleteOutDir` 만 끈 설정 사본으로 `start:dev` 를 돌린다 (`-V` 로 재생성하면 이미지의 산출물로 다시 채워진다).
|
||||
- **dev backend 메모리 한도는 2g** — watch 컴파일과 타입 검사가 함께 돌아 base 의 1g 에서 OOM 으로 죽었다 (운영은 1g 그대로).
|
||||
- **dev backend 는 컨테이너를 기동할 때 한 번 `migration:run` 을 한다** (`dc down -v` 등으로 DB 를 새로 만든 뒤에도 바로 뜨게).
|
||||
watch 재시작은 이 단계를 다시 돌지 않는다 — 그래서 생성만 하고 검토 전인 마이그레이션이 파일을 저장할 때마다 적용되지는 않는다.
|
||||
⚠️ 다만 **컨테이너를 다시 띄우면** 그때 있는 마이그레이션이 적용된다. 마이그레이션을 고치는 중(revert → 수정 → run)에는
|
||||
backend 를 재기동하지 말고, 검토를 마친 뒤 재기동한다. 실패하면 기동이 멈춘다(`set -e`) — `dc logs backend` 로 원인을 본다.
|
||||
운영은 `migrationsRun` 으로 앱이 뜰 때 적용된다.
|
||||
- **app-network 는 이름(`${PROJECT_NAME}_app-network`)과 서브넷(`APP_NETWORK_SUBNET`, 기본 `172.16.71.0/24`)을 고정한다.**
|
||||
메일 엔진이 이 대역만 신뢰하기 때문이다. 그래서 **backend·db·redis·mail(개발은 mailpit 도)만 붙인다** — frontend 는 `edge`
|
||||
(`${PROJECT_NAME}_edge`, `EDGE_NETWORK_SUBNET` 기본 `172.16.72.0/24`)로만 backend 에 닿는다. 새 서비스를 붙일 때도 신뢰 대역에 넣어도 되는지부터 본다.
|
||||
- mail setup 이 검사하는 서브넷 조건: 사설 대역, 네트워크 주소로, 접두 길이 `/24`~`/28`. 넓을수록 실제 클라이언트 주소(사무실 LAN·VPN)와 겹쳐
|
||||
그 접속까지 믿게 될 위험이 커지고, `/28` 보다 좁으면 컨테이너 주소가 모자란다 (근거: `setup.mjs` 의 `APP_NETWORK_PREFIX_RANGE`).
|
||||
- 기본값은 도커 기본 주소 풀 밖이라 자동 배정 네트워크와 겹치지 않지만, 같은 호스트의 다른 프로젝트가 같은 값을 쓰면
|
||||
`up` 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 mail setup 을 다시 실행한다 (`EDGE_NETWORK_SUBNET` 도 같다).
|
||||
⚠️ 서브넷을 처음 고정하거나 바꿀 때, 네트워크 구성을 바꿀 때는 **`dc down` 후 `dc up -d`** (또는 `dc up -d --force-recreate`)로 전부 다시 만든다.
|
||||
그냥 `up -d` 만 하면 네트워크는 새로 만들어지지만 재생성되지 않은 컨테이너가 서비스 이름(`db`·`mail` 등) 별칭을 잃어
|
||||
backend 가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (2026-10-26 개발 환경에서 겪음).
|
||||
- **env 파일**: 실행 전 아래 두 파일이 모두 있어야 한다. 없으면 `*.example` 을 복사해 생성한다.
|
||||
- 루트 `.env.{APP_ENV}` — 설정 SSOT ([[env-structure]])
|
||||
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다**
|
||||
@@ -188,5 +408,12 @@ dc --profile redis up -d
|
||||
- 프론트엔드 운영 컨테이너는 `nginx-unprivileged` 라 **8080** 포트를 쓴다 (`expose`·healthcheck·nginx.conf 가 모두 8080).
|
||||
- 프론트엔드 dev 컨테이너는 vite 를 **5173** 으로 띄운다 (비-root 는 80 포트 바인드 불가).
|
||||
- 포트를 바꿀 때는 Dockerfile `EXPOSE` · `nginx.conf` 의 `listen` · compose `expose`/`healthcheck` 를 **함께** 고쳐야 한다.
|
||||
- **DB/Redis 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
|
||||
- **DB/Redis·backend·frontend(dev 컨테이너) 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
|
||||
backend(3000·3001)는 호스트의 vite 개발 서버·도구만 붙으면 된다 — Android 에뮬레이터의 `10.0.2.2` 는 호스트 127.0.0.1 로 가서 그대로 되고,
|
||||
같은 LAN 의 실기기는 닿지 않는다. 운영 backend 는 호스트 포트를 열지 않는다(프론트 nginx 의 `/api` 로만).
|
||||
profile `frontend` 의 vite 컨테이너도 `127.0.0.1:${FRONTEND_PORT}` 다 (호스트 vite 와 같은 범위).
|
||||
- **메일 포트**: 8080(HTTP JMAP·관리 API)은 어떤 환경에서도 공개하지 않는다 (dev 는 `127.0.0.1:${MAIL_ADMIN_PORT}` 만).
|
||||
운영의 25·465·993·443 은 `0.0.0.0`(IPv4)로만 바인딩한다 — IPv6 접속은 docker-proxy 가 중계해 도커 게이트웨이 주소로 보이고,
|
||||
그러면 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다. dev 오버라이드는 `ports: !override` 로 운영 포트를 지운다.
|
||||
- **Mailpit**: 이미지 기본이 root 라 `user: "65534:65534"` 로 내린다. dev 전용(운영 compose 에 없다).
|
||||
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`.
|
||||
|
||||
@@ -17,32 +17,48 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| --- | --- | --- |
|
||||
| **에러 코드 레지스트리** | `src/common/errors/error-codes.ts` | 코드↔메시지↔HTTP 상태 **SSOT**. `npm run sync:error-codes` 로 클라이언트 사전 생성 |
|
||||
| **BusinessException** | `src/common/errors/business.exception.ts` | 에러 코드가 **생성자 필수 인자** — 코드 없는 실패를 만들 수 없다 |
|
||||
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | main.ts 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
|
||||
| 표준 응답 인터셉터 | `src/common/interceptors/transform.interceptor.ts` | `app.setup.ts` 에서 전역 등록 — 컨트롤러는 raw 데이터만 반환. `@SkipTransform()` 으로 제외 |
|
||||
| 전역 예외 필터 | `src/common/filters/http-exception.filter.ts` | 레지스트리 조회로 `{success:false,error:{code,message,traceId}}` 생성 ([[error-codes]]) |
|
||||
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]) |
|
||||
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]). `configureApp()` **맨 앞**의 express 미들웨어라 본문 파서 오류(413·400·415)에도 붙는다. 들어온 값은 `^[A-Za-z0-9._-]{1,128}$` 일 때만 이어받고 아니면 새로 발급(로그 위조 방지) |
|
||||
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
|
||||
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
|
||||
| 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` |
|
||||
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 |
|
||||
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 |
|
||||
| Rate Limiting | `app.module.ts` 의 `ThrottlerGuard`(APP_GUARD) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정 |
|
||||
| 전역 HTTP 설정 | `src/app.setup.ts` 의 `configureApp()` | prefix · helmet · cookie-parser · JSON 본문 파서(1mb — 발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4mb(`SEND_MESSAGE_JSON_BODY_LIMIT`, 앞단 nginx 와 같은 값), JSON 만 · **UTF-8 만**. `express.json` 두 개를 직접 붙인다 — 읽기 전 확인 `verify` 를 쓰려고(Nest 의 `useBodyParser` 는 verify 를 받지 않는다)) · 모양 한도 `rejectOversizedJsonBody`(아래 행) · ValidationPipe(`createValidationPipe()` — `whitelist/forbidNonWhitelisted/transform/stopAtFirstError`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성) |
|
||||
| **JSON 본문 모양 한도** | `src/shared/utils/json-shape.ts` + `app.setup.ts` 의 `JSON_SHAPE_LIMITS` | 배열 하나 1,000 · 값(객체 · 배열 · 원시) 10,000 · 깊이 32. 읽기 전(UTF-8 바이트를 한 번 훑는다 — `verifyJsonBody`, 재귀 없이 넘는 순간 멈춘다)과 읽은 뒤(`rejectOversizedJsonBody`) 두 번 보고, 넘으면 DTO 변환 · 검사 전에 400 `VAL_001`(본문 파서는 가드보다 앞 — 로그인 전 요청도 400). DTO 의 개수 한도로는 막지 못해 둔 것이다(2026-10-28 리뷰 — 1MB 에 작은 객체 8만 개로 7.8초 · +329MB, 깊은 중첩은 500). 새 계약의 본문이 한도를 넘으면 여기부터 올린다 ([[security]]) |
|
||||
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. 인증은 쿠키(`addCookieAuth('access_token')`)만 등록 — Authorization 헤더 인증은 없다 |
|
||||
| 보안 미들웨어 | `app.setup.ts`(helmet · `TRUST_PROXY`) · `main.ts`(CORS 화이트리스트 — 운영은 `CORS_ORIGIN` 만) | 클라이언트 IP 는 `TRUST_PROXY`(홉 수 또는 IP·CIDR 목록, 운영 필수) 기준 — `shared/utils/trust-proxy.ts` |
|
||||
| **출처 검사(CSRF)** | `src/common/guards/origin.guard.ts` (APP_GUARD) | 상태 변경 요청은 `Sec-Fetch-Site`(same-origin·none) 또는 허용 목록(`CORS_ORIGIN`)의 `Origin` 이어야 통과, 아니면 `AUTH_003`. 둘 다 없으면 통과. 규칙 `shared/utils/request-origin.ts` |
|
||||
| **인증 가드** | `src/common/guards/jwt-auth.guard.ts` | `JwtAuthGuard` — 액세스 토큰 쿠키 검증, 실패는 `AUTH_001`. 전략은 `modules/auth/jwt.strategy.ts` |
|
||||
| 현재 사용자 데코레이터 | `src/common/decorators/current-user.decorator.ts` | `@CurrentUser()` → `AuthenticatedUser { email, name }` |
|
||||
| 길이 검사 데코레이터 | `src/common/decorators/code-units.decorator.ts` | `MaxCodeUnits` · `MinCodeUnits` — JS 문자열 길이(UTF-16 코드 단위)로 센다(웹의 `.length` 와 같다). class-validator 의 `MinLength`·`MaxLength` 는 서로게이트 쌍 · 이모지를 덜 세어 클라이언트가 센 길이와 어긋난다 — 클라이언트와 같은 한도를 거는 문자열은 이것을 쓴다 |
|
||||
| 공백 · 중복 데코레이터 | `src/common/decorators/trim-string.decorator.ts` · `array-distinct.decorator.ts` | `TrimString()` — 앞뒤 공백을 지운 값으로 검사하고 그 값으로 저장한다(메일함 이름 · 필터의 이름 · 찾을 글 · 전달 주소). `ArrayDistinct(options)` — 배열 항목이 겹치지 않는지 Set 으로 한 번 훑는다(선형 — 원시 값은 값으로, 객체는 같은 객체일 때만. 객체 배열의 id 중복은 서비스가 Set 으로 본다 — 예: 필터 규칙 `findFilterRulesProblem`). class-validator 의 `ArrayUnique` 는 `indexOf` 비교라 항목 수의 제곱이다 — **쓰지 않는다** |
|
||||
| 직원 인증 | `modules/auth` | 쿠키 JWT 로그인·`me`·`refresh`·`logout` (계약 `docs/mail/api-contract.md` 1절) · 비밀번호 변경 `PUT /api/auth/password`(4단계 — 직원 본인 자격으로 엔진의 `x:AccountPassword/set`, 로그인과 같은 실패 카운터 · 진행 중 잠금, 바뀌면 이 요청의 세션만 남기고 그 직원의 세션을 끊는다 — 결과를 모르는 실패에도 끊는다). 설정 `auth.settings.ts`, 쿠키 `auth-cookies.ts`, 로그인 실패 횟수 `login-attempt.service.ts`, **로그인 세션(DB)** `auth-session.entity.ts`·`auth-session.policy.ts`(1회용 회전·재사용 감지)·`auth-session.cleanup.ts`(끝난 세션 정리 cron). **메일 엔진 관리자 차단** — 정적 목록(`employee-address.ts` + `MAIL_BLOCKED_ACCOUNTS`)과 로그인·리프레시 때 엔진 권한 확인(`jmap/jmap.client.ts`) 두 겹 |
|
||||
| 메일 (웹메일 API) | `modules/mail` | 엔진 포트 `mail.provider.ts`(`MAIL_PROVIDER`) + Stalwart JMAP 어댑터 `jmap/`, 받은 메일 HTML 정화 `html/sanitize-mail-html.ts`, 보내는 서식 본문 정화(허용 목록) `html/sanitize-outgoing-html.ts`, 두 정화 앞의 사전 검사(길이·파서 스택 비용, 선형 시간) `html/html-parse-budget.ts`, 첨부 형식 `attachment.policy.ts`, 설정 `mail.settings.ts`, 발송 중복 방지(`Idempotency-Key`) `send-idempotency.ts`, 발송의 받는 사람별 결과 `jmap/delivery-status.ts`(제출 다음에 따로 읽는다 — 일부 거절은 201 의 `rejectedRecipients`, 모두 거절이면 보낸 사본을 지우고 `MAIL_010` · `MAIL_011`, 계약 2절 "받는 사람별 결과"), 첨부 임시 디스크 저장 `mail-upload-storage.ts`, 임시 저장 `draft.controller.ts`(엔진 초안 — 새로 만들고 다시 읽은 뒤 옛것을 지운다), 서명 그림 주소 규칙 `html/signature-images.ts`(보낼 때 `cid:` 로), 본문 그림 판정 `html/inline-images.ts`, 받는 사람 추천 `recipient-suggestions.service.ts`(사용자별 10분 캐시) · 순서 `recipient-ranking.ts` · 커서 `recipient-suggestion-cursor.ts`, 검색어 → 엔진 조건 `jmap/search-terms.ts`(한국어 조사 표는 여기 한 곳 — 낱말 안 OR · 낱말끼리 AND, 따옴표 구절), 여러 통 `PATCH /api/messages`(엔진 요청 한 번) · 휴지통에서 영구 삭제 `message-deletion.controller.ts`(휴지통의 메일만, 조건부 지우기) — 본문 `dto/bulk-messages.dto.ts`, 새 메일 확인 `GET /api/mail-state`(`mailbox.controller.ts`), 메일함 관리 · 전체 읽음 · 비우기(`mailbox.controller.ts`, 본문 `dto/mailbox.dto.ts` — 오래 걸리는 처리는 어댑터(`BulkRun`)가 500통씩 · 한 요청 25초 안에서(엔진 호출마다 남은 시간만큼) 하고 남은 수를 돌려준다, 같은 직원 · 같은 메일함은 한 번에 하나(`mail.service.ts` 의 `mailboxOperations` — 진행 중이면 `REQ_001`), 지우기는 안의 메일을 휴지통으로 옮긴 뒤) · 부재중 자동 응답 `MailVacationController`(`dto/mail-vacation.dto.ts` — 6단계부터 저장 자리는 아래 필터의 Sieve 스크립트) · 메일 필터 `mail-filter.controller.ts`(6단계 — `GET`·`PUT /api/mail-filters`, 본문 `dto/mail-filter.dto.ts`, 칸 사이 규칙 `mail-filters.ts`. 엔진 쪽은 Sieve 스크립트 하나(`arasystem` — 필터와 **부재중을 함께**, 엔진의 VacationResponse 는 쓰지 않고 처음 읽을 때만 이어받는다): 저장소 `jmap/jmap-filter-script.ts`(읽은 상태 그대로일 때만 쓰고 켠다 · 그사이 바뀌면 다시 읽기 · 같은 규칙이면 옛 version 이어도 저장), 글 만들기 `jmap/sieve-script.ts`, 머리 주석의 원본(JSON · 판 `FILTER_SOURCE_FORMAT`) `jmap/filter-source.ts`, 부재중 제목 · 본문 인코딩 `jmap/mime-text.ts`, JMAP 결과 읽기 `jmap/jmap-results.ts`) · 원문 `GET /api/messages/{id}/source`(이름 `attachment.policy.ts` 의 `messageSourceFileName`) · 스팸 표시 `isJunk` (계약 2절) · 실시간 알림 `GET /api/mail-events`(5단계 — SSE, `mail-events.controller.ts` · 엔진을 모르는 `mail-events.service.ts`(직원마다 연결 5개 · 25초 ping · 토큰 만료 30초 전 `end` · 종료 때 `end`) · 엔진 쪽 `jmap/jmap-mail-watch.ts`(직원마다 엔진 EventSource 하나 · 75초 무소식이면 다시 잇기) · `jmap/jmap-push.ts`(스트림 읽기) · `jmap/new-mail.ts`(새 메일 거르기), SSE 형식 `shared/utils/sse.ts`. 머리글은 스트림을 열기로 정하면 바로(`flushHeaders`), 처리기에 닿기 전에 끊긴 응답(`res.destroyed`)도 정리한다) |
|
||||
| 메일 서명 | `modules/mail-signature` | 사용자마다 서명 하나(DB) + 서명 그림. 그림 형식은 내용으로 판별(매직 바이트·머리의 가로·세로, 새 의존성 없음) `signature-image-format.ts`, 파일은 업로드 볼륨의 `mail-signatures/` (`signature-image-storage.ts`), 서명에 붙지 않은 그림 정리 cron `mail-signature.cleanup.ts`. 메일 모듈이 발송·임시 저장 때 `MailSignatureService` 로 그림을 쓴다 (계약 2절 "서명") |
|
||||
| 사내 목록 | `modules/mail-directory` | 웹메일 로그인에 성공한 직원(관리자·게이트웨이·막은 계정 제외)을 `AuthService` 가 기록 → 받는 사람 추천의 "사내". 엔진의 계정 목록(Principal) 권한은 쓰지 않는다(일부러 막아 두었다) |
|
||||
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
|
||||
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
|
||||
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
|
||||
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off |
|
||||
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
|
||||
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션"). 첫 마이그레이션은 로그인 세션 표(`CreateAuthSessions`). e2e 는 시작할 때 `runMigrations()` 로 표를 만든다 |
|
||||
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
|
||||
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
|
||||
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
|
||||
| 파일 업로드 | `modules/upload` | **로그인 필요**(`JwtAuthGuard`). 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]). 한글 파일명은 multer `defParamCharset: 'utf8'` |
|
||||
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
|
||||
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
|
||||
| 요청 컨텍스트 | `shared/logger/request-context.ts` | 요청마다 추적 ID 를 AsyncLocalStorage 에 둔다 — 필터를 거치지 않는 로그도 `traceTag()` 로 `[traceId]` 를 붙인다 ([[logging]]). 요청이 끝난 뒤에도 이어지는 일(실시간 알림의 엔진 연결 등)은 `runOutsideRequestContext()` 로 연다 — 처음 요청의 추적 ID 를 물려받지 않게 |
|
||||
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 (예: `mask.ts` 로그용 이메일 마스킹·URL 의 검색어 가리기(`maskUrlForLog` — `q` 와 `cursor`(검색어 해시를 담는다)는 길이만), `settle.ts` 정해진 시간까지만 기다리기, `duration.ts` 기간 파싱, `content-disposition.ts` 다운로드 헤더, `cookies.ts`, `trust-proxy.ts`, `request-origin.ts`) |
|
||||
|
||||
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
|
||||
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
|
||||
> - **보호 라우트**: 인증은 구현되어 있다 (`modules/auth` + `common/guards/jwt-auth.guard.ts`). 새 보호 라우트는 컨트롤러·핸들러에
|
||||
> `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` 를 걸고 `@CurrentUser()` 로 직원을 받는다 (전역 가드가 아니다 — `users`·`health` 예제는 공개, `upload` 는 로그인 필요).
|
||||
> 권한(AUTH_003) 구분이 필요해지면 역할 가드를 `common/guards/` 에 추가한다.
|
||||
> 반면 출처 검사(`OriginGuard`)는 **전역**이다 — 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 예외 표시를 따로 만든다
|
||||
> - **메일 엔진 교체**: `modules/mail/mail.module.ts` 의 `MAIL_PROVIDER` 구현(useClass)만 바꾼다 — 서비스·컨트롤러는 포트만 안다
|
||||
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체).
|
||||
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다.
|
||||
> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조)
|
||||
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
|
||||
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
|
||||
> - `common/pipes/` 는 해당 관심사 등장 시 생성 (`common/guards/` 에는 인증 가드가 있다. 모듈 전용 파이프는 모듈 안에 둔다 — 예: `modules/mail/mail-id.pipe.ts`).
|
||||
> 아래 "모듈 구조"는 목표 구조
|
||||
|
||||
---
|
||||
|
||||
@@ -127,7 +143,7 @@ export class TransformInterceptor<T> implements NestInterceptor {
|
||||
}
|
||||
```
|
||||
|
||||
- `main.ts`에서 전역 등록: `app.useGlobalInterceptors(new TransformInterceptor())`
|
||||
- `app.setup.ts` 의 `configureApp()` 에서 전역 등록 (main.ts·e2e 공용): `app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)))`
|
||||
- 실패 응답(`success: false`)은 아래 Exception Filter가 생성한다.
|
||||
- 응답 포맷 SSOT는 [[response-format]] 참조.
|
||||
|
||||
@@ -168,7 +184,7 @@ const config = new DocumentBuilder()
|
||||
.setTitle('API 문서')
|
||||
.setDescription('프로젝트 API 명세서')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.addCookieAuth(ACCESS_TOKEN_COOKIE) // 인증은 HttpOnly 쿠키뿐 — 보호 라우트에 @ApiCookieAuth() (Bearer 는 등록하지 않는다)
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
@@ -190,7 +206,11 @@ SwaggerModule.setup('api-docs', app, document);
|
||||
|
||||
## 인증 (JWT)
|
||||
|
||||
> 📐 **확장 지점.** 이 템플릿은 JWT 인증을 강제하지 않는다(규칙만 제공). **인증이 필요해지면** 아래 규칙대로 구현한다: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증.
|
||||
> **구현 위치.** 쿠키 기반 JWT 가 구현되어 있다 — `modules/auth`(로그인·`me`·`refresh`·`logout`, 계약 `docs/mail/api-contract.md` 1절),
|
||||
> 가드 `common/guards/jwt-auth.guard.ts`, 전략 `modules/auth/jwt.strategy.ts`, `@CurrentUser()` `common/decorators/current-user.decorator.ts`.
|
||||
> 서명 키·만료(`JWT_*`)는 `modules/auth/auth.settings.ts` 한 곳에서 읽는다. 직원 자격 확인은 메일 엔진 포트(`MAIL_PROVIDER`)로 한다.
|
||||
> 리프레시 세션은 DB(`auth_sessions`)에 둔다 — 토큰은 해시만 저장, 1회용 회전, 이미 쓴 토큰이 오면 세션 폐기, 절대 30일·미사용 14일.
|
||||
> 액세스 토큰은 세션을 조회하지 않는다 (끊어도 만료까지 유효 — 그래서 `JWT_ACCESS_EXPIRES_IN` 은 env 검증이 5분~1시간으로 막는다).
|
||||
|
||||
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
|
||||
- Refresh Token: HttpOnly Secure 쿠키로 전달
|
||||
@@ -206,7 +226,14 @@ SwaggerModule.setup('api-docs', app, document);
|
||||
> 📐 **Redis 는 확장 지점이다.** compose 에서 **기본 비활성**(profile)이며 애플리케이션도 사용하지 않는다.
|
||||
> 단일 인스턴스에서는 in-memory 로 충분하다.
|
||||
>
|
||||
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다:
|
||||
> Redis 가 실제로 필요해지는 시점(다중 인스턴스 운영, 세션·캐시 공유, 분산 락)에 연결한다.
|
||||
> ⚠️ 인스턴스를 늘리면 **프로세스 메모리에 둔 한도·잠금**도 함께 옮겨야 한다 — 그대로 두면 인스턴스마다 따로 센다:
|
||||
> 로그인 실패 카운터(`modules/auth/fixed-window-counter.ts` → `INCR`+`PEXPIRE` 를 한 Lua 스크립트로),
|
||||
> 같은 주소의 진행 중 로그인·비밀번호 변경·같은 `Idempotency-Key` 의 진행 중 발송(`SET NX PX` 잠금), `@nestjs/throttler` 저장소,
|
||||
> 같은 직원 · 같은 메일함의 오래 걸리는 처리(`mail.service.ts` 의 `mailboxOperations` — 그대로면 두 인스턴스가 같은 휴지통을 함께 비우며 서로 어긋난다 → 잠금),
|
||||
> 사용자별 서명 그림 올리기 차례(`mail-signature.service.ts` 의 `uploadQueues` — 그대로면 동시 업로드가 보관 상한을 넘을 수 있다 → 잠금),
|
||||
> 받는 사람 추천 캐시(`recipient-suggestions.service.ts` — 틀리지는 않지만 보낸 직후 다른 인스턴스가 10분까지 옛 목록을 준다 → 공유 캐시나 무효화 알림),
|
||||
> 실시간 알림의 직원당 연결 5개(`mail-events.service.ts` — 기능은 맞지만 인스턴스마다 따로 세고, 엔진 연결도 인스턴스마다 하나씩 생긴다 → 필요하면 공유 셈):
|
||||
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
|
||||
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
|
||||
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
|
||||
@@ -214,14 +241,20 @@ SwaggerModule.setup('api-docs', app, document);
|
||||
> 4. 연결 실패 시 앱이 죽지 않도록 폴백 전략을 정한다
|
||||
>
|
||||
> 끝까지 쓰지 않을 프로젝트라면 compose 에서 redis 서비스와 관련 env 를 지우는 편이 낫다.
|
||||
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD`). 초과 시 429 → `REQ_001`.
|
||||
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD` = `ClientIpThrottlerGuard`). 초과 시 429 → `REQ_001`.
|
||||
IP 를 세는 모든 곳은 `clientIpKey()` 를 쓴다 — IPv6 는 주소를 계속 바꿀 수 있어 원문 그대로 세면 IP 한도가 무력해진다.
|
||||
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
|
||||
로그인 무차별 대입은 **실패 횟수**로 막는다 — `modules/auth/login-attempt.service.ts` (비밀번호 변경의 지금 비밀번호 틀림도 같은 카운터로 센다).
|
||||
메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 원자적으로 차지하고(동시 요청으로 한도를 넘지 못하게), 같은 주소의 진행 중 시도는 기다리지 않고 `REQ_001`.
|
||||
성공·엔진 장애는 차지한 자리를 돌려준다(성공은 세지 않는다). 요청 수 상한은 그와 별개로 넉넉하게 — 로그인 라우트의 `@Throttle`(IP 당 1분 60회, 사무실 NAT 고려).
|
||||
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
|
||||
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `toPaginatedResult()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]])
|
||||
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 금지. 레벨 기준·민감정보 마스킹은 [[logging]]
|
||||
- **Graceful shutdown**: `app.enableShutdownHooks()` 가 적용되어 있다. 종료 시 정리가 필요한 리소스(큐 소비자, 워커, 외부 연결)는
|
||||
`OnApplicationShutdown` 훅으로 정리한다. **`process.on(signal)` 로 직접 잡지 말 것** — Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다
|
||||
(`shutdown.logger.ts` 가 참고 패턴).
|
||||
**오래 열린 응답(SSE 등)은 `BeforeApplicationShutdown` 에서 먼저 끝낸다** — `app.close()` 가 HTTP 서버를 닫을 때 열린 연결이 끝나기를 기다려 `stop_grace_period`(30초)를
|
||||
다 쓰고 강제 종료된다 (`mail-events.service.ts` 가 `end {server-shutdown}` 를 보내고 닫는다).
|
||||
- **Health Check**: `@nestjs/terminus` 로 구성. 주의점 두 가지 —
|
||||
`pingCheck` 기본 타임아웃 1초는 짧아 유휴 커넥션 재연결 시 오판하므로 늘려서 쓰고,
|
||||
표준 응답 래퍼/에러 코드에서 제외해 원인이 보이게 한다 ([[response-format]])
|
||||
@@ -297,6 +330,13 @@ dc exec backend npm uninstall <패키지>
|
||||
- 변경 후 호스트의 `package.json`·`package-lock.json` 이 갱신됐는지 확인하고 **함께 커밋**한다 (bind mount 라 호스트에 반영된다).
|
||||
- 호스트에서 실행해버렸다면 `git checkout backend/package-lock.json` 으로 되돌린 뒤 위 방법으로 다시 한다.
|
||||
- 커밋 전 `docker compose ... build backend` 로 `npm ci` 가 통과하는지 확인한다. CI 도 같은 검사를 한다.
|
||||
⚠️ npm 이 11.6.2 인 동안 jest 의 `unrs-resolver` 를 1.12.x 로 올리지 않는다(`npm update` 포함) — 올리면 컨테이너의 `npm install` 이 lock 을 깨뜨린다.
|
||||
원인과 확인 방법은 [[docker]] "의존성은 컨테이너 안에서" 의 2026-10-27 항목.
|
||||
- **운영 의존성이 실제로 들어가는지**도 본다 — 임시 복사본에서 `npm ci --omit=dev` 후 새 운영 의존성을 `require` 해 본다.
|
||||
lock 의 `dev` 표시가 틀리면 운영 이미지에서만 패키지가 빠져 앱이 뜨지 않는다 (2026-10-27 htmlparser2 사례).
|
||||
- **ESM 전용 패키지**가 들어와 Jest 가 `Cannot use import statement outside a module` 로 실패하면, jest 설정 두 곳
|
||||
(`package.json` 의 `jest` · `test/jest-e2e.json`)의 `transform`·`transformIgnorePatterns` 목록에 그 패키지를 더한다
|
||||
(이미 있는 ts-jest 로 CJS 변환 — 지금은 htmlparser2 계열 6개).
|
||||
- 전이 의존성의 취약점이 `npm audit fix` 로 안 풀리면 `package.json` 의 `overrides` 를 쓴다 ([[security]]).
|
||||
현재 `multer: ^2.4.0` 이 걸려 있다 — `@nestjs/platform-express` 를 v12 로 올릴 때 제거한다.
|
||||
|
||||
@@ -310,13 +350,36 @@ dc exec backend npm uninstall <패키지>
|
||||
- [ ] **에러**: 사용자 노출 실패는 `BusinessException('CODE')` 로 던진다. 코드 없는 실패 금지.
|
||||
- [ ] **에러 코드 추가 시**: `error-codes.ts` 만 수정 → `npm run sync:error-codes` 실행 → 세 스택 목록 테스트 갱신 ([[error-codes]]).
|
||||
- [ ] **목록 API**: 페이지네이션 적용, 응답은 `{items, pageInfo}`.
|
||||
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`.
|
||||
- [ ] **DTO**: 모든 입력 DTO 에 `class-validator` + 한국어 메시지 + `@ApiProperty()`. 배열 중복은 `ArrayDistinct`(`ArrayUnique` 금지 — 항목 수의 제곱).
|
||||
DTO spec 은 전역 파이프 옵션에서 `stopAtFirstError` 만 빼고 검사한다(걸린 규칙을 모두 보려고 — 통과 · 실패 판정은 같다).
|
||||
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
|
||||
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
|
||||
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
|
||||
성능 단언은 `backend/test/cpu-time.ts` 의 `startCpuTimer()`(프로세스 CPU 시간)로 잰다 — 벽시계 한도는 부하에서 흔들린다 ([[testing]]).
|
||||
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
|
||||
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
|
||||
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
|
||||
- [ ] **DB 전환 시**: in-memory Map → TypeORM 엔티티/리포지토리로 교체.
|
||||
- [ ] **인증 추가 시**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/003 흐름 확인.
|
||||
- [ ] **보호 라우트**: `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` + `@CurrentUser()` — 가드 없이 `@CurrentUser()` 를 쓰면 AUTH_001 로 막힌다 ([[security]]).
|
||||
- [ ] **전역 설정 변경 시**: `src/app.setup.ts` 만 고친다 — main.ts 와 e2e 가 같은 함수를 쓴다.
|
||||
- [ ] **메일 엔진 코드**: JMAP 타입·호출은 `modules/mail/jmap/` 밖으로 내보내지 않는다. 받은 메일 HTML 은 `html/sanitize-mail-html.ts` 를 거친 뒤에만 내보낸다.
|
||||
보내는 서식 본문(`bodyHtml`)은 `html/sanitize-outgoing-html.ts` 를 거친 뒤에만 엔진에 넘긴다 — 편집기가 새 서식을 쓰게 되면 이 허용 목록과
|
||||
받은 메일 정화(다시 읽을 때 남아야 한다)를 함께 넓힌다 (계약 2절 "서식 본문").
|
||||
HTML 을 정화하는 경로는 먼저 `html/html-parse-budget.ts` 의 `checkHtmlParseBudget()` 을 부른다 — sanitize-html 이 쓰는 htmlparser2 는
|
||||
중첩 깊이의 제곱으로 느려져 2MB 한 통에 수 분 동안 이벤트 루프를 멈춘다 (2026-10-27 리뷰). 정규식·변환도 입력 길이에 비례하게 둔다.
|
||||
htmlparser2 판을 올리면 `html-parse-budget.spec.ts` 의 Parser 비교 테스트가 어긋남을 잡는다 — 실패하면 바뀐 Parser 규칙을 옮긴다.
|
||||
**엔진의 `Email/set destroy` 는 메일함과 상관없이 영구 삭제다** — 사용자 메일을 영구 삭제하는 경로(임시 저장 교체의 옛 초안 · 보낸 뒤 `draftId` ·
|
||||
휴지통에서 영구 삭제 · 4단계의 휴지통 · 스팸메일함 비우기)는 모두 `jmap-mail.provider.ts` 의 조건부 지우기를 쓴다: 지울 대상인지 확인(임시보관함에만 있는 사용자 초안 /
|
||||
휴지통(비우기는 그 메일함)에만 있는 메일 — 다른 메일함에도 있으면 그 메일함에서만 뺀다) → 확인한 상태 그대로일 때만 `ifInState` 로 지우기 → 어긋나면 다시 확인
|
||||
(한 통 · 여러 통은 합계 `CONDITIONAL_DESTROY_ATTEMPTS`, 비우기는 500통 차례마다 이어서 그만큼). 확인과 지우기 사이에
|
||||
다른 탭이 옮기거나 되돌린 메일까지 지우지 않게 하기 위해서다 (2026-10-27, Stalwart 0.16.27 이 `stateMismatch` 로 거절하는 것을 확인).
|
||||
`conditionalEmailSet` 은 `ifInState` 를 필수로 받는다 — 조건 없는 destroy 는 그 요청이 방금 만든 메일(실패한 저장의 되돌림 · 받는 사람이 모두 거절한 발송의 보낸 사본 — `discardQuietly`)에만, 따로 쓴다.
|
||||
같은 요청에서 만들기와 지우기를 함께 하지 않는다 — 엔진은 만들기가 실패해도 지우기를 한다.
|
||||
**메일함 지우기(`Mailbox/set destroy`)는 `onDestroyRemoveEmails: false` 만 쓴다** — `true` 는 안의 메일을 영구 삭제한다. 안의 메일은 먼저 휴지통으로 옮기고,
|
||||
그사이 들어온 메일이 있으면 엔진이 `mailboxHasEmail` 로 거절하므로 다시 옮긴다 (2026-10-28 확인).
|
||||
**Sieve 스크립트**(6단계 — 필터 · 부재중)는 `jmap/sieve-script.ts` 한 곳에서 만든다: 사용자 글은 `quote()` 만 거친다(`\` · `"` 이스케이프), `${` 는 받지 않는다
|
||||
(엔진이 require 와 상관없이 변수로 펼친다 — `%{…}` 는 펼치지 않는다), 부재중 제목은 RFC 2047 · 본문은 `vacation :mime` base64, 만든 글이 90,000바이트를 넘으면 `MAIL_013`.
|
||||
**스팸 판정은 엔진의 분류 표시**(`X-Spam-Score` 가 `spam` 으로 시작)로 한다 — 점수 문턱을 따로 두지 않는다(`spamtest` 는 점수를 정수로 내린 값이라 10 으로 막다가 5~10점 스팸을 놓쳤다, 2026-10-28 실엔진).
|
||||
날짜 비교는 `currentdate :zone "+0000"`. 엔진 오류의 설명 문장은 로그에 남기지 않는다(줄 · 칸만 — 만난 문자열에 규칙의 글이 들어 있다).
|
||||
원본 판(`FILTER_SOURCE_FORMAT`)을 올린 배포를 되돌리면 새 판으로 저장한 직원의 필터 · 부재중 저장이 503 `MAIL_001` 로 막힌다(옛 서버가 새 판을 지우지 않게) — 판을 올릴 때는 되돌리기 계획을 함께 둔다.
|
||||
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
|
||||
|
||||
@@ -18,14 +18,50 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 중앙 Axios 인스턴스 | `src/composables/useApi.ts` | baseURL=`VITE_API_BASE_URL`, `withCredentials` |
|
||||
| 응답 언래핑 인터셉터 | `useApi.ts` | `{success,data}→data`, 에러 코드 정규화(`ApiError`), 중복 알림 억제 |
|
||||
| 에러 코드 사전 | `src/shared/constants/error-codes.generated.ts` | **자동 생성 파일 — 직접 수정 금지** ([[error-codes]]) |
|
||||
| 세션 만료 처리 | `useApi.ts` 의 `toAuthCode()` | 로그인 상태에서 401 → `AUTH_002` 승격 + `authStore.clearAuth()` |
|
||||
| 세션 만료 처리 | `useApi.ts` 의 `refreshSession()` · 응답 인터셉터 | 로그인 상태에서 401 → `/auth/refresh` 한 번 시도 후 원 요청 재시도. 실패하면 `AUTH_002` 승격 + `authStore.expireSession()` → `components/feedback/SessionExpiredDialog.vue` (흐름: `docs/mail/api-contract.md` 1절). 갱신은 시작할 때 세션 세대(`authStore.generation`)를 잡고, 끝났을 때 바뀌었으면 성공이어도 버린다. 로그인은 진행 중인 갱신이 끝난 뒤(`settleRefresh()`)에 보낸다 |
|
||||
| 이동 전 세션 확인 | `useApi.ts` 의 `confirmSession()` | `<a href>` 다운로드처럼 axios 를 거치지 않는 이동 직전에 부른다 — me → refresh 1회 → 실패하면 세션 만료 |
|
||||
| 탭 간 로그아웃 | `src/composables/useLogoutBroadcast.ts` (`App.vue` 의 `useLogoutSync()`) | 로그아웃은 요청 **전에** BroadcastChannel 로 다른 탭에 알린다 — 받은 탭은 로그아웃으로 다루고 로그인 화면으로. 로그아웃 뒤 늦게 온 `/auth/refresh` 성공은 버리고 `/auth/logout` 을 한 번 더 부른다 (계약 1절) |
|
||||
| 요청 단위 알림 끄기 | `useApi.ts` 의 `silent` 요청 옵션 | `true` 또는 코드 배열 (`api.get(url, { silent: ['AUTH_001'] })`). 세션 만료 대화상자는 끄지 않는다. 끈 코드를 나중에 알림으로 알려야 하면(그 칸이 사라진 뒤 등) `notifyApiError()` — 같은 `showErrorUI()` 를 거친다 |
|
||||
| 페이지네이션 타입 | `src/shared/types/pagination.ts` | 목록 응답 공유 계약 ([[response-format]]) |
|
||||
| 도메인 Composable 예제 | `src/composables/useUser.ts` | 새 도메인 API 패턴 참고 |
|
||||
| 인증 Composable 예제 | `src/composables/useAuth.ts` | 로그인/로그아웃 흐름(쿠키 기반) 시작점 |
|
||||
| Pinia 스토어 예제 | `src/stores/user.store.ts` | 새 스토어 패턴 참고 |
|
||||
| 인증 스토어 | `src/stores/auth.store.ts` | 세션 인증 여부(`isLoggedIn`) 관리 |
|
||||
| 도메인 Composable 예제 | `src/composables/useUser.ts` · `useMail.ts` | 새 도메인 API 패턴 참고 (`useMail` 이 실제 모듈 예) |
|
||||
| 인증 Composable | `src/composables/useAuth.ts` | 로그인·로그아웃·`restoreSession()`(me 가 AUTH_001 이면 refresh 1회, 알림 없음) — 쿠키 기반. 비밀번호 바꾸기 `changePassword()`(4단계 — 한도 30초 `PASSWORD_CHANGE_TIMEOUT_MS`, 지금 비밀번호 틀림은 400 `AUTH_005` 라 갱신 · 재시도를 타지 않는다) |
|
||||
| Pinia 스토어 예제 | `src/stores/user.store.ts` · `mail.store.ts` | 새 스토어 패턴 참고 (`mail.store` 는 늦게 온 응답 무시 패턴 포함) |
|
||||
| 인증 스토어 | `src/stores/auth.store.ts` | 로그인 사용자(email·name) · 세션 복원(`restore`) · 만료 상태(`expireSession`) · 로그아웃 상태(`clearAuth()` 가 켜는 `signedOut` — 다시 로그인할 때까지 늦게 온 갱신·복원 결과를 쓰지 않는다) |
|
||||
| 공통 컴포넌트 예제 | `src/components/BaseButton.vue` | 재사용 컴포넌트 기준 패턴(타입 Props/Emits·scoped·rem·a11y) |
|
||||
| 라우터 | `src/router/index.ts` | lazy import + `auth.store` 연동 `requiresAuth` 가드 |
|
||||
| 라우터 | `src/router/index.ts` · 라우트 표 `src/router/routes.ts`(라우터와 라우트 테스트가 함께 쓴다) | lazy import + 첫 네비게이션 전 세션 복원 + `requiresAuth` 가드(미인증 → `{ name: 'login', query: { redirect } }`) |
|
||||
| 전역 셸 | `src/components/shell/AppShell.vue` | 앱 레일 + 상단 바 + 모듈 영역. 레일 모듈 목록은 `src/shared/constants/app-modules.ts` 하나 |
|
||||
| 디자인 토큰·글꼴 | `src/styles/tokens.css` · `src/styles/base.css` | 색·간격·글자 크기 변수. 글꼴은 npm `pretendard` 자체 호스팅(`main.ts` 에서 import — CSP `font-src 'self'`) |
|
||||
| 브랜드 자산 | `src/assets/brand/` · `src/shared/constants/brand.ts` | 흰 로고(전체·ARA 마크). 원본은 `docs/brand/` |
|
||||
| 토스트 | `src/stores/toast.store.ts` · `src/components/feedback/ToastHost.vue` | 오류는 `useApi.ts` 의 `showErrorUI()`, 성공 안내는 `composables/useToast.ts`(API 가 아닌 화면 쪽 실패 — 예: 브라우저가 알림을 막음 · 보냈지만 받는 사람 일부가 받지 못함(+ [보낸편지함 보기], 문장은 `shared/utils/mail-send-result.ts`) — 는 `useToast().error()`). 알림 안의 단추는 `{ label, run }`(예: 임시 저장본 [되돌리기]), 이동 링크는 지금처럼. 단추가 있는 알림은 10초(`TOAST_ACTION_DURATION_MS`), 마우스 · 초점이 있는 동안은 `hold`·`release` 로 멈춘다. **단추는 띄운 세션에서만 움직인다** — `useToast` 가 `authStore.generation` 에 묶고, 로그아웃 · 세션 만료 · 다른 계정 로그인 때 단추가 있는 알림을 닫는다(메일 id 는 계정마다 겹칠 수 있다). 오래 걸리는 처리는 **진행 알림** `useToast().progress(글)` — 저절로 닫히지 않고(넘쳐도 다른 알림부터 닫는다) 글을 고치다가(`update`) 끝나면 그 자리에서 결과가 된다(`succeed` · `fail` — `toast.store` 의 `update`). 진행 알림도 세션이 바뀌면 닫는다 |
|
||||
| 모달 대화상자 | `src/components/feedback/ModalDialog.vue` | 네이티브 `<dialog>`(초점·Esc·닫힘 처리)와 모양. 세션 만료(`SessionExpiredDialog`)·나가기 확인(공용 `components/feedback/LeaveConfirmDialog.vue` — 쓰기 화면 `ComposeLeaveDialog.vue`·서명 설정 · 부재중 설정이 쓴다) · 메일함 대화상자(`components/mail/Mailbox*Dialog.vue` — 기본 슬롯에 이름 칸) · 필터 고치기 창(`components/settings/MailFilterDialog.vue` — 칸이 많은 창은 `wide`: 폭 620)이 쓴다 — 새 대화상자도 이것으로 만든다. 되돌릴 수 없는 일의 확인은 처음 초점을 「취소」에 둔다. 초점을 지키려고 `aria-disabled` 로 막은 단추에는 마우스를 올린 모양이 걸리지 않는다 |
|
||||
| 외부 HTML 격리 렌더링 | `src/components/mail/MailBodyFrame.vue` · `src/shared/utils/mail-html.ts` | 메일 본문 등 외부 HTML 은 이 컴포넌트로만 그린다 (sandbox·내부 CSP 근거는 파일 머리말). 보여 줄 HTML 고르기는 `displayedHtmlBody()` 하나다 — null 이거나 공백뿐이면 textBody (읽기 화면·인용 공용, 서버의 `isBodyTruncated` 와 같은 규칙) |
|
||||
| 메일 모듈 | `src/pages/mail/*` · `src/components/mail/*` | API 계약 `docs/mail/api-contract.md`, 디자인 원본 `docs/design/webmail/` |
|
||||
| 콘텐츠 열 토큰 | `src/styles/tokens.css` 의 `--content-*` · `--content-toolbar-start` | 읽기·쓰기 화면이 같은 최대 폭·여백·32px 시작선을 쓴다 (새 화면도 이 토큰으로) |
|
||||
| 밀도 예외 크기 | `src/styles/tokens.css` 의 `--size-compact-control` · `--size-segment-item` · `--size-segment-item-lg` · `--size-editor-tool` · `--size-editor-menu-item` · `--size-editor-swatch-cell` · `--size-editor-table-cell` · `--table-col-check` · `--size-row-check` · `--size-search-clear-width`/`-height` · `--size-notice-bar` · `--size-mailbox-more` · `--size-notice-action` · `--size-filter-control` · `--size-filter-remove` (주석에 "밀도 예외") | 클릭 영역 44px 기준의 의도된 예외(목록 표의 별·보기 전환, 설정 화면의 미리 보기 전환 36(`.segmented`)·서명 그림 크기 줄 36(작게·보통·원본·그림 빼기), 편집기 툴바 버튼 40×44·메뉴 항목 40·색 견본 40·표 고르기 칸 22, 3단계의 목록 표 고르기 칸 32·분할 행 고르기 칸 폭 36·검색 칸 ✕ 40×38·새 메일 알림 줄 40·검색 범위 전환 36·이동 메뉴 항목 40, 4단계의 메일함 행 ⋯ 32×40 · ⋯ 메뉴 항목 40, 5단계의 목록 위 알림 권하기 줄 40 · 줄 안의 단추 36, 6단계의 필터 조건 칸 40 · 조건 ✕ 36 · 필터 행 ▲▼ 32×40 · 켜기 스위치 누르는 곳 40×44 · 고치기 창 안 [다시 불러오기] 36)에만 쓴다 — 사용자 확정(캔버스). 그 밖의 `--size-editor-*`(툴바 높이·글꼴 단추·본문 최소 높이 등)는 예외가 아니라 보통 크기다 |
|
||||
| 사용자별 화면 설정 | `src/stores/mail-view.store.ts` | localStorage(키에 사용자 email, try/catch, 실패 시 기본값). 서버 설정 API 가 생기면 교체 |
|
||||
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
|
||||
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) |
|
||||
| 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 |
|
||||
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`) |
|
||||
| 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
|
||||
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(초성·자모만 친 글자는 묻지 않는다, 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
|
||||
| 메일 서명 | `src/stores/mail-signature.store.ts` · `components/mail/editor/signature/` · `shared/utils/mail-signature.ts` · `pages/settings/` | 서명은 메일 화면에 들어올 때 받아 두고 쓰기 화면이 넣는다(새 메일은 끝, 답장·전달은 인용 위 — 이어 쓰기는 다시 넣지 않는다). 설정 화면(`SettingsLayout.vue` 왼쪽 메뉴 — 묶음 · 항목은 `shared/constants/settings-menu.ts` + `MailSignaturePage.vue`)은 들어올 때마다 새로 받는다. 미리 보기는 편집기 스키마의 `DOMSerializer` 로 그린다. 그림 3장은 서명 칸 안의 그림 수로(서버 보관 상한 10장은 서버가 센다) |
|
||||
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
|
||||
| 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" |
|
||||
| 실시간 새 메일 (5단계) | `src/composables/useMailRealtime.ts`(`App.vue` 가 시작 — 로그인한 동안 앱 전체) · `stores/mail-realtime.store.ts` · `shared/utils/sse-parser.ts` · `mail-events.ts` · `mail-realtime.ts` · `browser-support.ts` | **브라우저마다 연결 하나** — Web Locks 잠금(이름에 사용자 주소)을 받은 탭만 `GET /api/mail-events` 를 읽고 BroadcastChannel 로 나눈다(메시지에 사용자 주소, 다른 계정 것은 버린다). `fetch` 로 읽는다 — `EventSource` 는 401 을 몰라 쓰지 않는다. 75초 무소식 감시는 요청 시작부터, 401 → 세션 갱신 한 번(그 바로 뒤의 시도만 "갱신 직후"), 다시 잇기 2 · 5 · 15 · 30 · 60초(±20%) — 간격은 연결이 **자리 잡은 뒤**(정상 ping 또는 20초)에만 처음부터 다시 세고, 자리 잡기 전의 `end` 는 실패처럼 기다린다(열리자마자 끝나는 연결에서 갱신 폭주를 막는다). 메일 화면(`useMailStatePolling`)은 이 소식을 받아 연결이 살아 있는 동안 1분 확인을 멈추고, `state` 가 바뀌면 보이는 탭은 맞추기(1초 모아서 — `MAIL_REALTIME_SYNC_DELAY_MS`) · 숨은 탭은 `inboxUnread` 로 탭 제목만(`setInboxUnread`) — 계약 2절 "실시간 알림" |
|
||||
| 바탕화면 알림 (5단계) | `src/composables/useNewMailNotifier.ts` · `useDesktopNotification.ts` · `stores/mail-notify.store.ts`(브라우저 · 사용자마다 localStorage) · `shared/utils/desktop-notify.ts` · 목록 위 권하기 `components/mail/MailNotifyOffer.vue` · 설정 `pages/settings/MailNotifyPage.vue`(+ `components/settings/NotifySettingsForm.vue` · `RealtimeLinkStatus.vue`) · 아이콘 `assets/brand/notification-icon.png` · 문구 `shared/constants/mail-notify.ts` | 연결한 탭이 `new-mail` 을 받고 설정이 켜져 있고 허락됐고 **웹메일을 보고 있는 탭이 없을 때만**(보이고 초점이 있음 — `visibilityState === 'visible' && document.hasFocus()`, 다른 탭에 짧게 묻는다) 띄운다 — tag 하나로 앞 알림을 바꿔 쓰고, 사용자가 돌아오면 닫는다. 허락을 묻는 동안 단추는 `disabled` 가 아니라 `aria-disabled`(초점을 잃지 않게). 허락은 사용자가 누른 뒤에만(`Notification.requestPermission()`). 누르면 그 메일로(`useMailNavigation` 의 `openFromNotification` — 세션이 바뀌었으면 이동하지 않는다) |
|
||||
| 실시간 테스트 도구 | `src/test-utils/event-stream.ts`(가짜 SSE 서버) · `notification.ts`(가짜 Notification) · `web-locks.ts`(가짜 `navigator.locks` · BroadcastChannel) | 실시간 · 알림 테스트는 이것들과 손으로 돌리는 시계(`manual-clock.ts`)로 — 진짜 타이머 · 고정 대기 없이 |
|
||||
| 메일 검색 | `src/components/mail/MailSearchBox.vue`(상단 바의 search 슬롯) · `composables/useMailSearchNavigation.ts` · `MailSearchHeader.vue` · `MailSearchScope.vue` · `MailSearchEmpty.vue` · 라우트 `/mail/search` · `/mail/search/:messageId` | Enter 로만 찾는다(`isImeComposing()` 이면 무시), ✕ · Esc 는 검색을 시작한 메일함으로. 주소 `?q=…&in=<메일함 id \| all>` — 검색 화면 안의 다시 찾기 · 범위 바꾸기는 replace. 서버와 같은 한도(200자 · 낱말 8개)를 먼저 본다. 조사 처리 · 한계는 서버 몫(계약 2절 "검색") |
|
||||
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다) — 계약 2절 "여러 통 한꺼번에" |
|
||||
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계)), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다 — 계약 1절 · 2절 "부재중 자동 응답" |
|
||||
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
|
||||
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
|
||||
| 메일 필터 (6단계) | `src/pages/settings/MailFilterPage.vue` · `components/settings/MailFilter*.vue`(목록 · 행 · 목록 위 줄 `MailFilterNotice` · 빈 상태 · 안내 상자 `MailFilterNotes` · 고치기 창 `MailFilterDialog` + 조건 `MailFilterConditionFields` · `MailFilterConditionRow` · 할 일 `MailFilterActionFields`) · `mail-filter-ui.ts` · `stores/mail-filter.store.ts` · `composables/useMailFilterActions.ts`(켜기 · 순서 · 지우기 · 되돌리기 알림) · `useMailFilterDialog.ts`(창 상태) · `shared/utils/mail-filter.ts`(검사 · 요약 · 옮길 곳 · 되돌릴 자리) · `shared/constants/mail-filter.ts` · 읽기 화면 ⋯ 「이런 메일로 필터 만들기」(`MessageMoreMenu.vue` → `/settings/mail/filters?from=`) | 바꾸는 일은 모두 `PUT /api/mail-filters` 전체 교체(받은 `version`)이고 **저장은 한 번에 하나**(`saving`). 켜기 · 순서는 화면을 먼저 바꾸고 실패하면(그사이 목록이 안 바뀌었으면) 되돌린다. 창의 저장은 그 순간 목록의 켜짐 값을 쓴다. [되돌리기]는 지운 필터만 앞 · 뒤 필터 기준 자리에 다시 넣고, 누른 차례대로 앞 저장이 끝난 뒤 보낸다(`restoreQueue` — 기다림과 `send()` 사이에 await 를 두지 않는다 — 2026-10-28 리뷰의 경합). `MAIL_012` 는 목록 위 줄 + 창 안 안내와 [다시 불러오기](쓴 내용을 지키고 다시 받은 목록 위에 저장), 늦게 온 409 는 보낼 때의 순번(seq)이 그대로일 때만 줄을 켠다. 검사는 서버와 같은 규칙(전달 주소의 @ 앞은 dot-atom · 64자로 서버보다 좁게)이고 거절은 칸 아래(`VAL_001` · `MAIL_013` 은 알림을 끈다). 필터 화면이 떠 있지 않을 때 실패한 되돌리기는 알림으로(`attachPage` · `pageVisible`). 세션이 바뀌면 늦은 응답을 버린다(`authStore.generation`) — 계약 2절 "메일 필터", 디자인 README "6단계" |
|
||||
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
|
||||
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인") |
|
||||
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
|
||||
| 쓰기 칸 포커스 | `src/styles/base.css` 의 `.compose-field` | 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)은 아래 선 하나로 포커스를 보인다. 새 입력 행에도 이 클래스를 붙인다 — 칸마다 포커스 규칙을 따로 만들지 않는다 |
|
||||
| 테두리 입력칸 | `src/styles/base.css` 의 `.form-input` · `.form-input__control` · `.form-message` | 설정 화면 · 메일함 이름 대화상자의 테두리 있는 칸(44)과 칸 아래 안내 · 오류(12.5px). 칸 안에 단추가 있는 상자(비밀번호 보기)는 상자에 `.form-input`, 입력 요소에 `.form-input__control` — 입력 요소에 초점이 있을 때만 상자에 초점을 보인다. 오류는 입력 요소의 `aria-invalid="true"`. 쓰기 화면의 밑줄 칸(`.compose-field`)과는 다른 규칙이다 |
|
||||
| env 타입 선언 | `frontend/env.d.ts` | `VITE_API_BASE_URL` |
|
||||
| dev API 프록시 | `vite.config.ts` 의 `server.proxy` | `/api` → 백엔드. 없으면 vite 가 SPA 폴백으로 **HTML 을 200 으로** 돌려줘 조용히 실패한다 |
|
||||
| dev 서버 포트 | `vite.config.ts` 의 `loadEnv` | 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 를 읽는다. `strictPort` 로 포트 충돌 시 fail-fast ([[env-structure]]) |
|
||||
@@ -36,8 +72,8 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
> 두 포트 모두 `vite.config.ts` 가 **루트 `.env.{mode}` 에서 직접 읽는다** — 프론트 쪽에 포트를 중복 작성하지 않는다.
|
||||
|
||||
> **확장 시 따르는 규칙:**
|
||||
> - **로그인 연동**: 실제 `/auth/login` 엔드포인트 연결 → 성공 시 `useAuth` 가 `authStore.markAuthenticated()` 호출
|
||||
> - **로그인 페이지**: 추가 시 라우터 가드 리다이렉트를 `{ name: 'login' }` 으로 변경
|
||||
> - **새 사내 모듈**(캘린더·파일·ERP 등): `app-modules.ts` 에 항목을 활성화하고, 라우트 + `AppShell` 안의 모듈 패널을 메일 모듈처럼 만든다. 셸을 새로 만들지 않는다.
|
||||
> - **새 화면**: 구현 전에 디자인 캔버스로 먼저 확인받고, 확정본을 `docs/design/` 에 둔 뒤 그 기준으로 만든다. 뱃지(안 읽음 점·개수 알약·태그)는 쓰지 않는다.
|
||||
> - **공통 컴포넌트**: `BaseButton` 패턴을 따라 `src/components/` 에 추가
|
||||
|
||||
---
|
||||
@@ -146,7 +182,7 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
- Toast 라이브러리를 도입하면 `useApi.ts` 의 `showErrorUI()` **한 함수만** 교체한다.
|
||||
- 알림 UI 는 `useApi.ts` 의 `showErrorUI()` 가 토스트(`toast.store`)로 띄운다. 다른 UI 로 바꿀 때도 **이 함수 하나만** 교체한다.
|
||||
|
||||
```typescript
|
||||
// composables/useUser.ts — 도메인별 API Composable
|
||||
@@ -219,11 +255,24 @@ router.beforeEach((to) => {
|
||||
- [ ] **컴포넌트**: `<script setup lang="ts">` + `<style scoped>` + rem 단위. 인라인 스타일·Options API 금지.
|
||||
- [ ] **타입**: Props/Emits 는 인터페이스로 정의.
|
||||
- [ ] **라우트**: 페이지는 lazy import, 보호 라우트는 `meta.requiresAuth`.
|
||||
- [ ] **auth 가드**: `router/index.ts` 가드는 `auth.store.ts` 의 `isLoggedIn` 과 이미 연결되어 있다. 로그인 페이지 추가 시 리다이렉트 대상을 `{ name: 'login' }` 으로 바꾼다.
|
||||
- [ ] **auth 가드**: `router/index.ts` 가드는 첫 네비게이션 전에 세션을 복원한 뒤 `isLoggedIn` 으로 판정하고, 미인증은 `{ name: 'login', query: { redirect } }` 로 보낸다. 새 보호 화면은 `meta.requiresAuth` 만 붙인다.
|
||||
- [ ] **외부 HTML**: 메일 본문처럼 외부에서 온 HTML 은 `MailBodyFrame` 으로만 그린다. `v-html` 로 직접 넣지 않는다.
|
||||
외부 HTML 을 고쳐 쓸 때(붙여 넣기·원문 인용 다듬기)는 `<template>`·`DOMParser` 같은 **떨어진 문서 안에서 끝내고** `template.innerHTML` 로 직렬화한다
|
||||
(`mail-serialize.ts` 의 `rewriteInert()`). 화면 문서의 요소로 옮기면(`append`) 입양되면서 `img onerror` 같은 처리기가 실행된다 —
|
||||
운영 CSP 가 막아도 그것에만 기대지 않는다 (2026-10-27 사례, 회귀 테스트는 `mail-serialize.spec.ts`).
|
||||
편집기로 읽어 들이는 서식은 **서버가 남기는 형식만** 받는다 (`outgoing-html-rules.ts`) — 서버만 지우면 편집기에서 숨은 글(`font-size:0` 등)이
|
||||
받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다 (2026-10-27 리뷰).
|
||||
이어 쓰는 임시 저장본의 `bodyHtml`·서명 HTML 도 같은 경로로 편집기에 옮긴다 (다른 메일 프로그램이 만든 초안도 있다).
|
||||
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test:unit` 통과. 자동 수정은 `npm run lint:fix`.
|
||||
테스트 시간 한도는 전역 20초(`vitest.config.ts` — jsdom·편집기 테스트가 부하에서 5~13초)이고, 긴 초안을 맞추는 테스트는 `src/test-utils/timeouts.ts` 의 한도를 쓴다.
|
||||
"몇 ms 안에 끝난다"를 검사하는 성능 단언은 벽시계 대신 계산량(CPU 시간·비용)으로 잰다 — 벽시계는 다른 작업이 많으면 몇 배로 늘어
|
||||
CI 에서 흔들린다 (2026-10-27 사례, backend 는 `backend/test/cpu-time.ts`).
|
||||
- [ ] **린트로 강제되는 항목**: Options API·인라인 스타일·`console`·`any` 는 빌드가 실패한다.
|
||||
- [ ] **env**: `VITE_API_BASE_URL` 은 빌드 시 루트 `BACKEND_API_URL` 에서 주입된다 — `frontend/.env` 에 별도 작성 불필요.
|
||||
- [ ] **env**: `VITE_API_BASE_URL` 은 운영 빌드에서 `/api` 로 고정된다 (같은 주소 배포 — 운영은 프론트 nginx, 개발은 vite 프록시가 `/api` 를 백엔드로 넘긴다).
|
||||
`frontend/.env` 에 별도 작성 불필요 — 다른 주소를 넣으면 쿠키(Path=/api)·CSP `connect-src 'self'`·출처 검사가 맞지 않는다 ([[docker]] "같은 주소 배포").
|
||||
- [ ] **dev 서버**: 프론트엔드는 호스트에서 `npm run dev` 로 띄운다 (컨테이너는 `--profile frontend` 선택, [[docker]]).
|
||||
- [ ] **dev 화면이 운영 빌드와 다를 때**(여백·폭이 통째로 사라지는 등): dev 서버가 전역 CSS(`styles/tokens.css`)의 변경을 놓쳐 옛 토큰을 내보내는 경우가 있었다.
|
||||
`curl -s localhost:5173/src/styles/tokens.css` 에 새 변수가 없으면 dev 서버를 재시작하고 브라우저를 새로고침한다 (2026-10-26 사례 — 코드 문제 아님).
|
||||
- [ ] **포트**: `vite.config.ts` 에 포트를 새로 하드코딩하지 않는다 — 루트 `.env.{mode}` 의 `FRONTEND_PORT`/`BACKEND_PORT` 가 SSOT ([[env-structure]]).
|
||||
- [ ] **dev 에서 API 가 안 될 때**: 응답이 JSON 이 아니라 HTML 이면 vite 프록시(`server.proxy`) 문제다.
|
||||
`baseURL` 이 `/api` 상대경로라 프록시가 없으면 index.html 이 200 으로 돌아온다.
|
||||
|
||||
@@ -20,6 +20,18 @@ WEBSOCKET_PORT=3001
|
||||
DB_PORT=5432
|
||||
REDIS_PORT=6379
|
||||
|
||||
# ==========================================
|
||||
# 내부 네트워크 (compose app-network · edge)
|
||||
#
|
||||
# 서브넷을 고정한다 — 메일 엔진은 app-network 대역만 신뢰한다 (게이트웨이 로그인·자동 차단 예외, mail/README.md).
|
||||
# APP_NETWORK_SUBNET : backend·db·redis·mail(·mailpit) — 사설 대역, /24 ~ /28 (mail setup 이 검사한다)
|
||||
# EDGE_NETWORK_SUBNET : frontend ↔ backend 전용 (frontend 는 app-network 에 붙지 않는다)
|
||||
# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다.
|
||||
# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다.
|
||||
# ==========================================
|
||||
APP_NETWORK_SUBNET=172.16.71.0/24
|
||||
EDGE_NETWORK_SUBNET=172.16.72.0/24
|
||||
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
# ==========================================
|
||||
@@ -37,10 +49,16 @@ REDIS_PASSWORD=change-me-in-local-env
|
||||
REDIS_DATA_DIR=./data/redis
|
||||
|
||||
# ==========================================
|
||||
# API 경로 설정
|
||||
# 화면·API 주소 — 같은 주소(origin) 배포 (docs/mail/api-contract.md 머리말)
|
||||
#
|
||||
# 브라우저는 화면과 같은 주소의 /api 를 부른다 (개발은 vite 프록시, 운영은 프론트 nginx 가 백엔드로 넘긴다).
|
||||
# - CORS_ORIGIN : 화면의 주소(origin). 상태 변경 요청의 출처 검사에 쓴다 — FRONTEND_PORT 를 바꾸면 함께 고칠 것
|
||||
# - BACKEND_API_URL : 화면 주소 + /api. 앱은 쓰지 않고, 운영 기동 때 공개 주소인지 검사하는 데만 쓴다
|
||||
# - TRUST_PROXY : 백엔드 앞 프록시 홉 수. 개발은 vite 프록시 하나라 1 (비우면 1)
|
||||
# ==========================================
|
||||
BACKEND_API_URL=http://localhost:3000/api
|
||||
BACKEND_API_URL=http://localhost:5173/api
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
TRUST_PROXY=1
|
||||
|
||||
# ==========================================
|
||||
# Rate Limiting (API 남용 방지)
|
||||
@@ -61,21 +79,82 @@ DB_SYNCHRONIZE=false
|
||||
# ==========================================
|
||||
# 선택적 서비스 (Compose profiles)
|
||||
#
|
||||
# 기본값(비어 있음) = redis 와 frontend 컨테이너를 기동하지 않는다.
|
||||
# 기본값 = mail — 메일 엔진(Stalwart)과 개발용 Mailpit 을 띄운다.
|
||||
# 직원 로그인이 메일 엔진에 의존하므로 개발에서도 켜 두는 것을 권장한다.
|
||||
# (redis 와 frontend 컨테이너는 기동하지 않는다)
|
||||
#
|
||||
# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=redis
|
||||
# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=frontend
|
||||
# 둘 다 → COMPOSE_PROFILES=redis,frontend
|
||||
# mail 메일 엔진 + Mailpit (기본) → COMPOSE_PROFILES=mail
|
||||
# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=mail,redis
|
||||
# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=mail,frontend
|
||||
#
|
||||
# 일회성으로 켜려면: docker compose --profile redis ... up -d
|
||||
#
|
||||
# ⚠️ 개발 환경의 프론트엔드는 호스트에서 띄우는 것이 기본이다 (`cd frontend && npm run dev`).
|
||||
# 컨테이너 vite 는 바인드 마운트 때문에 첫 서빙까지 수 분이 걸린다.
|
||||
# ==========================================
|
||||
COMPOSE_PROFILES=
|
||||
COMPOSE_PROFILES=mail
|
||||
|
||||
# ==========================================
|
||||
# 파일 업로드 (로컬 디스크)
|
||||
#
|
||||
# UPLOAD_DATA_DIR 는 개발 전용 — 호스트 디렉터리에 바로 쌓여 열어 볼 수 있다 (운영은 named volume <PROJECT_NAME>_uploads).
|
||||
# 리눅스 호스트는 처음 한 번 mkdir -p data/uploads (없으면 도커가 root 소유로 만들어 backend 가 쓰지 못한다)
|
||||
# ==========================================
|
||||
UPLOAD_DATA_DIR=./data/uploads
|
||||
UPLOAD_MAX_SIZE_MB=5
|
||||
|
||||
# ==========================================
|
||||
# 메일 엔진 (Stalwart) — profile `mail`
|
||||
#
|
||||
# 직원 로그인 = 메일 계정. 백엔드는 내부 JMAP 으로 메일 엔진에 붙는다.
|
||||
# 처음 띄운 뒤 한 번 실행한다 (여러 번 실행해도 안전하다):
|
||||
# node --env-file=.env.development mail/scripts/setup.mjs
|
||||
# 자세한 내용: mail/README.md
|
||||
#
|
||||
# 개발 도메인은 외부와 섞이지 않는 예약 TLD(.test)를 쓴다.
|
||||
# ⚠️ 약한 비밀번호는 Stalwart 의 강도 정책이 거부한다. 복사 후 무작위 값으로 교체할 것.
|
||||
# ⚠️ 비밀번호는 영문·숫자로만 만든다. # · 따옴표 · $ · 공백은 compose 와 Node(--env-file)가
|
||||
# 서로 다르게 읽어(Node 는 # 부터 주석으로 자른다) 컨테이너와 setup 스크립트의 값이 어긋난다.
|
||||
# ==========================================
|
||||
# 패치까지 고정한다 — setup 스크립트가 0.16 의 동작(관리 API·bootstrap)에 맞춰져 있다 (docker 스킬 "이미지 버전 고정")
|
||||
MAIL_IMAGE=stalwartlabs/stalwart:v0.16.27
|
||||
MAIL_DOMAIN=arasystem.test
|
||||
# 서버 호스트명 — 최초 bootstrap 때만 반영된다 (이후 변경은 관리 화면에서)
|
||||
MAIL_HOSTNAME=mail.arasystem.test
|
||||
# 관리 화면·setup 스크립트용 HTTP 포트 (127.0.0.1 로만 열린다)
|
||||
MAIL_ADMIN_PORT=8180
|
||||
# JMAP·OAuth 디스커버리 기준 주소. 개발은 관리 화면 주소 — MAIL_ADMIN_PORT 를 바꾸면 함께 고칠 것
|
||||
MAIL_PUBLIC_URL=http://localhost:8180
|
||||
# 영구 관리자 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 admin) — setup 스크립트가 이 이름으로 관리자를 만들고,
|
||||
# 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 (두 쪽이 이 값 하나를 읽는다).
|
||||
# ⚠️ bootstrap 전에만 정할 수 있다. bootstrap 뒤에 이 값만 바꾸면 엔진 관리자는 그대로 남고 백엔드는 새 이름만 막게 되므로,
|
||||
# setup 이 중단한다 (되돌리는 절차: mail/README.md "문제 해결"). 옮겨 올 메일함에 같은 주소가 있으면 다른 이름으로 정한다.
|
||||
# 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (백엔드와 같은 규칙)
|
||||
MAIL_ADMIN_LOCAL_PART=admin
|
||||
# 영구 관리자 비밀번호 — setup 스크립트가 이 값으로 맞춘다
|
||||
MAIL_ADMIN_PASSWORD=change-me-in-local-env
|
||||
# 최초 기동(bootstrap)용 임시 관리자 "아이디:비밀번호". ⚠️ 값이 있는 동안은 정상 모드에서도 관리자로 로그인된다
|
||||
# (개발은 127.0.0.1 에만 열려 있어 이 파일에 둔다 — 운영은 파일에 두지 않고 일회성 override 로만 쓴다)
|
||||
MAIL_RECOVERY_ADMIN=admin:change-me-in-local-env
|
||||
# 백엔드가 직원 메일함을 대리 접근(impersonate)할 때 쓰는 서비스 계정 (주소 전체).
|
||||
# ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다
|
||||
MAIL_GATEWAY_USER=mail-gateway@arasystem.test
|
||||
MAIL_GATEWAY_PASSWORD=change-me-in-local-env
|
||||
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망). 보통 바꿀 일이 없다
|
||||
MAIL_JMAP_URL=http://mail:8080
|
||||
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다
|
||||
# (그 앞에 외부 리버스 프록시를 두면 그쪽 한도도 맞춘다 — docker 스킬 "운영 환경")
|
||||
MAIL_ATTACHMENT_MAX_SIZE_MB=20
|
||||
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다
|
||||
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25
|
||||
# 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분).
|
||||
# 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다
|
||||
MAIL_TRUSTED_NETWORKS=
|
||||
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
|
||||
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
|
||||
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.test
|
||||
# 개발용 테스트 직원 계정(ara.kim@ 등)의 공통 비밀번호 — 개발 환경에서만 만들어진다
|
||||
MAIL_DEV_USER_PASSWORD=change-me-in-local-env
|
||||
# 개발 전용 메일 수집기 — 사내 도메인 밖으로 보낸 메일이 여기에만 쌓인다
|
||||
MAILPIT_IMAGE=axllent/mailpit:v1.32.0
|
||||
MAILPIT_UI_PORT=8025
|
||||
|
||||
+97
-14
@@ -16,6 +16,19 @@ WEBSOCKET_PORT=3001
|
||||
DB_PORT=5432
|
||||
REDIS_PORT=6379
|
||||
|
||||
# ==========================================
|
||||
# 내부 네트워크 (compose app-network · edge)
|
||||
#
|
||||
# 서브넷을 고정한다 — 메일 엔진은 app-network 대역만 신뢰한다 (게이트웨이 로그인·자동 차단 예외, mail/README.md).
|
||||
# APP_NETWORK_SUBNET : backend·db·redis·mail — 사설 대역, /24 ~ /28 (mail setup 이 검사한다)
|
||||
# EDGE_NETWORK_SUBNET : frontend(nginx) ↔ backend 전용. 인터넷을 향한 frontend 는 app-network 에 붙지 않는다.
|
||||
# TRUST_PROXY 를 주소 목록으로 쓸 때 이 대역을 넣는다 (아래)
|
||||
# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다.
|
||||
# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다.
|
||||
# ==========================================
|
||||
APP_NETWORK_SUBNET=172.16.71.0/24
|
||||
EDGE_NETWORK_SUBNET=172.16.72.0/24
|
||||
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
# ==========================================
|
||||
@@ -33,21 +46,31 @@ REDIS_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
REDIS_DATA_DIR=./data/redis
|
||||
|
||||
# ==========================================
|
||||
# API 경로 설정
|
||||
# 화면·API 주소 — 같은 주소(origin) 배포 (docs/mail/api-contract.md 머리말)
|
||||
#
|
||||
# ⚠️ 운영에서는 **반드시 실제 공개 도메인**으로 교체한다.
|
||||
# - BACKEND_API_URL : 브라우저가 호출할 API 주소 (프론트엔드 번들에 그대로 박힌다)
|
||||
# - CORS_ORIGIN : 백엔드가 허용할 오리진 (scheme+host[+port], 여러 개면 콤마 구분)
|
||||
# 브라우저는 포털 주소의 /api 를 부르고 프론트 nginx 가 백엔드로 넘긴다. 운영의 입구는 프론트 nginx 하나다.
|
||||
# - CORS_ORIGIN : 화면의 공개 주소(origin, 여러 개면 콤마). 상태 변경 요청의 출처 검사에 쓴다 — 운영 필수
|
||||
# - BACKEND_API_URL : 화면 주소 + /api. 앱은 쓰지 않고, 운영 기동 때 공개 주소인지 검사하는 데만 쓴다
|
||||
#
|
||||
# 아래 example.com 이나 localhost 가 남아 있으면 **백엔드가 기동에 실패한다**
|
||||
# localhost 나 example.com 이 남아 있으면 **백엔드가 기동에 실패한다**
|
||||
# (backend/src/shared/config/env.validation.ts — fail-fast).
|
||||
# 기동은 되는데 브라우저만 조용히 실패하는 상황을 막기 위한 검사다.
|
||||
#
|
||||
# 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만
|
||||
# ALLOW_LOCALHOST_URL=true 로 이 검사를 건너뛴다. 실제 배포에는 설정하지 않는다.
|
||||
#
|
||||
# - TRUST_PROXY : 백엔드 앞 프록시 — 실제 클라이언트 IP(로그인 실패 한도의 기준)를 읽는다. 운영 필수
|
||||
# 이 compose 의 운영 경로는 클라이언트 → 외부 리버스 프록시(TLS) → 프론트 nginx → 백엔드 하나뿐이다.
|
||||
# (프론트 nginx 는 호스트 포트를 열지 않고 TLS 도 하지 않으므로 외부 프록시 없이는 들어올 수 없다 — 1 은 해당 없음)
|
||||
# 2 = 홉 수. proxy 네트워크를 이 프로젝트의 외부 프록시만 쓸 때
|
||||
# 주소 목록 = <EDGE_NETWORK_SUBNET>,<외부 프록시 IP> (예: 172.16.72.0/24,172.20.0.10) — proxy 네트워크를
|
||||
# 다른 프로젝트와 함께 쓴다면 이쪽을 권장한다. 홉 수는 "몇 번째 주소"만 믿어서, 같은 네트워크의 다른
|
||||
# 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면 X-Forwarded-For 를 꾸며 IP 를 바꿀 수 있다.
|
||||
# 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안 주소)이며 고정해 둔다(ipv4_address 등).
|
||||
# 넓은 사설 대역을 통째로 넣지 않는다.
|
||||
# true·0·false 는 운영에서 기동이 거부된다.
|
||||
# ==========================================
|
||||
BACKEND_API_URL=https://api.example.com/api
|
||||
CORS_ORIGIN=https://app.example.com
|
||||
BACKEND_API_URL=https://portal.arasystem.it.kr/api
|
||||
CORS_ORIGIN=https://portal.arasystem.it.kr
|
||||
TRUST_PROXY=2
|
||||
|
||||
# ==========================================
|
||||
# Rate Limiting (API 남용 방지)
|
||||
@@ -68,15 +91,75 @@ DB_SYNCHRONIZE=false
|
||||
# ==========================================
|
||||
# 선택적 서비스 (Compose profiles)
|
||||
#
|
||||
# 기본값(비어 있음) = redis 를 기동하지 않는다.
|
||||
# 캐시·세션 공유가 필요해지면 아래를 redis 로 바꾸면 컨테이너가 함께 뜬다.
|
||||
# COMPOSE_PROFILES=redis
|
||||
# 기본값 = mail — 메일 엔진(Stalwart)을 띄운다. 직원 로그인이 메일 엔진에 의존한다.
|
||||
# (redis 는 기동하지 않는다)
|
||||
# 캐시·세션 공유가 필요해지면 redis 를 추가하면 컨테이너가 함께 뜬다.
|
||||
# COMPOSE_PROFILES=mail,redis
|
||||
# 일회성으로 켜려면: docker compose --profile redis ... up -d
|
||||
# ==========================================
|
||||
COMPOSE_PROFILES=
|
||||
COMPOSE_PROFILES=mail
|
||||
|
||||
# ==========================================
|
||||
# 파일 업로드 (로컬 디스크)
|
||||
#
|
||||
# 저장 위치는 named volume <PROJECT_NAME>_uploads 다 (docker-compose.yml). 호스트 디렉터리를 쓰지 않는다 —
|
||||
# sudo docker compose 가 없는 디렉터리를 root 소유로 만들어 backend(node)가 쓰지 못한다.
|
||||
# (UPLOAD_DATA_DIR 는 개발 전용이라 운영에는 두지 않는다. 백업은 docker 스킬 "운영 환경")
|
||||
# ==========================================
|
||||
UPLOAD_DATA_DIR=./data/uploads
|
||||
UPLOAD_MAX_SIZE_MB=5
|
||||
|
||||
# ==========================================
|
||||
# 메일 엔진 (Stalwart) — profile `mail`
|
||||
#
|
||||
# 운영 기본 가정 = 메일 전용 호스트: 25·465·993·443 을 이 호스트에 직접 연다 (docker-compose.yml).
|
||||
# 첫 배포 후 setup 스크립트를 한 번 실행한다 — mail/README.md "운영 배포".
|
||||
#
|
||||
# ⚠️ 비밀번호는 모두 강한 무작위 값으로 교체한다. CHANGE-ME 가 남아 있으면 setup 스크립트가 중단한다.
|
||||
# 영문·숫자로만 만든다 — # · 따옴표 · $ · 공백은 compose 와 Node 가 다르게 읽는다.
|
||||
# 예: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
|
||||
# ⚠️ 8080(관리 API)은 공개하지 않는다. setup 스크립트는 내부망에서 실행한다 (README 참조).
|
||||
# 공개 443 의 JMAP·관리 API·웹 포털은 setup 이 막는다 (MTA-STS 정책 파일만 열어 둔다).
|
||||
# ==========================================
|
||||
# 패치까지 고정한다 — setup 스크립트가 0.16 의 동작(관리 API·bootstrap)에 맞춰져 있다 (docker 스킬 "이미지 버전 고정")
|
||||
MAIL_IMAGE=stalwartlabs/stalwart:v0.16.27
|
||||
MAIL_DOMAIN=arasystem.it.kr
|
||||
# 새 엔진의 호스트명 (EHLO·PTR·TLS 인증서) — 최초 bootstrap 때만 반영된다.
|
||||
# mailcow 가 mail.arasystem.it.kr 를 쓰는 전환 기간에는 새 이름(mx1.)을 쓴다 (mail/README.md "전환 절차")
|
||||
MAIL_HOSTNAME=mx1.arasystem.it.kr
|
||||
# JMAP·OAuth 디스커버리 기준 주소 — 새 엔진에 실제로 닿는 주소여야 한다
|
||||
MAIL_PUBLIC_URL=https://mx1.arasystem.it.kr
|
||||
# 영구 관리자 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 admin) — setup 스크립트가 이 이름으로 관리자를 만들고,
|
||||
# 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 (두 쪽이 이 값 하나를 읽는다).
|
||||
# ⚠️ 운영 bootstrap 전에 정한다. mailcow 에 실제 admin@ 메일함이 있으면 다른 이름으로 한다
|
||||
# (그대로 두면 엔진 관리자와 주소가 겹쳐 그 메일함은 옮겨 와도 웹메일에 로그인할 수 없다).
|
||||
# ⚠️ bootstrap 뒤에 이 값만 바꾸면 엔진 관리자는 그대로 남고 백엔드는 새 이름만 막게 되므로, setup 이 중단한다
|
||||
# (되돌리는 절차: mail/README.md "문제 해결"). 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (백엔드와 같은 규칙)
|
||||
MAIL_ADMIN_LOCAL_PART=admin
|
||||
# 영구 관리자 비밀번호 — setup 스크립트가 이 값으로 맞춘다
|
||||
MAIL_ADMIN_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
# 복구 관리자(MAIL_RECOVERY_ADMIN, "아이디:비밀번호")는 이 파일에 적지 않는다 — 최초 bootstrap·관리자 복구 때
|
||||
# 셸에서 일회성으로만 준다. 값이 있는 동안은 정상 모드에서도 관리자로 로그인되기 때문이다.
|
||||
# 이 파일에 값이 있거나, 끝난 뒤에도 mail 컨테이너에 남아 있으면 setup 이 실패한다 (mail/README.md "운영 배포").
|
||||
# read -rsp '임시 복구 비밀번호: ' P; echo; export MAIL_RECOVERY_ADMIN="admin:$P"
|
||||
# docker compose --env-file .env.production up -d mail
|
||||
# node --env-file=.env.production mail/scripts/setup.mjs --recovery
|
||||
# unset MAIL_RECOVERY_ADMIN P; docker compose --env-file .env.production up -d mail # 복구 관리자 끄기
|
||||
# node --env-file=.env.production mail/scripts/setup.mjs # 꺼졌는지 확인
|
||||
# 백엔드가 직원 메일함을 대리 접근(impersonate)할 때 쓰는 서비스 계정 (주소 전체).
|
||||
# ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다
|
||||
MAIL_GATEWAY_USER=mail-gateway@arasystem.it.kr
|
||||
MAIL_GATEWAY_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망)
|
||||
MAIL_JMAP_URL=http://mail:8080
|
||||
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도는 이 값 + 1MB 로 자동으로 따라간다.
|
||||
# ⚠️ 그 앞의 외부 리버스 프록시 본문 한도는 직접 이 값 + 1MB 이상으로 둔다 —
|
||||
# 작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 (docker 스킬 "운영 환경")
|
||||
MAIL_ATTACHMENT_MAX_SIZE_MB=20
|
||||
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다
|
||||
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25
|
||||
# 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분).
|
||||
# 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다
|
||||
MAIL_TRUSTED_NETWORKS=
|
||||
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
|
||||
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
|
||||
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.it.kr
|
||||
|
||||
@@ -10,9 +10,17 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# lock(package-lock.json)을 쓰고 검사하는 npm 버전 — backend·frontend Dockerfile 의 NPM_VERSION, docker 스킬 문서와 같은 값.
|
||||
# setup-node 의 Node 에 딸린 npm(Node 24 는 11.19 등 — 패치마다 바뀐다)은 lock 의 중첩 항목을 다르게 정리하므로 그대로 쓰지 않는다.
|
||||
# Node 는 24 LTS 패치까지 고정 — backend·frontend Dockerfile 의 ARG NODE_VERSION 과 같은 값 (세 잡이 이 값 하나를 쓴다).
|
||||
# 갱신 규칙(일반 패치는 배포 2주 뒤, 보안 릴리스는 CI 통과 즉시)은 docker 스킬 문서 "Node 버전".
|
||||
env:
|
||||
NPM_VERSION: '11.6.2'
|
||||
NODE_VERSION: '24.22.1'
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend (lint · test · e2e · build)
|
||||
name: Backend (lint · type-check · test · e2e · build)
|
||||
runs-on: ubuntu-latest
|
||||
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
|
||||
timeout-minutes: 20
|
||||
@@ -43,16 +51,23 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: npm 버전 고정
|
||||
run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# Warning 0 (.claude/rules/checklist.md)
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
# tsc --noEmit -p tsconfig.json — 빌드(tsconfig.build.json)가 빼는 spec·test 파일까지 타입 검사한다
|
||||
- name: Type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
|
||||
@@ -72,7 +87,7 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
frontend:
|
||||
name: Frontend (lint · type-check · test)
|
||||
name: Frontend (lint · type-check · test · build)
|
||||
runs-on: ubuntu-latest
|
||||
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
|
||||
timeout-minutes: 15
|
||||
@@ -84,10 +99,13 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: npm 버전 고정
|
||||
run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Lint
|
||||
@@ -99,6 +117,52 @@ jobs:
|
||||
- name: Test
|
||||
run: npm run test:unit -- --run
|
||||
|
||||
# 운영 이미지와 같은 vite 빌드 (타입 검사는 위에서 했으므로 build-only)
|
||||
- name: Build
|
||||
run: npm run build-only
|
||||
|
||||
infra:
|
||||
name: Infra (compose · Dockerfile · nginx)
|
||||
runs-on: ubuntu-latest
|
||||
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
|
||||
timeout-minutes: 10
|
||||
# 잡 컨테이너 안에서 docker 를 쓴다 — act_runner 기본 설정(docker_host 비움)이 호스트 도커 소켓을 잡 컨테이너에 붙여 주고,
|
||||
# 잡 이미지(gitea/runner-images:ubuntu-latest)에 docker CLI·compose 가 들어 있다 (docker 스킬 "CI 러너").
|
||||
# 빌드 컨텍스트는 API 로 올라가므로 잡 컨테이너 안의 체크아웃으로 빌드할 수 있다 (-v 바인드 마운트는 호스트 경로라 쓰지 않는다)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: compose 문법·변수 치환 (예시 env)
|
||||
run: |
|
||||
# env_file 대상(backend/.env.*)은 커밋되지 않는다 — 빈 파일로 자리만 채운다
|
||||
touch backend/.env.development backend/.env.production
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example config -q
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile frontend config -q
|
||||
docker compose -f docker-compose.yml --env-file .env.production.example config -q
|
||||
|
||||
- name: Dockerfile 검사
|
||||
run: |
|
||||
docker build --check backend
|
||||
docker build --check frontend
|
||||
|
||||
# 운영 이미지의 nginx 단계만 만든다 (화면 빌드 없음 — frontend/Dockerfile 의 nginx-config)
|
||||
- name: nginx 설정 검사
|
||||
run: |
|
||||
tag="ci-nginx-config:${{ github.run_id }}"
|
||||
docker build --target nginx-config -t "$tag" frontend
|
||||
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t
|
||||
# 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소)
|
||||
if docker run --rm -e PROJECT_NAME=ci -e MAIL_ATTACHMENT_MAX_SIZE_MB=08 "$tag" nginx -t; then
|
||||
echo "::error::MAIL_ATTACHMENT_MAX_SIZE_MB=08 을 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
if docker run --rm -e NGINX_BACKEND_UPSTREAM=http://ci-backend:3000/ "$tag" nginx -t; then
|
||||
echo "::error::경로가 붙은 NGINX_BACKEND_UPSTREAM 을 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
|
||||
- name: 정리
|
||||
if: always()
|
||||
run: docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
||||
|
||||
error-codes-sync:
|
||||
name: 에러 코드 SSOT 동기화 검사
|
||||
runs-on: ubuntu-latest
|
||||
@@ -109,10 +173,13 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: npm 버전 고정
|
||||
run: npm install -g npm@${{ env.NPM_VERSION }} && npm --version
|
||||
|
||||
- name: Install backend deps
|
||||
working-directory: backend
|
||||
run: npm ci
|
||||
|
||||
+5
-2
@@ -11,14 +11,17 @@
|
||||
# ==========================================
|
||||
# ⚠️ 루트 기준으로 고정한다(앞의 /). 'data/' 로 두면 모든 depth 의 data 디렉터리가 무시되어
|
||||
# flutter/lib/features/<도메인>/data/ 같은 소스 코드까지 커밋되지 않는다.
|
||||
/data/
|
||||
# 디렉터리(/data/)가 아니라 내용(/data/*)을 무시하고 .gitkeep 만 추적한다 — 그래야 클론할 때 data/ 가
|
||||
# 클론한 사용자 소유로 생긴다. 없으면 운영의 `sudo docker compose up` 이 root 소유로 만들어
|
||||
# 메일 setup 스크립트가 data/mail/ 에 쓰지 못한다 (mail/README.md "운영 배포").
|
||||
# (개발 DB·업로드 등 data/ 아래 내용은 모두 무시된다)
|
||||
/data/*
|
||||
!/data/.gitkeep
|
||||
/db-data/
|
||||
/redis-data/
|
||||
# CI 러너 등록 정보
|
||||
ci/runner-data/
|
||||
# 업로드 파일 (로컬 저장) — 코드가 아니다
|
||||
/data/uploads/
|
||||
backend/uploads/
|
||||
|
||||
# ==========================================
|
||||
|
||||
@@ -12,10 +12,15 @@ NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에
|
||||
| `flutter/` | Flutter + Riverpod | 모바일 앱 | - |
|
||||
| `db` | PostgreSQL | 데이터베이스 | 5432 |
|
||||
| `redis` | Redis | 캐시 / 세션 (**기본 비활성**) | 6379 |
|
||||
| `mail` | Stalwart (JMAP) | 사내 메일 엔진 — 직원 로그인 = 메일 계정 (profile `mail`, [`mail/README.md`](./mail/README.md)) | 운영 25·465·993·443 / 개발 관리 8180 |
|
||||
| `mailpit` | Mailpit | **개발 전용** — 사내 도메인 밖으로 보낸 메일 수집 | 8025 |
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
- Docker / Docker Compose — backend·frontend·db·redis 구동
|
||||
- Docker / Docker Compose — backend·frontend·db·redis 구동 (컨테이너 안 런타임은 Node 24 LTS — `node:24.22.1-alpine3.24` 고정, 갱신 규칙은 docker 스킬 "Node 버전")
|
||||
- Node.js **24 LTS 권장** — 호스트에서 프론트엔드 dev 서버·메일 setup 스크립트를 실행할 때 필요하다.
|
||||
프론트 `engines` 는 `^20.19.0 || >=22.12.0` 이라 20.19 이상이면 돌아가지만, 이미지·CI(24)와 맞추는 것을 권장한다
|
||||
(메일 setup 스크립트는 `--env-file` 때문에 20.6 이상)
|
||||
- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님)
|
||||
|
||||
## 빠른 시작 (Docker)
|
||||
@@ -28,9 +33,12 @@ cp .env.development.example .env.development # ← 여기
|
||||
cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿)
|
||||
# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요
|
||||
|
||||
# 2) 백엔드 스택 기동 (backend + db)
|
||||
# 2) 백엔드 스택 기동 (backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail + mailpit 포함)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
|
||||
# 2-1) 메일 엔진 초기화 — 처음 한 번 (여러 번 실행해도 안전, mail/README.md)
|
||||
node --env-file=.env.development mail/scripts/setup.mjs
|
||||
|
||||
# 3) 의존성 변경 시에만 --build
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
|
||||
|
||||
@@ -92,7 +100,7 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
|
||||
| ----------------------------- | ---------------------------------------- | --------------------- |
|
||||
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
|
||||
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
|
||||
| `frontend/.env.{env}.example` | 프론트 — 빌드 시 루트 `BACKEND_API_URL` 주입 | 작성 불필요 |
|
||||
| `frontend/.env.{env}.example` | 프론트 — API 주소는 같은 출처의 `/api` 고정(빌드 arg) | 작성 불필요 |
|
||||
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
|
||||
|
||||
`{env}` = `development` | `production`
|
||||
@@ -191,7 +199,7 @@ lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다.
|
||||
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
|
||||
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
|
||||
├── .github/
|
||||
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 검사
|
||||
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 + 인프라(compose·Dockerfile·nginx) 검사
|
||||
│ └── pull_request_template.md
|
||||
├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리
|
||||
└── .claude/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# ==========================================
|
||||
# 이미지 빌드 컨텍스트 제외 목록
|
||||
#
|
||||
# 이미지에는 소스와 lock 파일만 들어가야 한다.
|
||||
#
|
||||
# node_modules : 호스트(Windows)에서 설치한 node_modules 가 `COPY . .` 로 들어가면
|
||||
# `npm ci` 가 설치한 Linux 용 의존성을 덮어쓴다 (네이티브 모듈이 깨진다).
|
||||
# dev 의 익명 볼륨(/app/node_modules)도 이미지 내용으로 초기화되므로 같은 문제가 생긴다.
|
||||
# .env* : 비밀값이 이미지 레이어에 영구히 남는다. 값은 compose 가 런타임에 주입한다.
|
||||
# ==========================================
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
uploads
|
||||
logs
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
.git
|
||||
@@ -9,7 +9,14 @@
|
||||
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
|
||||
# ==========================================
|
||||
|
||||
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
|
||||
# JWT 서명 키 — 액세스·리프레시 토큰을 서명한다. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
|
||||
# 반드시 **무작위로 만든다.** 사람이 지은 문장·단어 조합·회사명·날짜 등은 쓰지 않는다
|
||||
# (토큰 하나만 손에 넣으면 오프라인에서 키를 대입해 볼 수 있고, 맞히면 모든 직원의 토큰을 위조할 수 있다).
|
||||
# 만드는 법: openssl rand -base64 48
|
||||
# (openssl 이 없으면) node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
# 운영은 32자 이상이 아니면 기동하지 않는다 (src/shared/config/env.validation.ts).
|
||||
JWT_SECRET=change-me-generate-a-long-random-string
|
||||
# 만료 — 액세스 5분~1시간, 리프레시(미사용 만료) 30일 이하이며 액세스보다 길어야 한다 (env 검증).
|
||||
# 세션 절대 만료(로그인 후 30일)는 코드 상수다.
|
||||
JWT_ACCESS_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
@@ -9,7 +9,14 @@
|
||||
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
|
||||
# ==========================================
|
||||
|
||||
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
|
||||
# JWT 서명 키 — 액세스·리프레시 토큰을 서명한다. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
|
||||
# 반드시 **무작위로 만든다.** 사람이 지은 문장·단어 조합·회사명·날짜 등은 쓰지 않는다
|
||||
# (토큰 하나만 손에 넣으면 오프라인에서 키를 대입해 볼 수 있고, 맞히면 모든 직원의 토큰을 위조할 수 있다).
|
||||
# 만드는 법: openssl rand -base64 48
|
||||
# (openssl 이 없으면) node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
# 운영은 32자 이상이 아니면 기동하지 않는다 (src/shared/config/env.validation.ts).
|
||||
JWT_SECRET=change-me-generate-a-long-random-string
|
||||
# 만료 — 액세스 5분~1시간, 리프레시(미사용 만료) 30일 이하이며 액세스보다 길어야 한다 (env 검증).
|
||||
# 세션 절대 만료(로그인 후 30일)는 코드 상수다.
|
||||
JWT_ACCESS_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
+21
-6
@@ -1,9 +1,18 @@
|
||||
FROM node:20-alpine AS builder
|
||||
# lock(package-lock.json)을 쓰고 검사하는 npm 은 한 버전으로 고정한다.
|
||||
# npm 은 버전마다 lock 의 중첩 항목(chokidar·readdirp·@emnapi 등)을 다르게 정리해서, 섞어 쓰면 lock 이 매번 바뀐다.
|
||||
# 바꿀 때는 frontend/Dockerfile · .github/workflows/ci.yml 의 NPM_VERSION · docker 스킬 문서를 함께 고친다.
|
||||
ARG NPM_VERSION=11.6.2
|
||||
|
||||
# 런타임은 Node 24 LTS — 패치까지 고정한다 (메이저 태그는 빌드할 때마다 그날의 최신 패치를 받는다).
|
||||
# 갱신 규칙(일반 패치는 배포 2주 뒤, 보안 릴리스는 CI 통과 즉시)과 함께 고치는 곳은 docker 스킬 문서 "Node 버전" 참조.
|
||||
ARG NODE_VERSION=24.22.1
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine3.24 AS builder
|
||||
ARG NPM_VERSION
|
||||
WORKDIR /app
|
||||
|
||||
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
|
||||
# (전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행)
|
||||
RUN npm install -g npm@11
|
||||
# 전역 설치는 root 권한이 필요하므로 USER 전환 전에 수행
|
||||
RUN npm install -g npm@${NPM_VERSION}
|
||||
|
||||
# 개발 환경(docker-compose.dev.yml)은 이 스테이지를 그대로 구동하므로 비-root 로 전환한다.
|
||||
# /app 소유권을 넘겨야 익명 볼륨(node_modules)도 node 소유로 생성되어 캐시 쓰기가 가능하다.
|
||||
@@ -17,13 +26,14 @@ RUN npm ci
|
||||
COPY --chown=node:node . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
FROM node:${NODE_VERSION}-alpine3.24 AS production
|
||||
ARG NPM_VERSION
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# 운영 stage 도 동일 npm 버전 사용
|
||||
RUN npm install -g npm@11
|
||||
RUN npm install -g npm@${NPM_VERSION}
|
||||
|
||||
# 운영용 의존성만 설치
|
||||
COPY package*.json ./
|
||||
@@ -31,6 +41,11 @@ RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads) — node 소유로 만들어 둔다.
|
||||
# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다.
|
||||
# (호스트 디렉터리를 바인드 마운트하면 sudo docker compose 가 root 소유로 만들어 업로드가 실패한다)
|
||||
RUN mkdir -p /app/uploads && chown node:node /app/uploads
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# 비-root 실행 (보안 규칙)
|
||||
|
||||
Generated
+629
-246
File diff suppressed because it is too large
Load Diff
+28
-2
@@ -14,6 +14,7 @@
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test,scripts}/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "eslint \"{src,apps,libs,test,scripts}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
@@ -32,6 +33,8 @@
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/schedule": "^6.1.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
@@ -41,11 +44,16 @@
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.1.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"nest-winston": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"typeorm": "^0.3.28",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
@@ -57,10 +65,13 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@swc/cli": "^0.8.1",
|
||||
"@swc/core": "^1.15.24",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/node": "^24.19.2",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/sanitize-html": "^2.16.2",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"chokidar": "^5.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
@@ -93,12 +104,27 @@
|
||||
},
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"/node_modules/(htmlparser2|domhandler|domutils|entities|dom-serializer|domelementtype)/.+\\.js$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig": {
|
||||
"allowJs": true,
|
||||
"module": "commonjs",
|
||||
"target": "es2022",
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(htmlparser2|domhandler|domutils|entities|dom-serializer|domelementtype)/)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+20
-10
@@ -1,13 +1,16 @@
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard';
|
||||
import { OriginGuard } from '@/common/guards/origin.guard';
|
||||
import { AuthModule } from '@/modules/auth/auth.module';
|
||||
import { HealthModule } from '@/modules/health/health.module';
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
@@ -58,20 +61,27 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
}),
|
||||
}),
|
||||
HealthModule,
|
||||
// 직원 로그인(쿠키 JWT) — 자격 확인은 MailModule 의 메일 엔진 포트로
|
||||
AuthModule,
|
||||
// 웹메일 API (Stalwart JMAP 어댑터)
|
||||
MailModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
providers: [
|
||||
ShutdownLogger,
|
||||
// 전역 가드는 등록 순서대로 돈다 — 다른 사이트에서 온 상태 변경 요청은 요청 수를 세기 전에 막는다
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
useClass: OriginGuard,
|
||||
},
|
||||
// 요청 수 한도 — ThrottlerGuard 그대로, 클라이언트 키만 IPv6 /64 로 묶는다 (두 번째 limiter 가 아니다)
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ClientIpThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
// 모든 요청에 추적 ID 를 부여한다 (로그·에러 응답 traceId 와 동일 값)
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestIdMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
// 요청 추적 ID 는 app.setup.ts 의 configureApp() 이 **맨 앞** express 미들웨어로 붙인다 —
|
||||
// 본문 파서 오류(413·400·415)에도 traceId 가 남도록 Nest 미들웨어(본문 파서 뒤)로 두지 않는다
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { BadRequestException, HttpStatus } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MessageDeletionDto } from '@/modules/mail/dto/bulk-messages.dto';
|
||||
import { MailFilterUpdateDto } from '@/modules/mail/dto/mail-filter.dto';
|
||||
import { SendMessageDto } from '@/modules/mail/dto/send-message.dto';
|
||||
import {
|
||||
JSON_SHAPE_LIMITS,
|
||||
createValidationPipe,
|
||||
isDraftSaveRequest,
|
||||
isSendMessageRequest,
|
||||
rejectOversizedJsonBody,
|
||||
verifyJsonBody,
|
||||
} from './app.setup';
|
||||
|
||||
/** 본문 파서의 읽기 전 확인을 부른다 — 던진 것을 돌려준다 (던지지 않으면 undefined) */
|
||||
function verifyFailure(text: string, encoding = 'utf-8'): unknown {
|
||||
try {
|
||||
verifyJsonBody(
|
||||
{} as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
Buffer.from(text, 'utf8'),
|
||||
encoding,
|
||||
);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 읽은 뒤의 확인을 부른다 — next 에 넘긴 것 */
|
||||
function afterParse(body: unknown): unknown[] {
|
||||
const next = jest.fn();
|
||||
rejectOversizedJsonBody({ body } as Request, {} as Response, next);
|
||||
return next.mock.calls[0] as unknown[];
|
||||
}
|
||||
|
||||
const repeat = (unit: string, count: number) =>
|
||||
Array.from({ length: count }, () => unit).join(',');
|
||||
|
||||
describe('JSON 본문의 모양 한도 (JSON_SHAPE_LIMITS — DTO 변환 · 검사 전)', () => {
|
||||
it('읽기 전 확인 — 넘으면 BusinessException VAL_001(상태 400 — body-parser 가 이 상태를 쓴다), 까닭은 로그용 context 에만', () => {
|
||||
const failure = verifyFailure(`{"ids":[${repeat('1', 1_001)}]}`);
|
||||
|
||||
expect(failure).toBeInstanceOf(BusinessException);
|
||||
expect((failure as BusinessException).code).toBe('VAL_001');
|
||||
expect((failure as BusinessException).getStatus()).toBe(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
// http-errors 는 감싼 오류의 status 를 상태 코드로 쓴다 (body-parser 의 createError(403, err))
|
||||
expect((failure as { status?: unknown }).status).toBe(400);
|
||||
expect((failure as BusinessException).context).toEqual({
|
||||
reason: 'json-shape-limit',
|
||||
problem: 'array-too-long',
|
||||
stage: 'before-parse',
|
||||
});
|
||||
expect(verifyFailure(`{"ids":[${repeat('1', 1_000)}]}`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('JSON 본문은 UTF-8 만 — 다른 글자 인코딩(바이트로 셀 수 없다)은 작은 본문도 400 VAL_001', () => {
|
||||
// body-parser 는 `utf-` 로 시작하는 charset 을 모두 받아 소문자로 넘긴다
|
||||
for (const encoding of [
|
||||
'utf-16',
|
||||
'utf-16le',
|
||||
'utf-16be',
|
||||
'utf-32',
|
||||
'utf-7',
|
||||
]) {
|
||||
const failure = verifyFailure('{"ids":[]}', encoding);
|
||||
expect(failure).toBeInstanceOf(BusinessException);
|
||||
expect((failure as { status?: unknown }).status).toBe(400);
|
||||
expect((failure as BusinessException).code).toBe('VAL_001');
|
||||
expect((failure as BusinessException).context).toEqual({
|
||||
reason: 'json-not-utf8',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('UTF-8 로 읽히는 다른 이름(utf-_8 등)도 UTF-8 로 보고 모양을 본다 — 이름 글자 그대로 견주면 확인을 건너뛴다', () => {
|
||||
for (const encoding of [
|
||||
'utf-8',
|
||||
'utf-_8',
|
||||
'utf--8',
|
||||
'utf-8.',
|
||||
'utf-8:2026',
|
||||
]) {
|
||||
expect(verifyFailure('{"ids":[]}', encoding)).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
verifyFailure(
|
||||
`{"ids":[${repeat('1', 1_001)}]}`,
|
||||
encoding,
|
||||
) as BusinessException
|
||||
).context,
|
||||
).toEqual({
|
||||
reason: 'json-shape-limit',
|
||||
problem: 'array-too-long',
|
||||
stage: 'before-parse',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('읽은 뒤 확인 — 한도 안이면 그대로 넘기고(next()), 본문이 없는 요청도 지난다. 넘으면 next(VAL_001)', () => {
|
||||
expect(afterParse({ rules: [], version: 'v' })).toEqual([]);
|
||||
expect(afterParse(undefined)).toEqual([]);
|
||||
|
||||
const [failure] = afterParse({
|
||||
ids: Array.from({ length: 5_000 }, () => 1),
|
||||
});
|
||||
expect(failure).toBeInstanceOf(BusinessException);
|
||||
expect((failure as BusinessException).context).toEqual({
|
||||
reason: 'json-shape-limit',
|
||||
problem: 'array-too-long',
|
||||
stage: 'after-parse',
|
||||
});
|
||||
});
|
||||
|
||||
it('한도는 계약의 어떤 본문보다 넉넉하다 — 배열 1,000 · 값 10,000 · 깊이 32', () => {
|
||||
expect(JSON_SHAPE_LIMITS).toEqual({
|
||||
maxArrayLength: 1_000,
|
||||
maxValues: 10_000,
|
||||
maxDepth: 32,
|
||||
});
|
||||
});
|
||||
|
||||
describe('리뷰에서 잰 모양이 400 으로 0.5초 안에 끝난다 (본문 파서의 읽기 전 확인 — 본문은 재기 전에 만든다)', () => {
|
||||
it.each([
|
||||
[
|
||||
'필터 rules 에 {"id":N} 8만 개 (1MB)',
|
||||
`{"version":"v","rules":[${Array.from(
|
||||
{ length: 80_000 },
|
||||
(_, index) => `{"id":${index}}`,
|
||||
).join(',')}]}`,
|
||||
],
|
||||
[
|
||||
'규칙 하나의 conditions 에 {} 15만 개',
|
||||
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 150_000)}]}]}`,
|
||||
],
|
||||
[
|
||||
'규칙 하나의 conditions 에 {} 30만 개 (900KB)',
|
||||
`{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 300_000)}]}]}`,
|
||||
],
|
||||
[
|
||||
'발송 to 에 {} 139만 개 (4MB 경로의 최대)',
|
||||
`{"subject":"x","body":"x","to":[${repeat('{}', 1_390_000)}]}`,
|
||||
],
|
||||
[
|
||||
'여러 통 ids 에 문자열 6만 개',
|
||||
`{"ids":[${Array.from(
|
||||
{ length: 60_000 },
|
||||
(_, index) => `"${index.toString(36)}"`,
|
||||
).join(',')}]}`,
|
||||
],
|
||||
[
|
||||
'로그인 본문에 10만 단 중첩 (600KB — 전에는 DTO 변환의 재귀가 넘쳐 500)',
|
||||
`{"email":"a@b.c","password":"x","z":${'{"a":'.repeat(100_000)}1${'}'.repeat(100_000)}}`,
|
||||
],
|
||||
])('%s', (_label, text) => {
|
||||
const cpuMs = startCpuTimer();
|
||||
const failure = verifyFailure(text);
|
||||
expect(cpuMs()).toBeLessThan(500);
|
||||
expect((failure as BusinessException).code).toBe('VAL_001');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () => {
|
||||
/**
|
||||
* 모양 한도 안의 가장 무거운 본문들 — 본문 파서 · 모양 확인을 지난 뒤 DTO 변환 · 검사까지 해도 400 이 싸게 난다.
|
||||
* CPU 시간(test/cpu-time.ts) — 지금 0.02~0.08초. 한도 2초는 부하를 견디는 값이다: 2026-10-28 CI 와 같은 Linux 컨테이너에서
|
||||
* 메모리가 모자라고 테스트가 함께 돌 때 0.59초까지 재졌다(평소의 7배 — 그때 한도 0.5초로 CI 가 실패했다)
|
||||
*/
|
||||
const DTO_CPU_BUDGET_MS = 2_000;
|
||||
const filtersWithManyUnknowns = {
|
||||
version: 'v',
|
||||
rules: Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `r${index}`,
|
||||
name: '',
|
||||
isEnabled: true,
|
||||
match: 'all',
|
||||
conditions: Array.from({ length: 5 }, () => ({
|
||||
field: 'subject',
|
||||
operator: 'contains',
|
||||
value: 'x',
|
||||
extra: Array.from({ length: 32 }, () => 1),
|
||||
})),
|
||||
actions: {
|
||||
moveToMailboxId: null,
|
||||
markAsRead: true,
|
||||
flag: false,
|
||||
forwardTo: null,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
it.each<[string, new () => object, unknown]>([
|
||||
[
|
||||
'필터 rules 1,000개 (배열 한도까지)',
|
||||
MailFilterUpdateDto,
|
||||
{
|
||||
version: 'v',
|
||||
rules: Array.from({ length: 1_000 }, (_, index) => ({ id: index })),
|
||||
},
|
||||
],
|
||||
[
|
||||
'규칙 하나의 conditions 1,000개',
|
||||
MailFilterUpdateDto,
|
||||
{
|
||||
version: 'v',
|
||||
rules: [
|
||||
{ id: 'r', conditions: Array.from({ length: 1_000 }, () => ({})) },
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'필터 50개 × 조건 5개마다 모르는 배열 — 값 약 9,800개 (값 한도 가까이)',
|
||||
MailFilterUpdateDto,
|
||||
filtersWithManyUnknowns,
|
||||
],
|
||||
[
|
||||
'발송 to 1,000명',
|
||||
SendMessageDto,
|
||||
{
|
||||
subject: 'x',
|
||||
body: 'x',
|
||||
to: Array.from({ length: 1_000 }, () => ({})),
|
||||
},
|
||||
],
|
||||
[
|
||||
'여러 통 ids 1,000개',
|
||||
MessageDeletionDto,
|
||||
{ ids: Array.from({ length: 1_000 }, (_, index) => `m${index}`) },
|
||||
],
|
||||
])(
|
||||
'%s — 모양 확인을 지나도 DTO 검사가 2초 안에 400 (CPU 시간)',
|
||||
async (_label, metatype, body) => {
|
||||
const text = JSON.stringify(body);
|
||||
const pipe = createValidationPipe();
|
||||
const cpuMs = startCpuTimer();
|
||||
|
||||
expect(verifyFailure(text)).toBeUndefined();
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
expect(afterParse(parsed)).toEqual([]);
|
||||
await expect(
|
||||
pipe.transform(parsed, { type: 'body', metatype }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(cpuMs()).toBeLessThan(DTO_CPU_BUDGET_MS);
|
||||
},
|
||||
);
|
||||
|
||||
it('검사 결과는 그대로다 — 맞는 본문은 지나고, 한도를 넘거나 모르는 칸이 있으면 400', async () => {
|
||||
const pipe = createValidationPipe();
|
||||
const valid = {
|
||||
rules: [],
|
||||
version: 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E',
|
||||
};
|
||||
await expect(
|
||||
pipe.transform(valid, { type: 'body', metatype: MailFilterUpdateDto }),
|
||||
).resolves.toBeInstanceOf(MailFilterUpdateDto);
|
||||
for (const body of [
|
||||
{ ...valid, isActive: true },
|
||||
{ ...valid, rules: Array.from({ length: 51 }, () => ({})) },
|
||||
{ ...valid, version: 'a b' },
|
||||
]) {
|
||||
await expect(
|
||||
pipe.transform(body, { type: 'body', metatype: MailFilterUpdateDto }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => {
|
||||
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/drafts'],
|
||||
['POST', '/API/Drafts/'],
|
||||
['PUT', '/api/drafts/faaaaabi'],
|
||||
['PUT', '/api/drafts/faaaaabi/?x=1'],
|
||||
] as const) {
|
||||
expect(isDraftSaveRequest({ method, url })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('조회·다른 경로·하위 경로는 1MB 그대로다', () => {
|
||||
for (const [method, url] of [
|
||||
['GET', '/api/drafts/faaaaabi'],
|
||||
['PUT', '/api/drafts'],
|
||||
['POST', '/api/drafts/faaaaabi'],
|
||||
['PUT', '/api/drafts/faaaaabi/attachments'],
|
||||
['PATCH', '/api/drafts/faaaaabi'],
|
||||
['POST', '/api/draftsx'],
|
||||
['PUT', '/drafts/faaaaabi'],
|
||||
] as const) {
|
||||
expect(isDraftSaveRequest({ method, url })).toBe(false);
|
||||
}
|
||||
expect(isDraftSaveRequest({ method: 'POST', url: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
|
||||
it('POST /api/messages 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => {
|
||||
for (const url of [
|
||||
'/api/messages',
|
||||
'/api/messages/',
|
||||
'/API/Messages',
|
||||
'/api/messages?draft=1',
|
||||
]) {
|
||||
expect(isSendMessageRequest({ method: 'POST', url })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('다른 메서드·다른 경로는 1MB 그대로다 — 메일 수정·첨부 업로드·로그인 포함', () => {
|
||||
for (const [method, url] of [
|
||||
['PATCH', '/api/messages/eaaaaab'],
|
||||
['GET', '/api/messages'],
|
||||
['PUT', '/api/messages'],
|
||||
['POST', '/api/messages/eaaaaab'],
|
||||
['POST', '/api/messages/eaaaaab/attachments'],
|
||||
['POST', '/api/mail-attachments'],
|
||||
['POST', '/api/auth/login'],
|
||||
['POST', '/messages'],
|
||||
['POST', '/api/messagesx'],
|
||||
] as const) {
|
||||
expect(isSendMessageRequest({ method, url })).toBe(false);
|
||||
}
|
||||
expect(isSendMessageRequest({ method: 'POST', url: undefined })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { json, type NextFunction, type Request, type Response } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { DRAFTS_ROUTE, MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
|
||||
import {
|
||||
findJsonShapeProblem,
|
||||
findJsonTextShapeProblem,
|
||||
type JsonShapeLimits,
|
||||
type JsonShapeProblem,
|
||||
} from '@/shared/utils/json-shape';
|
||||
import { resolveTrustProxy } from '@/shared/utils/trust-proxy';
|
||||
|
||||
/** 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) */
|
||||
export const API_PREFIX = 'api';
|
||||
|
||||
/**
|
||||
* JSON 본문 최대 크기 — 발송(`POST /api/messages`)·임시 저장(`POST /api/drafts`·`PUT /api/drafts/{id}`)을 뺀 모든 API.
|
||||
* 텍스트 본문 100,000자(한글은 UTF-8 로 3바이트라 약 300KB)도 이 안에 든다 (기본값 100KB 로는 잘린다).
|
||||
*/
|
||||
export const JSON_BODY_LIMIT = '1mb';
|
||||
|
||||
/**
|
||||
* 발송·임시 저장 요청의 JSON 본문 최대 크기 (계약 2절) — 서식 본문(최대 500,000자: 한글이면 약 1.5MB 에
|
||||
* JSON 이스케이프가 더해진다)과 텍스트 대체 본문(최대 100,000자)이 함께 오고, 답장·전달은 원문 인용이 길다.
|
||||
* 임시 저장 본문은 발송 본문과 같은 필드·한도라 같은 파서를 쓴다.
|
||||
* 앞단 nginx 도 이 경로들을 같은 값(4m)으로 둬야 한다 (release-ops — frontend/nginx.conf). 넘으면 413 FILE_002.
|
||||
*/
|
||||
export const SEND_MESSAGE_JSON_BODY_LIMIT = '4mb';
|
||||
|
||||
/**
|
||||
* JSON 본문의 모양 한도 — 두 본문 파서(1MB · 4MB)의 본문 모두에, 읽기 전(verifyJsonBody)과 읽은 뒤(rejectOversizedJsonBody)
|
||||
* DTO 변환 · 검사 **전에** 건다 (shared/utils/json-shape.ts 머리말).
|
||||
* 지금 계약의 어떤 본문보다 넉넉히 둔다: 가장 긴 배열은 받는 사람 · id 100(발송 본문의 첨부 목록 상한도 100), 가장 깊은 본문은 5단
|
||||
* (필터 — 본문 › rules › 규칙 › conditions › 조건), 값이 가장 많은 본문은 필터 50개를 모두 채운 저장(약 1,600개).
|
||||
* 넘으면 400 VAL_001 — 이 한도를 넘는 계약을 새로 만들면 여기부터 올린다.
|
||||
* 한도까지 채운 본문도 DTO 변환 · 검사가 0.1초 안쪽에 끝난다 (app.setup.spec.ts 가 CPU 시간으로 본다 — 부하를 견디게 한도는 2초)
|
||||
*/
|
||||
export const JSON_SHAPE_LIMITS: JsonShapeLimits = {
|
||||
maxArrayLength: 1_000,
|
||||
maxValues: 10_000,
|
||||
maxDepth: 32,
|
||||
};
|
||||
|
||||
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
|
||||
const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`;
|
||||
|
||||
/** 라우터가 보는 경로 — Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다 */
|
||||
function routedPath(req: Pick<IncomingMessage, 'url'>): string {
|
||||
return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 요청인가 — `POST /api/messages`.
|
||||
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다.
|
||||
*/
|
||||
export function isSendMessageRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
): boolean {
|
||||
return req.method === 'POST' && routedPath(req) === SEND_MESSAGE_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장 요청인가 — `POST /api/drafts`(처음 저장) · `PUT /api/drafts/{id}`(바꿔 저장). 조회(GET)는 본문이 없다.
|
||||
*/
|
||||
export function isDraftSaveRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
): boolean {
|
||||
const path = routedPath(req);
|
||||
if (req.method === 'POST') return path === DRAFTS_PATH;
|
||||
if (req.method !== 'PUT' || !path.startsWith(`${DRAFTS_PATH}/`)) {
|
||||
return false;
|
||||
}
|
||||
const id = path.slice(DRAFTS_PATH.length + 1);
|
||||
return id !== '' && !id.includes('/');
|
||||
}
|
||||
|
||||
/** 모양 한도를 넘은 본문의 실패 — 400 VAL_001. 로그에는 넘은 한도의 이름과 본 자리(읽기 전 · 뒤)만 남는다 */
|
||||
function jsonShapeFailure(
|
||||
problem: JsonShapeProblem,
|
||||
stage: 'before-parse' | 'after-parse',
|
||||
): BusinessException {
|
||||
return new BusinessException('VAL_001', {
|
||||
context: { reason: 'json-shape-limit', problem, stage },
|
||||
});
|
||||
}
|
||||
|
||||
/** 글자 인코딩 이름의 비교용 모양 — body-parser 가 본문을 글자로 바꿀 때(iconv-lite)와 같게 소문자 영숫자만 남긴다 */
|
||||
function canonicalCharset(label: string): string {
|
||||
return label.toLowerCase().replace(/:\d{4}$|[^0-9a-z]/g, '');
|
||||
}
|
||||
|
||||
/** UTF-8 의 비교용 이름 — `utf-8` · `UTF-8` · `utf-_8` 이 모두 이것이다 (body-parser 는 이 본문을 모두 UTF-8 로 읽는다) */
|
||||
const UTF8_CHARSET = 'utf8';
|
||||
|
||||
/**
|
||||
* 본문 파서의 읽기 전 확인(body-parser 의 verify) — UTF-8 본문을 JSON.parse 하기 **전에** 한 번 훑어 모양 한도를 넘으면 멈춘다.
|
||||
* JSON.parse 도 작은 객체가 아주 많으면 무겁다 (4MB 에 `{}` 139만 개를 읽는 데만 CPU 0.67초 — 2026-10-28 측정).
|
||||
*
|
||||
* JSON 본문은 UTF-8 만 받는다 (RFC 8259 8.1 — 웹 · 앱 모두 UTF-8 로 보낸다). body-parser 는 `utf-` 로 시작하는 charset 을
|
||||
* 모두 받지만(utf-16 · utf-32 · utf-7) 그 본문은 바이트로 셀 수 없어 JSON.parse 전에 끊지 못한다 — 그래서 400 VAL_001 로 거절한다.
|
||||
* 이름은 견줄 모양으로 바꿔 본다: 글자 그대로 `utf-8` 과 견주면 `utf-_8` 처럼 UTF-8 로 읽히는 다른 이름이 확인을 건너뛴다.
|
||||
*
|
||||
* 여기서 던진 오류는 body-parser 가 http-errors 로 감싸며 상태를 오류에서 가져와 400 이 되고(BusinessException 의 status),
|
||||
* 전역 필터가 VAL_001 로 답한다 (app.e2e-spec.ts 가 실제 응답으로 본다). 본문 파서는 가드보다 앞이라 로그인 전의 요청도 같다
|
||||
*/
|
||||
export function verifyJsonBody(
|
||||
_req: IncomingMessage,
|
||||
_res: ServerResponse,
|
||||
buffer: Buffer,
|
||||
encoding: string,
|
||||
): void {
|
||||
if (canonicalCharset(encoding) !== UTF8_CHARSET) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'json-not-utf8' },
|
||||
});
|
||||
}
|
||||
const problem = findJsonTextShapeProblem(buffer, JSON_SHAPE_LIMITS);
|
||||
if (problem !== null) throw jsonShapeFailure(problem, 'before-parse');
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 파서가 읽은 JSON 본문의 모양을 본다 (JSON_SHAPE_LIMITS) — 넘으면 DTO 변환 · 검사로 넘기지 않고 400 VAL_001.
|
||||
* 지금은 읽기 전 확인을 지난 UTF-8 본문만 오므로 같은 판정을 한 번 더 하는 셈이다(값 10,000개 안이라 가볍다) —
|
||||
* 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다
|
||||
*/
|
||||
export function rejectOversizedJsonBody(
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const body: unknown = req.body;
|
||||
const problem = findJsonShapeProblem(body, JSON_SHAPE_LIMITS);
|
||||
if (problem === null) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
next(jsonShapeFailure(problem, 'after-parse'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 DTO 검증 — 첫 실패에서 그 칸의 남은 검사를 멈춘다(stopAtFirstError).
|
||||
* 배열 길이 한도에 걸린 칸은 항목마다의 중첩 검사를 하지 않는다 — 실패 응답은 늘 VAL_001 이라 어느 제약이 먼저 걸렸는지는 쓰지 않는다
|
||||
* (도메인 코드 — 받는 사람 합계 MAIL_002 · 첨부 수 MAIL_006 · 본문 길이 MAIL_007 등 — 는 검증을 지난 뒤 서비스가 고른다)
|
||||
*/
|
||||
export function createValidationPipe(): ValidationPipe {
|
||||
return new ValidationPipe({
|
||||
whitelist: true, // DTO 에 없는 값은 버린다
|
||||
forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400
|
||||
transform: true, // DTO·기본 타입으로 변환
|
||||
stopAtFirstError: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** JSON 본문인가 — body-parser 의 기본 판정('application/json', 매개변수는 보지 않는다)과 같다 */
|
||||
function isJsonBody(req: IncomingMessage): boolean {
|
||||
const mediaType = (req.headers['content-type'] ?? '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return mediaType === 'application/json';
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 파이프라인 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다
|
||||
* (따로 적으면 e2e 가 실제 응답과 다른 설정으로 돌게 된다).
|
||||
*
|
||||
* 앱은 `{ bodyParser: false }` 로 만들어야 한다 — 본문 파서를 여기서 직접 등록한다.
|
||||
* CORS·Swagger·종료 훅은 배포 환경에 묶인 설정이라 main.ts 에 둔다.
|
||||
*/
|
||||
export function configureApp(app: NestExpressApplication): void {
|
||||
// 요청 추적 ID — **가장 먼저** 붙인다. 본문 파서·helmet 보다 앞이라 그 단계의 실패(413·400·415)에도
|
||||
// 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다
|
||||
app.use(requestIdMiddleware);
|
||||
|
||||
// 믿을 프록시 뒤에서 실제 클라이언트 IP 를 읽는다 — 로그인 실패 한도·요청 수 한도의 기준 (TRUST_PROXY).
|
||||
// 운영 체인: 클라이언트 → 외부 리버스 프록시 → 프론트 nginx → 백엔드. 값이 없으면(비운영) 1
|
||||
app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
|
||||
|
||||
// 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치)
|
||||
app.setGlobalPrefix(API_PREFIX, {
|
||||
exclude: [{ path: '/', method: RequestMethod.GET }],
|
||||
});
|
||||
|
||||
// 보안 헤더
|
||||
app.use(helmet());
|
||||
|
||||
// 인증 쿠키(access_token·refresh_token)를 읽는다
|
||||
app.use(cookieParser());
|
||||
|
||||
// JSON 만 받는다 — urlencoded 파서를 등록하지 않으면 다른 사이트의 <form> 으로 로그인·발송을 시도하는
|
||||
// 요청(CSRF)은 본문이 비어 VAL_001 이 된다. JSON 요청은 CORS 사전 확인을 거치므로 화이트리스트가 막는다.
|
||||
// inflate=false: 압축된 요청 본문을 받지 않는다 (브라우저는 보내지 않는다 — 압축 폭탄·body-parser DoS 경로 차단)
|
||||
//
|
||||
// 경로별 한도: 발송·임시 저장 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
|
||||
// 같은 요청을 두 번 읽지 않는다 — body-parser 는 이미 다 읽은 요청을 건너뛴다(read.js 의 isFinished 확인).
|
||||
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다.
|
||||
// 두 파서 모두 읽기 전 확인(verify — UTF-8 만 · 모양 한도)을 건다 — Nest 의 useBodyParser 는 verify 를 rawBody 용으로 남겨 두고 받지 않아(타입에서 뺀다)
|
||||
// 같은 파서(express.json = body-parser)를 직접 붙인다 (useBodyParser 도 안에서 이렇게 붙인다)
|
||||
app.use(
|
||||
json({
|
||||
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
|
||||
inflate: false,
|
||||
type: (req: IncomingMessage) =>
|
||||
(isSendMessageRequest(req) || isDraftSaveRequest(req)) &&
|
||||
isJsonBody(req),
|
||||
verify: verifyJsonBody,
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
json({ limit: JSON_BODY_LIMIT, inflate: false, verify: verifyJsonBody }),
|
||||
);
|
||||
// 두 파서가 읽은 본문 모두 — 배열 길이 · 값 수 · 깊이가 한도를 넘으면 DTO 변환 · 검사 전에 400 (JSON_SHAPE_LIMITS)
|
||||
app.use(rejectOversizedJsonBody);
|
||||
|
||||
// 전역 DTO 검증
|
||||
app.useGlobalPipes(createValidationPipe());
|
||||
|
||||
// 표준 실패·성공 응답
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { ArrayDistinct } from './array-distinct.decorator';
|
||||
|
||||
class Ids {
|
||||
@ArrayDistinct({ message: '같은 값이 두 번 들어 있습니다.' })
|
||||
ids!: unknown;
|
||||
}
|
||||
|
||||
async function constraintsOf(ids: unknown): Promise<string[]> {
|
||||
const target = Object.assign(new Ids(), { ids });
|
||||
const errors = await validate(target);
|
||||
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
describe('ArrayDistinct — 배열 항목이 겹치지 않는다 (선형)', () => {
|
||||
it('겹치는 값이 있으면 arrayDistinct — 원시 값은 값으로 견준다', async () => {
|
||||
expect(await constraintsOf(['m1', 'm2'])).toEqual([]);
|
||||
expect(await constraintsOf([])).toEqual([]);
|
||||
expect(await constraintsOf(['m1', 'm2', 'm1'])).toEqual(['arrayDistinct']);
|
||||
expect(await constraintsOf([1, 1])).toEqual(['arrayDistinct']);
|
||||
// 문자열 '1' 과 숫자 1 은 다른 값이다 (ArrayUnique 와 같다)
|
||||
expect(await constraintsOf(['1', 1])).toEqual([]);
|
||||
});
|
||||
|
||||
it('배열이 아니면 실패한다', async () => {
|
||||
for (const ids of ['m1', null, undefined, { 0: 'm1' }]) {
|
||||
expect(await constraintsOf(ids)).toEqual(['arrayDistinct']);
|
||||
}
|
||||
});
|
||||
|
||||
it('항목 수에 비례한다 — 20만 개도 0.5초 안 (ArrayUnique 는 6만 개에 4.7초였다)', async () => {
|
||||
const ids = Array.from({ length: 200_000 }, (_, index) => `m${index}`);
|
||||
const cpuMs = startCpuTimer();
|
||||
expect(await constraintsOf(ids)).toEqual([]);
|
||||
expect(cpuMs()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ValidateBy, type ValidationOptions } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 배열 항목이 겹치지 않는다 — Set 으로 한 번 훑는다(항목 수에 비례). 배열이 아니면 실패한다(IsArray 가 함께 알린다).
|
||||
* class-validator 의 ArrayUnique 는 항목마다 indexOf 로 앞을 다시 훑어 항목 수의 제곱이다 — id 6만 개에 4.7초 (2026-10-28 리뷰).
|
||||
* 견주는 방법은 ArrayUnique 와 같다(같은 값 — 원시 값은 값으로, 객체는 같은 객체일 때만)
|
||||
*/
|
||||
export function ArrayDistinct(options: ValidationOptions): PropertyDecorator {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'arrayDistinct',
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
Array.isArray(value) && new Set(value).size === value.length,
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ValidateBy, type ValidationOptions } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 문자열 길이 검사 — JS 문자열 길이(UTF-16 코드 단위)로 센다. 웹 클라이언트가 세는 방법(`value.length`)과 같다.
|
||||
* class-validator 의 MinLength·MaxLength(validator.js)는 서로게이트 쌍·이모지 표시 선택자를 덜 세어
|
||||
* 클라이언트가 센 길이와 어긋난다 (modules/mail/dto/send-message.dto.ts 의 본문 한도와 같은 이유).
|
||||
* 문자열이 아니면 실패한다 — 형식 오류는 IsString 이 함께 알린다.
|
||||
*/
|
||||
|
||||
/** 최대 길이 (JS 문자열 길이) */
|
||||
export function MaxCodeUnits(
|
||||
max: number,
|
||||
options: ValidationOptions,
|
||||
): PropertyDecorator {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'maxCodeUnits',
|
||||
constraints: [max],
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' && value.length <= max,
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** 최소 길이 (JS 문자열 길이) */
|
||||
export function MinCodeUnits(
|
||||
min: number,
|
||||
options: ValidationOptions,
|
||||
): PropertyDecorator {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'minCodeUnits',
|
||||
constraints: [min],
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' && value.length >= min,
|
||||
},
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/** 인증된 직원 — 액세스 토큰에서 복원한 값 (요청마다 메일 엔진에 묻지 않는다) */
|
||||
export interface AuthenticatedUser {
|
||||
/** 직원 메일 주소 (= 메일 계정) */
|
||||
readonly email: string;
|
||||
/** 표시 이름 */
|
||||
readonly name: string;
|
||||
/**
|
||||
* 액세스 토큰이 끝나는 시각 (epoch ms) — 가드(`JwtAuthGuard` · `JwtStrategy`)를 거친 요청에만 있다.
|
||||
* 로그인 · 갱신 응답의 직원 정보에는 없다. 오래 열린 연결(실시간 알림)을 토큰이 끝나기 전에 닫는 데 쓴다
|
||||
*/
|
||||
readonly accessTokenExpiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가드(`JwtAuthGuard`)가 검증해 `req.user` 에 넣은 직원 정보를 꺼낸다.
|
||||
* 가드 없이 쓰면 사용자가 없으므로 AUTH_001 로 막는다 (가드 누락이 조용히 통과하지 않게).
|
||||
*
|
||||
* 사용 예:
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* me(@CurrentUser() user: AuthenticatedUser) { ... }
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthenticatedUser => {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as AuthenticatedUser | undefined;
|
||||
|
||||
if (!user) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'current-user-without-guard' },
|
||||
});
|
||||
}
|
||||
return user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
|
||||
import { TrimString } from './trim-string.decorator';
|
||||
|
||||
class Named {
|
||||
@TrimString()
|
||||
name!: unknown;
|
||||
}
|
||||
|
||||
const nameOf = (name: unknown) => plainToInstance(Named, { name }).name;
|
||||
|
||||
describe('TrimString — DTO 변환에서 앞뒤 공백을 지운다', () => {
|
||||
it('문자열은 JS trim() 과 같게 — 탭 · 줄바꿈 · NBSP · U+3000 도 지운다, 가운데는 그대로', () => {
|
||||
expect(nameOf(' 거래처 메일 ')).toBe('거래처 메일');
|
||||
expect(nameOf('\t\n\u00a0\u3000가 나\u3000')).toBe('가 나');
|
||||
expect(nameOf(' ')).toBe('');
|
||||
});
|
||||
|
||||
it('문자열이 아니면 그대로 둔다 — 형식 검사(IsString 등)가 막는다', () => {
|
||||
expect(nameOf(null)).toBeNull();
|
||||
expect(nameOf(42)).toBe(42);
|
||||
expect(nameOf([' a '])).toEqual([' a ']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Transform, type TransformFnParams } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* 앞뒤 공백을 지운다 — DTO 변환(class-transformer) 단계라 검사(class-validator)는 지운 값으로 한다.
|
||||
* 문자열이 아니면 그대로 두어 IsString · IsEmail 같은 검사가 막는다. 지우는 공백은 JS `trim()` 과 같다(NBSP · U+3000 포함 — 웹의 `.trim()` 과 같게 센다).
|
||||
* 메일함 이름(dto/mailbox.dto.ts) · 메일 필터의 이름 · 찾을 글 · 전달 주소(dto/mail-filter.dto.ts)가 쓴다
|
||||
*/
|
||||
export function TrimString(): PropertyDecorator {
|
||||
return Transform(trimStringValue);
|
||||
}
|
||||
|
||||
/** TrimString 의 변환 — 문자열만 앞뒤 공백을 지운다 */
|
||||
export function trimStringValue({ value }: TransformFnParams): unknown {
|
||||
const raw: unknown = value;
|
||||
return typeof raw === 'string' ? raw.trim() : raw;
|
||||
}
|
||||
@@ -44,9 +44,29 @@ describe('에러 코드 레지스트리', () => {
|
||||
expect(resolveCodeByStatus(HttpStatus.BAD_GATEWAY)).toBe('SYS_001');
|
||||
});
|
||||
|
||||
it('지금 비밀번호 틀림(AUTH_005)은 401 이 아니라 400 이다 — 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다', () => {
|
||||
expect(ERROR_CODES.AUTH_005.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
// 400 의 역매핑은 그대로 VAL_001 이다 (AUTH_005 는 서버가 명시적으로만 던진다)
|
||||
expect(resolveCodeByStatus(HttpStatus.BAD_REQUEST)).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('같은 이름의 메일함(MAIL_009)은 409 다', () => {
|
||||
expect(ERROR_CODES.MAIL_009.status).toBe(HttpStatus.CONFLICT);
|
||||
});
|
||||
|
||||
it('받는 사람을 엔진이 모두 받지 않은 발송 — 5xx 가 섞이면 400 MAIL_010, 모두 4xx(지금은 받을 수 없음)면 503 MAIL_011', () => {
|
||||
expect(ERROR_CODES.MAIL_010.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
expect(ERROR_CODES.MAIL_011.status).toBe(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it('메일 필터 — 그사이 규칙이 바뀜은 409 MAIL_012, 만든 엔진 규칙이 엔진 한도를 넘음은 400 MAIL_013', () => {
|
||||
expect(ERROR_CODES.MAIL_012.status).toBe(HttpStatus.CONFLICT);
|
||||
expect(ERROR_CODES.MAIL_013.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
});
|
||||
|
||||
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
|
||||
const clientOnlyCodes = Object.entries(ERROR_CODES)
|
||||
.filter(([, definition]) => definition.clientOnly)
|
||||
.filter(([, definition]) => 'clientOnly' in definition)
|
||||
.map(([code]) => code);
|
||||
|
||||
expect(clientOnlyCodes).toContain('AUTH_002');
|
||||
|
||||
@@ -40,6 +40,17 @@ const COMMON_ERROR_CODES = {
|
||||
status: HttpStatus.FORBIDDEN,
|
||||
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
},
|
||||
// 로그인 자격 증명 불일치 — 계정 없음/비밀번호 틀림을 구분하지 않는다 (.claude/rules/security.md)
|
||||
AUTH_004: {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
},
|
||||
// 비밀번호 바꾸기(PUT /api/auth/password)의 지금 비밀번호가 틀림 — 세션은 그대로다.
|
||||
// 401 이 아니라 400 인 까닭: 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 (계약 1절)
|
||||
AUTH_005: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '지금 비밀번호가 올바르지 않습니다.',
|
||||
},
|
||||
VAL_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
@@ -73,6 +84,83 @@ const DOMAIN_ERROR_CODES = {
|
||||
status: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
message: '파일 크기가 허용 범위를 초과했습니다.',
|
||||
},
|
||||
// 메일 — 외부 메일 엔진(Stalwart) 장애를 SYS_001(우리 버그)과 구분하기 위해 분리한다
|
||||
MAIL_001: {
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
message: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
},
|
||||
MAIL_002: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '받는 사람 주소를 다시 확인해 주세요.',
|
||||
},
|
||||
MAIL_003: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
// 발송 때 엔진에서 첨부(blob)를 찾지 못함 — 올려 두고 오래 지나 만료된 업로드·그사이 지워진 원본 메일의 첨부(전달)·잘못된 첨부 id.
|
||||
// 문구는 원인을 단정하지 않는다 — 올린 파일은 다시 올리고, 전달하는 원본 첨부는 빼야 한다 (계약 2절)
|
||||
MAIL_004: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
},
|
||||
// 같은 Idempotency-Key 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다.
|
||||
// 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다
|
||||
MAIL_005: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.',
|
||||
},
|
||||
// 첨부 개수 한도 초과 — 업로드한 파일과 전달하는 원본 첨부를 함께 센다.
|
||||
// 문구의 숫자는 attachment.policy.ts 의 MAX_ATTACHMENTS_PER_MESSAGE 와 같아야 한다 (attachment.policy.spec.ts 가 확인한다)
|
||||
MAIL_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
|
||||
},
|
||||
// 본문이 너무 길다 — 텍스트 100,000자·서식 본문 500,000자 초과, 서식 본문이 정화 전 비용 한도나 정화 뒤 2,000,000바이트를 넘음.
|
||||
// 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 미리 막고 본문 아래에 이 문구를 보인다 (계약 2절)
|
||||
MAIL_007: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'본문이 너무 길어 보낼 수 없습니다. 내용을 줄인 뒤 다시 보내 주세요.',
|
||||
},
|
||||
// 임시 저장본 교체(PUT /api/drafts/{id}) 때 옛 초안이 이미 없다 — 다른 창·기기에서 먼저 고쳤거나 지웠다. 아무것도 저장하지 않는다.
|
||||
// 클라이언트는 막지 않고 지금 내용을 새 임시 저장본(POST)으로 남긴다 (계약 2절 "임시 저장")
|
||||
MAIL_008: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'임시 저장한 메일이 다른 곳에서 바뀌었거나 지워졌습니다. 지금 내용을 새 임시 저장본으로 남겨 주세요.',
|
||||
},
|
||||
// 메일함 만들기·이름 바꾸기 때 같은 상위 안에 같은 이름이 이미 있다 — 엔진이 alreadyExists 로 거절한다 (계약 2절 "메일함 관리")
|
||||
MAIL_009: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '같은 이름의 메일함이 이미 있습니다.',
|
||||
},
|
||||
// 발송했지만 엔진이 받는 사람을 모두 거절했다 — 그중 하나라도 5xx(없는 주소 · 거절). 아무에게도 가지 않아 보낸 사본을 지웠다 (계약 2절 "받는 사람별 결과")
|
||||
MAIL_010: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '받는 사람 주소로 메일을 보낼 수 없습니다. 주소를 확인해 주세요.',
|
||||
},
|
||||
// 발송했지만 엔진이 받는 사람을 모두 4xx(지금은 받을 수 없음 — 한도 등)로 거절했다. 보낸 사본을 지웠다 — 같은 키로 다시 보내면 된다
|
||||
MAIL_011: {
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
message:
|
||||
'받는 쪽이 지금은 메일을 받을 수 없습니다. 잠시 뒤 다시 보내 주세요.',
|
||||
},
|
||||
// 필터 저장(PUT /api/mail-filters) 때 그사이 다른 곳(다른 탭 · PC)에서 규칙이 바뀌었다 — 저장하지 않았다.
|
||||
// 화면은 목록 위 줄 + [다시 불러오기] 로 알린다 (계약 2절 "메일 필터"). 부재중 응답만 바뀐 것은 걸리지 않는다
|
||||
MAIL_012: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
|
||||
},
|
||||
// 필터 · 부재중 응답으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다 (계약 2절 "메일 필터"의 크기)
|
||||
MAIL_013: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'필터가 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터를 지워 주세요.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
|
||||
@@ -21,7 +21,11 @@ interface CapturedResponse {
|
||||
}
|
||||
|
||||
/** 필터 실행 후 실제로 내려간 응답을 캡처한다. */
|
||||
function runFilter(exception: unknown, requestId?: string): CapturedResponse {
|
||||
function runFilter(
|
||||
exception: unknown,
|
||||
requestId?: string,
|
||||
url = '/api/users/1',
|
||||
): CapturedResponse {
|
||||
const captured = {} as CapturedResponse;
|
||||
|
||||
const response = {
|
||||
@@ -37,7 +41,7 @@ function runFilter(exception: unknown, requestId?: string): CapturedResponse {
|
||||
|
||||
const request = {
|
||||
method: 'GET',
|
||||
url: '/api/users/1',
|
||||
url,
|
||||
header: () => requestId,
|
||||
};
|
||||
|
||||
@@ -107,6 +111,67 @@ describe('HttpExceptionFilter', () => {
|
||||
expect(JSON.stringify(result.body)).not.toContain('File too large');
|
||||
});
|
||||
|
||||
it('Express 미들웨어의 4xx(http-errors)는 상태로 역매핑한다 — 본문 크기 초과가 SYS_001 로 보이면 안 된다', () => {
|
||||
// body-parser 는 한도를 넘으면 { status: 413, expose: true } 인 오류를 next(err) 로 넘긴다
|
||||
const tooLarge = Object.assign(new Error('request entity too large'), {
|
||||
status: 413,
|
||||
statusCode: 413,
|
||||
expose: true,
|
||||
type: 'entity.too.large',
|
||||
});
|
||||
|
||||
const result = runFilter(tooLarge);
|
||||
|
||||
expect(result.status).toBe(HttpStatus.PAYLOAD_TOO_LARGE);
|
||||
expect(result.body.error.code).toBe('FILE_002');
|
||||
expect(JSON.stringify(result.body)).not.toContain('entity too large');
|
||||
});
|
||||
|
||||
it('expose 가 없거나 5xx 인 오류 객체는 SYS_001 로 수렴한다', () => {
|
||||
const notExposed = Object.assign(new Error('내부'), { status: 400 });
|
||||
const serverSide = Object.assign(new Error('내부'), {
|
||||
status: 502,
|
||||
expose: true,
|
||||
});
|
||||
|
||||
expect(runFilter(notExposed).body.error.code).toBe('SYS_001');
|
||||
expect(runFilter(serverSide).status).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
});
|
||||
|
||||
it('5xx BusinessException 도 context 를 로그에 남긴다 (응답에는 싣지 않는다)', () => {
|
||||
const errorLog = jest.spyOn(Logger.prototype, 'error');
|
||||
errorLog.mockClear();
|
||||
|
||||
const result = runFilter(
|
||||
new BusinessException('MAIL_001', {
|
||||
context: { reason: 'timeout', jmapType: 'serverFail' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
expect(JSON.stringify(result.body)).not.toContain('serverFail');
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"jmapType":"serverFail"'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('로그의 요청 주소에서 검색어(q)와 커서(cursor — 검색어 해시를 담는다)는 길이만 남긴다 — 나머지 쿼리는 그대로', () => {
|
||||
const warnLog = jest.spyOn(Logger.prototype, 'warn');
|
||||
warnLog.mockClear();
|
||||
|
||||
runFilter(
|
||||
new BusinessException('VAL_001'),
|
||||
'trace-q',
|
||||
'/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=bad',
|
||||
);
|
||||
|
||||
const logged = warnLog.mock.calls.map(([message]) => String(message));
|
||||
expect(logged).toEqual([
|
||||
'[VAL_001][trace-q] GET /api/recipient-suggestions?q=<2자>&limit=20&cursor=<3자>',
|
||||
]);
|
||||
});
|
||||
|
||||
it('미정의 예외는 내부 정보 없이 SYS_001 로 수렴한다', () => {
|
||||
const result = runFilter(new Error('DB 커넥션 풀 고갈: host=10.0.0.5'));
|
||||
|
||||
|
||||
@@ -17,10 +17,14 @@ import {
|
||||
resolveCodeByStatus,
|
||||
} from '@/common/errors/error-codes';
|
||||
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
|
||||
import { maskUrlForLog } from '@/shared/utils/mask';
|
||||
|
||||
/** 이 값 이상이면 서버 책임 오류로 보고 error 레벨로 남긴다. */
|
||||
const SERVER_ERROR_STATUS_THRESHOLD = 500;
|
||||
|
||||
/** 클라이언트 오류(4xx)의 시작 */
|
||||
const CLIENT_ERROR_STATUS_THRESHOLD = 400;
|
||||
|
||||
/**
|
||||
* 전역 Exception Filter — 모든 실패 응답을 표준 포맷으로 변환한다.
|
||||
* 에러 코드 ↔ 메시지 ↔ HTTP 상태는 error-codes.ts 레지스트리가 SSOT 이며,
|
||||
@@ -81,7 +85,19 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 미정의 예외 — 내부 정보가 새지 않도록 항상 SYS_001 로 수렴시킨다.
|
||||
// 3. Express 미들웨어(body-parser 등)가 만든 http-errors 객체 — 클라이언트 책임(4xx)이면 상태로 역매핑한다.
|
||||
// (본문 크기 초과 413 이 SYS_001(500)로 보이면 원인을 오해하게 된다)
|
||||
const clientStatus = this.extractClientErrorStatus(exception);
|
||||
if (clientStatus !== null) {
|
||||
const code = resolveCodeByStatus(clientStatus);
|
||||
return {
|
||||
status: clientStatus,
|
||||
code,
|
||||
message: getErrorDefinition(code).message,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 미정의 예외 — 내부 정보가 새지 않도록 항상 SYS_001 로 수렴시킨다.
|
||||
return {
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
code: FALLBACK_ERROR_CODE,
|
||||
@@ -89,6 +105,25 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* http-errors 형식(`status` + `expose: true`)의 클라이언트 오류면 상태 코드를 꺼낸다.
|
||||
* `expose` 는 "메시지를 클라이언트에 보여도 되는 4xx" 에만 true 이므로, 5xx·임의 객체는 걸러진다.
|
||||
*/
|
||||
private extractClientErrorStatus(exception: unknown): number | null {
|
||||
if (!(exception instanceof Error)) return null;
|
||||
|
||||
const { status, expose } = exception as Error & {
|
||||
status?: unknown;
|
||||
expose?: unknown;
|
||||
};
|
||||
const isClientError =
|
||||
typeof status === 'number' &&
|
||||
status >= CLIENT_ERROR_STATUS_THRESHOLD &&
|
||||
status < SERVER_ERROR_STATUS_THRESHOLD;
|
||||
|
||||
return isClientError && expose === true ? status : null;
|
||||
}
|
||||
|
||||
/** 예외 본문에 error code 가 실려 있으면 꺼낸다. */
|
||||
private extractCode(body: unknown): ErrorCode | null {
|
||||
if (typeof body === 'object' && body !== null && 'code' in body) {
|
||||
@@ -106,21 +141,23 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
request: Request,
|
||||
traceId?: string,
|
||||
): void {
|
||||
const where = `${request.method} ${request.url}`;
|
||||
// 검색어(q)는 길이만 — 사람 이름·주소가 든다 (shared/utils/mask.ts)
|
||||
const where = `${request.method} ${maskUrlForLog(request.url)}`;
|
||||
const prefix = `[${code}]${traceId ? `[${traceId}]` : ''} ${where}`;
|
||||
// context 는 로그 전용 개발자 정보다 — 5xx(예: 외부 메일 엔진 장애 MAIL_001)도 원인을 알 수 있게 함께 남긴다
|
||||
const context =
|
||||
exception instanceof BusinessException && exception.context
|
||||
? ` ${JSON.stringify(exception.context)}`
|
||||
: '';
|
||||
|
||||
if (status >= SERVER_ERROR_STATUS_THRESHOLD) {
|
||||
this.logger.error(
|
||||
prefix,
|
||||
`${prefix}${context}`,
|
||||
exception instanceof Error ? exception.stack : String(exception),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const context =
|
||||
exception instanceof BusinessException && exception.context
|
||||
? ` ${JSON.stringify(exception.context)}`
|
||||
: '';
|
||||
this.logger.warn(`${prefix}${context}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
import { ClientIpThrottlerGuard } from './client-ip-throttler.guard';
|
||||
|
||||
/** getTracker 만 확인한다 — 한도 계산은 throttler 의 몫이다 */
|
||||
function trackerOf(req: Record<string, unknown>): Promise<string> {
|
||||
const guard = Object.create(
|
||||
ClientIpThrottlerGuard.prototype,
|
||||
) as ClientIpThrottlerGuard;
|
||||
return (
|
||||
guard as unknown as {
|
||||
getTracker: (r: Record<string, unknown>) => Promise<string>;
|
||||
}
|
||||
).getTracker(req);
|
||||
}
|
||||
|
||||
describe('ClientIpThrottlerGuard', () => {
|
||||
it('ThrottlerGuard 를 그대로 쓰고 세는 키만 바꾼다 (두 번째 limiter 가 아니다)', () => {
|
||||
expect(Object.getPrototypeOf(ClientIpThrottlerGuard.prototype)).toBe(
|
||||
ThrottlerGuard.prototype,
|
||||
);
|
||||
});
|
||||
|
||||
it('IPv6 는 /64 로 묶는다 — 로그인 실패 한도와 같은 키', async () => {
|
||||
await expect(trackerOf({ ip: '2001:db8:1:2::5' })).resolves.toBe(
|
||||
'2001:db8:1:2::/64',
|
||||
);
|
||||
await expect(trackerOf({ ip: '2001:db8:1:2:aaaa::9' })).resolves.toBe(
|
||||
'2001:db8:1:2::/64',
|
||||
);
|
||||
});
|
||||
|
||||
it('IPv4-mapped 주소는 IPv4 로, IPv4 는 그대로', async () => {
|
||||
await expect(trackerOf({ ip: '::ffff:203.0.113.7' })).resolves.toBe(
|
||||
'203.0.113.7',
|
||||
);
|
||||
await expect(trackerOf({ ip: '203.0.113.7' })).resolves.toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('IP 를 알 수 없으면 한 묶음(unknown)으로 센다', async () => {
|
||||
await expect(trackerOf({})).resolves.toBe('unknown');
|
||||
await expect(trackerOf({ ip: '' })).resolves.toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
import { clientIpKey } from '@/shared/utils/client-ip';
|
||||
|
||||
/**
|
||||
* 전역 요청 수 한도(APP_GUARD) — `@nestjs/throttler` 의 ThrottlerGuard 에서 **클라이언트를 세는 키만** 바꾼다.
|
||||
* 두 번째 limiter 가 아니다 (한도·저장소·`@Throttle`/`@SkipThrottle` 은 그대로 throttler 가 맡는다).
|
||||
*
|
||||
* 키는 로그인 실패 한도와 같은 `clientIpKey()` — IPv6 는 /64 로 묶고, IPv4-mapped 주소는 IPv4 로 본다.
|
||||
* 그래서 같은 회선에서 IPv6 주소를 바꿔 가며 요청 수 한도를 피하지 못한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ClientIpThrottlerGuard extends ThrottlerGuard {
|
||||
protected override getTracker(req: Record<string, unknown>): Promise<string> {
|
||||
const ip = typeof req.ip === 'string' && req.ip !== '' ? req.ip : 'unknown';
|
||||
return Promise.resolve(clientIpKey(ip));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
|
||||
/** 가드가 던진 예외의 에러 코드를 꺼낸다 */
|
||||
function codeOf(run: () => unknown): string | undefined {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('JwtAuthGuard.handleRequest', () => {
|
||||
const guard = new JwtAuthGuard();
|
||||
|
||||
it('검증된 사용자는 그대로 통과시킨다', () => {
|
||||
expect(guard.handleRequest(null, user, undefined)).toBe(user);
|
||||
});
|
||||
|
||||
it('토큰이 없으면 AUTH_001 이다', () => {
|
||||
expect(
|
||||
codeOf(() =>
|
||||
guard.handleRequest(null, false, new Error('No auth token')),
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('만료·위조 토큰도 원인을 구분하지 않고 AUTH_001 이다', () => {
|
||||
for (const message of [
|
||||
'jwt expired',
|
||||
'invalid signature',
|
||||
'jwt malformed',
|
||||
]) {
|
||||
expect(
|
||||
codeOf(() => guard.handleRequest(null, false, new Error(message))),
|
||||
).toBe('AUTH_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('예상치 못한 오류도 코드 없는 실패로 새지 않고 AUTH_001 이다', () => {
|
||||
expect(
|
||||
codeOf(() => guard.handleRequest(new Error('boom'), false, undefined)),
|
||||
).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('실패 사유는 로그 context 에만 남긴다', () => {
|
||||
try {
|
||||
guard.handleRequest(null, false, new Error('jwt expired'));
|
||||
} catch (error) {
|
||||
expect((error as BusinessException).context).toEqual({
|
||||
reason: 'jwt expired',
|
||||
});
|
||||
expect((error as BusinessException).getResponse()).not.toHaveProperty(
|
||||
'reason',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('전략이 코드와 함께 거부한 예외는 그대로 전달한다', () => {
|
||||
const rejected = new BusinessException('AUTH_001', {
|
||||
context: { reason: 'wrong-token-type' },
|
||||
});
|
||||
|
||||
expect(() => guard.handleRequest(rejected, false, undefined)).toThrow(
|
||||
rejected,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/** passport 전략 이름 — `modules/auth/jwt.strategy.ts` 가 이 이름으로 등록한다 */
|
||||
export const JWT_STRATEGY = 'jwt';
|
||||
|
||||
/**
|
||||
* 액세스 토큰 쿠키로 보호하는 가드.
|
||||
* 토큰이 없거나·만료됐거나·위조됐으면 원인을 구분하지 않고 AUTH_001 로 응답한다.
|
||||
* (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 /auth/refresh 를 한 번 시도한다)
|
||||
*
|
||||
* 사용 예:
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* @ApiCookieAuth()
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) {
|
||||
handleRequest<TUser = AuthenticatedUser>(
|
||||
error: unknown,
|
||||
user: unknown,
|
||||
info: unknown,
|
||||
): TUser {
|
||||
// 전략의 validate() 가 코드와 함께 거부한 경우 그대로 전달한다
|
||||
if (error instanceof BusinessException) throw error;
|
||||
|
||||
if (error || !user) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: describeFailure(error ?? info) },
|
||||
});
|
||||
}
|
||||
return user as TUser;
|
||||
}
|
||||
}
|
||||
|
||||
/** 토큰 원문은 남기지 않고 실패 종류만 남긴다 (예: 'No auth token', 'jwt expired') */
|
||||
function describeFailure(cause: unknown): string {
|
||||
if (cause instanceof Error) return cause.message;
|
||||
return 'unauthenticated';
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { OriginGuard } from './origin.guard';
|
||||
|
||||
function guardWith(env: Record<string, string>): OriginGuard {
|
||||
const config = {
|
||||
get: (key: string) => env[key],
|
||||
} as unknown as ConfigService;
|
||||
return new OriginGuard(config);
|
||||
}
|
||||
|
||||
function httpContext(
|
||||
method: string,
|
||||
headers: Record<string, string> = {},
|
||||
): ExecutionContext {
|
||||
const request = {
|
||||
method,
|
||||
header: (name: string) => headers[name.toLowerCase()],
|
||||
};
|
||||
return {
|
||||
getType: () => 'http',
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function codeOf(run: () => unknown): string | undefined {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('OriginGuard', () => {
|
||||
const production = guardWith({
|
||||
NODE_ENV: 'production',
|
||||
CORS_ORIGIN: 'https://mail.arasystem.it.kr',
|
||||
});
|
||||
|
||||
it('조회(GET·HEAD·OPTIONS)는 검사하지 않는다', () => {
|
||||
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
|
||||
expect(
|
||||
production.canActivate(
|
||||
httpContext(method, { 'sec-fetch-site': 'cross-site' }),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['POST', 'PUT', 'PATCH', 'DELETE'])(
|
||||
'%s 는 다른 사이트에서 오면 403 AUTH_003',
|
||||
(method) => {
|
||||
expect(
|
||||
codeOf(() =>
|
||||
production.canActivate(
|
||||
httpContext(method, {
|
||||
'sec-fetch-site': 'cross-site',
|
||||
origin: 'https://evil.test',
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBe('AUTH_003');
|
||||
},
|
||||
);
|
||||
|
||||
it('같은 주소의 화면에서 온 요청은 통과한다', () => {
|
||||
expect(
|
||||
production.canActivate(
|
||||
httpContext('POST', {
|
||||
'sec-fetch-site': 'same-origin',
|
||||
origin: 'https://mail.arasystem.it.kr',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('운영에서는 localhost 출처를 허용하지 않는다 (CORS_ORIGIN 만)', () => {
|
||||
expect(
|
||||
codeOf(() =>
|
||||
production.canActivate(
|
||||
httpContext('POST', { origin: 'http://localhost:5173' }),
|
||||
),
|
||||
),
|
||||
).toBe('AUTH_003');
|
||||
});
|
||||
|
||||
it('개발에서는 vite dev 서버 출처를 허용한다', () => {
|
||||
const development = guardWith({ NODE_ENV: 'development' });
|
||||
expect(
|
||||
development.canActivate(
|
||||
httpContext('POST', {
|
||||
'sec-fetch-site': 'same-site',
|
||||
origin: 'http://localhost:5173',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('출처 정보가 없는 요청(브라우저가 아닌 클라이언트)은 통과한다', () => {
|
||||
expect(production.canActivate(httpContext('POST'))).toBe(true);
|
||||
});
|
||||
|
||||
it('거부 사유는 로그 context 에만 남긴다', () => {
|
||||
try {
|
||||
production.canActivate(
|
||||
httpContext('DELETE', { origin: 'https://evil.test' }),
|
||||
);
|
||||
fail('예외가 발생해야 한다');
|
||||
} catch (error) {
|
||||
expect((error as BusinessException).context).toEqual({
|
||||
reason: 'cross-site-request',
|
||||
secFetchSite: null,
|
||||
origin: 'https://evil.test',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
buildAllowedOrigins,
|
||||
checkRequestOrigin,
|
||||
} from '@/shared/utils/request-origin';
|
||||
|
||||
/** 상태를 바꾸는 메서드 — 이 요청만 출처를 검사한다 (조회는 다른 사이트가 응답을 읽지 못한다) */
|
||||
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
/**
|
||||
* 출처 검사(CSRF 방어) — 전역 가드(APP_GUARD, app.module.ts).
|
||||
*
|
||||
* 쿠키 인증이라 다른 사이트가 사용자의 브라우저로 요청을 보내면 쿠키가 함께 간다.
|
||||
* SameSite 쿠키와 JSON 전용 본문 파서가 1차로 막고, 이 가드가 브라우저가 붙이는 출처 정보로 한 번 더 막는다.
|
||||
* 규칙은 `shared/utils/request-origin.ts` (docs/mail/api-contract.md 머리말) — 거부하면 403 AUTH_003.
|
||||
*
|
||||
* 가드는 인터셉터보다 먼저 돌므로 거부된 업로드는 파일을 읽기 전에 끝난다.
|
||||
* 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 이 가드에 예외 표시를 따로 만든다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OriginGuard implements CanActivate {
|
||||
private readonly allowedOrigins: ReadonlySet<string>;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.allowedOrigins = new Set(
|
||||
buildAllowedOrigins((key) => config.get<string>(key)),
|
||||
);
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (context.getType() !== 'http') return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
if (!STATE_CHANGING_METHODS.has(request.method.toUpperCase())) return true;
|
||||
|
||||
const secFetchSite = request.header('sec-fetch-site');
|
||||
const origin = request.header('origin');
|
||||
if (
|
||||
checkRequestOrigin({ secFetchSite, origin }, this.allowedOrigins) !==
|
||||
'rejected'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new BusinessException('AUTH_003', {
|
||||
context: {
|
||||
reason: 'cross-site-request',
|
||||
secFetchSite: secFetchSite ?? null,
|
||||
origin: origin ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CallHandler, ExecutionContext } from '@nestjs/common';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { lastValueFrom, of, throwError } from 'rxjs';
|
||||
|
||||
import { DeleteUploadedFileInterceptor } from './delete-uploaded-file.interceptor';
|
||||
|
||||
function contextWith(file?: { path: string }): ExecutionContext {
|
||||
const request = { file };
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
/** 임시 파일이 지워지기를 기다리는 최대 시간 — 바쁜 CI 에서도 넉넉하게 */
|
||||
const REMOVAL_DEADLINE_MS = 2_000;
|
||||
/** 지워졌는지 다시 보는 간격 */
|
||||
const REMOVAL_POLL_MS = 10;
|
||||
|
||||
/**
|
||||
* 파일이 지워질 때까지 기다린다 — 기한 안에 지워졌으면 true, 기한이 지나도 남아 있으면 false.
|
||||
* 인터셉터는 finalize 안에서 삭제를 시작만 하고 기다리지 않는다(`void this.remove(...)`) — 응답이 끝난 뒤에도 rm 이 진행 중일 수 있다.
|
||||
* 예전에는 고정으로 20ms 만 기다렸는데, 부하가 큰 CI 에서 rm 이 그보다 늦어 가끔 실패했다 (2026-10-27).
|
||||
* 그래서 지워질 때까지 자주 확인하고, 기한 안에 지워졌는지를 검사한다 — 삭제가 빠지면 기한 뒤에 그대로 실패한다.
|
||||
*/
|
||||
async function waitUntilRemoved(path: string): Promise<boolean> {
|
||||
const deadline = Date.now() + REMOVAL_DEADLINE_MS;
|
||||
while (existsSync(path)) {
|
||||
if (Date.now() >= deadline) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, REMOVAL_POLL_MS));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe('DeleteUploadedFileInterceptor', () => {
|
||||
let dir: string;
|
||||
let file: string;
|
||||
const interceptor = new DeleteUploadedFileInterceptor();
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'upload-cleanup-'));
|
||||
file = join(dir, 'upload.bin');
|
||||
writeFileSync(file, 'temp');
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it('핸들러가 성공하면 임시 파일을 지운다', async () => {
|
||||
const next: CallHandler = { handle: () => of({ id: 'blob' }) };
|
||||
|
||||
await expect(
|
||||
lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)),
|
||||
).resolves.toEqual({ id: 'blob' });
|
||||
|
||||
await expect(waitUntilRemoved(file)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('핸들러가 실패해도 임시 파일을 지운다', async () => {
|
||||
const next: CallHandler = {
|
||||
handle: () => throwError(() => new Error('engine down')),
|
||||
};
|
||||
|
||||
await expect(
|
||||
lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)),
|
||||
).rejects.toThrow('engine down');
|
||||
|
||||
await expect(waitUntilRemoved(file)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('파일이 없는 요청은 그냥 통과한다', async () => {
|
||||
const next: CallHandler = { handle: () => of(null) };
|
||||
|
||||
await expect(
|
||||
lastValueFrom(interceptor.intercept(contextWith(), next)),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { rm } from 'fs/promises';
|
||||
import { Observable, finalize } from 'rxjs';
|
||||
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
|
||||
/**
|
||||
* 임시 업로드 파일 정리 — 핸들러가 끝나면(성공·실패 모두) multer 가 디스크에 받아 둔 파일을 지운다.
|
||||
*
|
||||
* 파일을 계속 보관하는 업로드(modules/upload)가 아니라, 받은 파일을 다른 곳(메일 엔진)으로 넘기고
|
||||
* 버리는 엔드포인트에 쓴다. 반드시 FileInterceptor **뒤에** 둔다 (그래야 req.file 이 채워져 있다):
|
||||
*
|
||||
* @UseInterceptors(FileInterceptor('file'), DeleteUploadedFileInterceptor)
|
||||
*
|
||||
* multer 자체의 실패(크기 초과·연결 끊김)는 multer 가 받던 파일을 지운다 — 이 인터셉터까지 오지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DeleteUploadedFileInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(DeleteUploadedFileInterceptor.name);
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
return next.handle().pipe(finalize(() => void this.remove(request)));
|
||||
}
|
||||
|
||||
private async remove(request: Request): Promise<void> {
|
||||
const path = request.file?.path;
|
||||
if (!path) return;
|
||||
|
||||
try {
|
||||
await rm(path, { force: true });
|
||||
} catch (error) {
|
||||
// 지우지 못해도 응답은 이미 정해졌다 — 남은 파일은 기동 시 정리가 지운다
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 임시 업로드 파일을 지우지 못했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { currentRequestId } from '@/shared/logger/request-context';
|
||||
import {
|
||||
REQUEST_ID_HEADER,
|
||||
requestIdMiddleware,
|
||||
resolveRequestId,
|
||||
} from './request-id.middleware';
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
describe('resolveRequestId', () => {
|
||||
it('영문·숫자·.·_·- 1~128자면 이어받는다 (nginx $request_id 등)', () => {
|
||||
for (const value of [
|
||||
'3f1c0a2e0f1b4f2a9a1e6c9f0a1b2c3d',
|
||||
'req_2026-10-26.001',
|
||||
'a'.repeat(128),
|
||||
]) {
|
||||
expect(resolveRequestId(value)).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['없음', undefined],
|
||||
['빈 값', ''],
|
||||
['공백', 'has space'],
|
||||
['따옴표·꺾쇠', '"><script>'],
|
||||
['줄바꿈 흉내', 'a\\r\\nb'],
|
||||
['너무 김', 'a'.repeat(129)],
|
||||
['한글', '추적번호'],
|
||||
])('그 밖의 값은 버리고 새로 만든다 — %s', (_label, value) => {
|
||||
const resolved = resolveRequestId(value);
|
||||
|
||||
expect(resolved).toMatch(UUID_PATTERN);
|
||||
expect(resolved).not.toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestIdMiddleware', () => {
|
||||
function run(incoming?: string) {
|
||||
const headers: Record<string, string> = {};
|
||||
if (incoming !== undefined) headers[REQUEST_ID_HEADER] = incoming;
|
||||
const req = {
|
||||
headers,
|
||||
header: (name: string) => headers[name.toLowerCase()],
|
||||
} as unknown as Request;
|
||||
const setHeader = jest.fn();
|
||||
const res = { setHeader } as unknown as Response;
|
||||
let seenInContext: string | undefined;
|
||||
const next: NextFunction = () => {
|
||||
seenInContext = currentRequestId();
|
||||
};
|
||||
|
||||
requestIdMiddleware(req, res, next);
|
||||
return { headers, setHeader, seenInContext };
|
||||
}
|
||||
|
||||
it('정한 ID 를 요청 헤더에 다시 쓰고(예외 필터가 읽는다) 응답 헤더로 돌려주며, 이후 처리는 그 컨텍스트 안에서 돈다', () => {
|
||||
const { headers, setHeader, seenInContext } = run('trace-abc');
|
||||
|
||||
expect(headers[REQUEST_ID_HEADER]).toBe('trace-abc');
|
||||
expect(setHeader).toHaveBeenCalledWith(REQUEST_ID_HEADER, 'trace-abc');
|
||||
expect(seenInContext).toBe('trace-abc');
|
||||
});
|
||||
|
||||
it('형식이 틀린 값은 요청 헤더에서도 새 ID 로 바꾼다 (원래 값이 로그에 실리지 않게)', () => {
|
||||
const { headers, setHeader } = run('bad value <x>');
|
||||
|
||||
expect(headers[REQUEST_ID_HEADER]).toMatch(UUID_PATTERN);
|
||||
expect(setHeader).toHaveBeenCalledWith(
|
||||
REQUEST_ID_HEADER,
|
||||
headers[REQUEST_ID_HEADER],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,41 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { runWithRequestContext } from '@/shared/logger/request-context';
|
||||
|
||||
/** 요청 추적 헤더명 — 로그·에러 응답의 traceId 와 동일한 값을 사용한다. */
|
||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
||||
|
||||
/**
|
||||
* 요청 추적 ID 미들웨어.
|
||||
* 프록시/클라이언트가 보낸 값이 있으면 이어받고, 없으면 새로 발급한다.
|
||||
* 발급된 ID 는 응답 헤더로도 돌려주어 클라이언트가 문의 시 제시할 수 있게 한다.
|
||||
* 이어받을 수 있는 요청 ID 형식 — 영문·숫자·`.`·`_`·`-` 1~128자.
|
||||
* 프록시(nginx `$request_id` 등)가 붙인 값은 이어받고, 그 밖의 값(공백·따옴표·아주 긴 값 등)은 버리고 새로 만든다.
|
||||
* 클라이언트가 보낸 임의 문자열이 로그·응답 헤더에 그대로 실리지 않게 하기 위해서다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction): void {
|
||||
const incoming = req.header(REQUEST_ID_HEADER);
|
||||
const requestId =
|
||||
incoming && incoming.trim() !== '' ? incoming : randomUUID();
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
|
||||
|
||||
req.headers[REQUEST_ID_HEADER] = requestId;
|
||||
res.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
|
||||
next();
|
||||
}
|
||||
/** 들어온 값이 쓸 수 있는 형식이면 그것을, 아니면 새 UUID */
|
||||
export function resolveRequestId(incoming: string | undefined): string {
|
||||
return incoming !== undefined && REQUEST_ID_PATTERN.test(incoming)
|
||||
? incoming
|
||||
: randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 추적 ID 미들웨어 (express) — `configureApp()` 이 **가장 먼저** 등록한다.
|
||||
* 본문 파서보다 앞에 있어야 본문 파서의 실패(413·400·415)에도 응답 traceId 와 로그가 붙는다.
|
||||
*
|
||||
* 발급한 ID 는 요청 헤더에 다시 써 두고(예외 필터가 읽는다) 응답 헤더로도 돌려준다.
|
||||
* 이후의 처리는 요청 컨텍스트 안에서 돌아, 어느 계층의 로그에서도 `traceTag()` 로 같은 ID 를 붙일 수 있다.
|
||||
*/
|
||||
export function requestIdMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const requestId = resolveRequestId(req.header(REQUEST_ID_HEADER));
|
||||
|
||||
req.headers[REQUEST_ID_HEADER] = requestId;
|
||||
res.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
|
||||
runWithRequestContext({ requestId }, next);
|
||||
}
|
||||
|
||||
+34
-46
@@ -1,58 +1,58 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
import helmet from 'helmet';
|
||||
import type {
|
||||
Express,
|
||||
Request as ExpressRequest,
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { configureApp } from '@/app.setup';
|
||||
import { ACCESS_TOKEN_COOKIE } from '@/modules/auth/auth.constants';
|
||||
import { winstonConfig } from '@/shared/logger/winston.config';
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
import {
|
||||
buildAllowedOrigins,
|
||||
normalizeOrigin,
|
||||
} from '@/shared/utils/request-origin';
|
||||
|
||||
/** PORT 가 없거나 비었을 때 듣는 포트 */
|
||||
const DEFAULT_PORT = 3000;
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(winstonConfig),
|
||||
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
|
||||
bodyParser: false,
|
||||
});
|
||||
|
||||
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
|
||||
const expressApp = app.getHttpAdapter().getInstance() as Express;
|
||||
expressApp.set('trust proxy', 1);
|
||||
|
||||
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
|
||||
app.setGlobalPrefix('api', {
|
||||
// 헬스체크 / 루트 상태 엔드포인트는 prefix 미적용
|
||||
exclude: [{ path: '/', method: RequestMethod.GET }],
|
||||
});
|
||||
|
||||
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 추가
|
||||
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 (컨테이너 healthcheck 가 읽는다)
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
|
||||
res.send('Backend API Server is running 정상 구동 중입니다.');
|
||||
});
|
||||
|
||||
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
|
||||
app.use(helmet());
|
||||
// 1. 공통 HTTP 파이프라인 — prefix · helmet · 쿠키 · 본문 파서 · 검증 · 표준 응답
|
||||
// e2e 테스트도 같은 함수를 쓴다 (app.setup.ts)
|
||||
configureApp(app);
|
||||
|
||||
// 2. 화이트리스트 기반 CORS 정책
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const corsOriginEnv = process.env.CORS_ORIGIN;
|
||||
// 2. 화이트리스트 기반 CORS 정책 — 운영은 CORS_ORIGIN 만, localhost 기본값은 개발에서만.
|
||||
// 화면과 API 를 같은 주소로 배포하면 CORS 는 쓰이지 않는다 (docs/mail/api-contract.md 머리말).
|
||||
// 같은 목록을 상태 변경 요청의 출처 검사(OriginGuard)도 쓴다.
|
||||
const allowedOrigins = new Set(
|
||||
buildAllowedOrigins((key) => process.env[key]),
|
||||
);
|
||||
|
||||
app.enableCors({
|
||||
origin: (
|
||||
origin: string | undefined,
|
||||
callback: (err: Error | null, allow?: boolean) => void,
|
||||
) => {
|
||||
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
|
||||
const whitelist = [frontendUrl, 'http://localhost:3000'];
|
||||
if (corsOriginEnv) whitelist.push(corsOriginEnv);
|
||||
|
||||
if (!origin || whitelist.indexOf(origin) !== -1) {
|
||||
// 서버 간 통신(origin 없음)이거나 목록에 있는 출처일 때만 허용
|
||||
const normalized = origin === undefined ? null : normalizeOrigin(origin);
|
||||
if (!origin || (normalized !== null && allowedOrigins.has(normalized))) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
// Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로,
|
||||
@@ -67,37 +67,25 @@ async function bootstrap() {
|
||||
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
|
||||
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
|
||||
|
||||
// 4. 전역 DTO 유효성 파이프 (강력한 검증 단계)
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true, // DTO에 불필요한 값이 오면 삭제
|
||||
forbidNonWhitelisted: true, // DTO에 없는 필드가 주입되면 400 에러 발생
|
||||
transform: true, // 네트워크 데이터를 DTO나 기본 타입으로 자동 변환
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. 프론트엔드 연동을 위한 규격화된 에러 & 성공 페이로드 인터셉터 적용
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
|
||||
// 6. Swagger API 문서 설정
|
||||
// 4. Swagger API 문서 설정 — 인증은 HttpOnly 쿠키(access_token)뿐이다 (Authorization 헤더는 읽지 않는다)
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Backend API Documentation')
|
||||
.setDescription('The API documentation conforming to the global guidelines')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.addCookieAuth(ACCESS_TOKEN_COOKIE)
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
|
||||
// 7. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
|
||||
// 5. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
|
||||
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
|
||||
// (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다)
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다.
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
// compose 는 정의되지 않은 변수를 빈 문자열로 넣는다 — Number('') 는 0 이라 parsePositiveInt 로 기본값을 쓴다
|
||||
const port = parsePositiveInt(process.env.PORT, DEFAULT_PORT);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
|
||||
Logger.log(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 로그인 세션 표 (docs/mail/api-contract.md 1절 "세션 규칙") — 이 프로젝트의 첫 테이블.
|
||||
* 리프레시 토큰은 해시만 저장하고, 1회용 회전·재사용 감지·로그아웃 폐기를 이 표로 판단한다.
|
||||
* 인덱스: expires_at(만료 정리) · revoked_at 부분 인덱스(폐기된 행만 — 정리 작업의 두 번째 조건)
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/auth/auth-session.entity.ts)
|
||||
*/
|
||||
export class CreateAuthSessions1793010690903 implements MigrationInterface {
|
||||
name = 'CreateAuthSessions1793010690903';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "auth_sessions" ("id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "token_hash" character(64) NOT NULL, "previous_token_hash" character(64), "rotated_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "absolute_expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, "revoked_reason" character varying(32), CONSTRAINT "PK_641507381f32580e8479efc36cd" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_auth_sessions_expires_at" ON "auth_sessions" ("expires_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_auth_sessions_revoked_at" ON "auth_sessions" ("revoked_at") WHERE "revoked_at" IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_auth_sessions_revoked_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_auth_sessions_expires_at"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "auth_sessions"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 서명·서명 그림·사내 목록 표 (docs/mail/api-contract.md 2절 "서명" · "받는 사람 추천").
|
||||
* - mail_signatures: 사용자마다 서명 하나 (정화한 HTML·텍스트·넣을 메일)
|
||||
* - mail_signature_images: 서명 그림 정보 — 파일은 업로드 볼륨의 mail-signatures 폴더. 주인 조회용 인덱스(user_email),
|
||||
* 정리 작업용 부분 인덱스(attached_at 이 비어 있는 행의 created_at)
|
||||
* - mail_directory: 웹메일 로그인에 성공한 직원 (받는 사람 추천의 "사내")
|
||||
* 새 표만 만든다 — 기존 표(auth_sessions)는 건드리지 않는다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/mail-signature/*.entity.ts · modules/mail-directory/mail-directory.entity.ts)
|
||||
*/
|
||||
export class AddMailSignaturesAndDirectory1793084109734 implements MigrationInterface {
|
||||
name = 'AddMailSignaturesAndDirectory1793084109734';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_directory" ("email" character varying(254) NOT NULL, "name" character varying(200) NOT NULL, "last_login_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_d79c6055eed1d875a53a13a71f8" PRIMARY KEY ("email"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_signature_images" ("id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "content_type" character varying(32) NOT NULL, "size" integer NOT NULL, "width" integer NOT NULL, "height" integer NOT NULL, "file_name" character varying(64) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "attached_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_f52f2c8dbb00f2973088763a776" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_signature_images_unattached" ON "mail_signature_images" ("created_at") WHERE "attached_at" IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_signature_images_user_email" ON "mail_signature_images" ("user_email") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_signatures" ("user_email" character varying(254) NOT NULL, "body_html" text, "body_text" text, "is_for_new" boolean NOT NULL DEFAULT true, "is_for_reply" boolean NOT NULL DEFAULT true, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email"))`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "mail_signatures"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_signature_images_user_email"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_signature_images_unattached"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "mail_signature_images"`);
|
||||
await queryRunner.query(`DROP TABLE "mail_directory"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
clearSessionCookies,
|
||||
sessionCookieOptions,
|
||||
setSessionCookies,
|
||||
} from './auth-cookies';
|
||||
import type { AuthSettings } from './auth.settings';
|
||||
|
||||
const settings: AuthSettings = {
|
||||
jwtSecret: 'secret',
|
||||
accessTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 14 * 24 * 3600,
|
||||
secureCookies: false,
|
||||
};
|
||||
|
||||
function fakeResponse() {
|
||||
return {
|
||||
cookie: jest.fn(),
|
||||
clearCookie: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('세션 쿠키', () => {
|
||||
it('액세스 쿠키: HttpOnly · SameSite=Lax · Path=/api · Max-Age=액세스 만료', () => {
|
||||
expect(sessionCookieOptions('access', settings)).toEqual({
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
path: '/api',
|
||||
maxAge: 3600 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('리프레시 쿠키: HttpOnly · SameSite=Strict · Path=/api/auth · Max-Age=리프레시 만료', () => {
|
||||
expect(sessionCookieOptions('refresh', settings)).toEqual({
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
maxAge: 14 * 24 * 3600 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('운영에서는 Secure 를 붙인다', () => {
|
||||
const production = { ...settings, secureCookies: true };
|
||||
|
||||
expect(sessionCookieOptions('access', production).secure).toBe(true);
|
||||
expect(sessionCookieOptions('refresh', production).secure).toBe(true);
|
||||
});
|
||||
|
||||
it('두 쿠키를 함께 심고, 지울 때는 같은 Path·속성으로 지운다', () => {
|
||||
const res = fakeResponse();
|
||||
|
||||
setSessionCookies(
|
||||
res as unknown as Response,
|
||||
{ accessToken: 'a.b.c', refreshToken: 'd.e.f', refreshTtlSeconds: 600 },
|
||||
settings,
|
||||
);
|
||||
clearSessionCookies(res as unknown as Response, settings);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
'a.b.c',
|
||||
expect.objectContaining({ path: '/api' }),
|
||||
);
|
||||
// 리프레시 쿠키 Max-Age 는 세션의 남은 수명을 따른다 (절대 만료가 가까우면 짧아진다)
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'refresh_token',
|
||||
'd.e.f',
|
||||
expect.objectContaining({ path: '/api/auth', maxAge: 600 * 1000 }),
|
||||
);
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('access_token', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
path: '/api',
|
||||
});
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('refresh_token', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CookieOptions, Response } from 'express';
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN_COOKIE,
|
||||
ACCESS_TOKEN_COOKIE_PATH,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
REFRESH_TOKEN_COOKIE_PATH,
|
||||
} from './auth.constants';
|
||||
import type { AuthSettings } from './auth.settings';
|
||||
|
||||
/**
|
||||
* 세션 쿠키 — 계약 1절. 토큰은 응답 본문에 싣지 않고 HttpOnly 쿠키로만 주고받는다.
|
||||
*
|
||||
* | 쿠키 | SameSite | Path | Max-Age |
|
||||
* | access_token | Lax | /api | JWT_ACCESS_EXPIRES_IN |
|
||||
* | refresh_token | Strict | /api/auth | 세션의 남은 미사용 기간 (JWT_REFRESH_EXPIRES_IN, 절대 만료 30일 이내) |
|
||||
*/
|
||||
|
||||
export interface SessionTokens {
|
||||
readonly accessToken: string;
|
||||
readonly refreshToken: string;
|
||||
/** 리프레시 토큰의 남은 수명(초) — 세션 절대 만료가 가까우면 JWT_REFRESH_EXPIRES_IN 보다 짧다 */
|
||||
readonly refreshTtlSeconds: number;
|
||||
}
|
||||
|
||||
type CookieKind = 'access' | 'refresh';
|
||||
|
||||
/** 지울 때도 같은 Path·속성을 써야 브라우저가 같은 쿠키로 알아본다 */
|
||||
function baseOptions(kind: CookieKind, settings: AuthSettings): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: settings.secureCookies,
|
||||
sameSite: kind === 'access' ? 'lax' : 'strict',
|
||||
path:
|
||||
kind === 'access' ? ACCESS_TOKEN_COOKIE_PATH : REFRESH_TOKEN_COOKIE_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(
|
||||
kind: CookieKind,
|
||||
settings: AuthSettings,
|
||||
ttlSeconds: number = kind === 'access'
|
||||
? settings.accessTtlSeconds
|
||||
: settings.refreshTtlSeconds,
|
||||
): CookieOptions {
|
||||
// express 의 maxAge 는 밀리초다 (헤더에는 초 단위 Max-Age 와 Expires 로 나간다)
|
||||
return { ...baseOptions(kind, settings), maxAge: ttlSeconds * 1000 };
|
||||
}
|
||||
|
||||
export function setSessionCookies(
|
||||
res: Response,
|
||||
tokens: SessionTokens,
|
||||
settings: AuthSettings,
|
||||
): void {
|
||||
res.cookie(
|
||||
ACCESS_TOKEN_COOKIE,
|
||||
tokens.accessToken,
|
||||
sessionCookieOptions('access', settings),
|
||||
);
|
||||
res.cookie(
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
tokens.refreshToken,
|
||||
sessionCookieOptions('refresh', settings, tokens.refreshTtlSeconds),
|
||||
);
|
||||
}
|
||||
|
||||
export function clearSessionCookies(
|
||||
res: Response,
|
||||
settings: AuthSettings,
|
||||
): void {
|
||||
res.clearCookie(ACCESS_TOKEN_COOKIE, baseOptions('access', settings));
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, baseOptions('refresh', settings));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { ENDED_SESSION_RETENTION_MS } from './auth.constants';
|
||||
import { AuthSessionCleanup } from './auth-session.cleanup';
|
||||
import type { AuthSessionRepository } from './auth-session.repository';
|
||||
|
||||
describe('AuthSessionCleanup', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('보존 기간(7일)보다 먼저 끝난 세션을 지운다', async () => {
|
||||
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||
const deleteEndedBefore = jest.fn().mockResolvedValue(3);
|
||||
const cleanup = new AuthSessionCleanup({
|
||||
deleteEndedBefore,
|
||||
} as unknown as AuthSessionRepository);
|
||||
const now = new Date('2026-10-26T09:00:00.000Z');
|
||||
|
||||
await expect(cleanup.purgeEndedSessions(now)).resolves.toBe(3);
|
||||
expect(deleteEndedBefore).toHaveBeenCalledWith(
|
||||
new Date(now.getTime() - ENDED_SESSION_RETENTION_MS),
|
||||
);
|
||||
});
|
||||
|
||||
it('DB 오류가 나도 앱을 멈추지 않고 오류 로그만 남긴다 (다음 주기에 다시 한다)', async () => {
|
||||
const error = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const cleanup = new AuthSessionCleanup({
|
||||
deleteEndedBefore: jest.fn().mockRejectedValue(new Error('db down')),
|
||||
} as unknown as AuthSessionRepository);
|
||||
|
||||
await expect(cleanup.purgeEndedSessions()).resolves.toBe(0);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[SYS_001] 끝난 세션 정리에 실패했습니다'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { ENDED_SESSION_RETENTION_MS } from './auth.constants';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
|
||||
/**
|
||||
* 끝난 세션 정리 — 만료·폐기되고 보존 기간(7일)이 지난 세션을 지운다.
|
||||
* 보존 기간 동안은 "왜 로그아웃됐나요" 문의에 폐기 사유(revoked_reason)로 답할 수 있다.
|
||||
*
|
||||
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 DELETE 를 실행한다 (결과는 같지만 낭비).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthSessionCleanup {
|
||||
private readonly logger = new Logger(AuthSessionCleanup.name);
|
||||
|
||||
constructor(private readonly sessions: AuthSessionRepository) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_HOUR, { name: 'auth-session-cleanup' })
|
||||
async purgeEndedSessions(now: Date = new Date()): Promise<number> {
|
||||
try {
|
||||
const removed = await this.sessions.deleteEndedBefore(
|
||||
new Date(now.getTime() - ENDED_SESSION_RETENTION_MS),
|
||||
);
|
||||
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
|
||||
if (removed > 0)
|
||||
this.logger.debug(`끝난 세션 ${removed}건을 지웠습니다.`);
|
||||
return removed;
|
||||
} catch (error) {
|
||||
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다
|
||||
this.logger.error(
|
||||
`[SYS_001] 끝난 세션 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** SHA-256 16진 문자열 길이 */
|
||||
const TOKEN_HASH_LENGTH = 64;
|
||||
/** 폐기 사유 길이 */
|
||||
const REVOKED_REASON_LENGTH = 32;
|
||||
|
||||
/** 세션을 끝낸 사유 — 로그·문의 대응용 */
|
||||
export type SessionRevokedReason =
|
||||
| 'logout'
|
||||
| 'refresh-token-reused'
|
||||
| 'account-unavailable'
|
||||
/** 비밀번호를 바꿨다 — 바꾼 세션 밖의 그 직원 세션 (계약 1절 "PUT /api/auth/password") */
|
||||
| 'password-changed'
|
||||
/** 비밀번호 바꾸기의 결과를 알 수 없다(엔진이 바꿨을 수 있다) — 바꾼 세션 밖의 그 직원 세션 */
|
||||
| 'password-change-uncertain';
|
||||
|
||||
/**
|
||||
* 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다.
|
||||
*
|
||||
* - 리프레시 토큰 원문은 저장하지 않는다. SHA-256 해시만 둔다 (DB 가 새어도 토큰을 만들 수 없다)
|
||||
* - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증 — 세션을 끊어도 만료까지는 유효하다)
|
||||
* - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts)
|
||||
*/
|
||||
@Entity({ name: 'auth_sessions' })
|
||||
export class AuthSessionEntity {
|
||||
/** 세션 id — 리프레시 토큰의 `sid` 클레임. 토큰을 서명하기 전에 정해야 하므로 앱이 만든다 */
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
userEmail!: string;
|
||||
|
||||
/** 지금 쓸 수 있는 리프레시 토큰의 해시 */
|
||||
@Column({ name: 'token_hash', type: 'char', length: TOKEN_HASH_LENGTH })
|
||||
tokenHash!: string;
|
||||
|
||||
/** 바로 이전 토큰의 해시 — 동시 갱신 유예 판단용 (그보다 오래된 토큰이 오면 재사용) */
|
||||
@Column({
|
||||
name: 'previous_token_hash',
|
||||
type: 'char',
|
||||
length: TOKEN_HASH_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
previousTokenHash!: string | null;
|
||||
|
||||
/** 마지막 회전 시각 */
|
||||
@Column({ name: 'rotated_at', type: 'timestamptz', nullable: true })
|
||||
rotatedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
/** 미사용 만료 — 회전할 때마다 늘어나지만 절대 만료를 넘지 않는다 */
|
||||
@Index('IDX_auth_sessions_expires_at')
|
||||
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||
expiresAt!: Date;
|
||||
|
||||
/** 절대 만료 — 로그인 후 30일 */
|
||||
@Column({ name: 'absolute_expires_at', type: 'timestamptz' })
|
||||
absoluteExpiresAt!: Date;
|
||||
|
||||
/** 폐기 시각 — 정리 작업이 이 열로 찾으므로 폐기된 행만 담는 부분 인덱스를 둔다 (대부분의 행은 null) */
|
||||
@Index('IDX_auth_sessions_revoked_at', { where: '"revoked_at" IS NOT NULL' })
|
||||
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
|
||||
revokedAt!: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'revoked_reason',
|
||||
type: 'varchar',
|
||||
length: REVOKED_REASON_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
revokedReason!: SessionRevokedReason | null;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
classifyRefresh,
|
||||
hashRefreshToken,
|
||||
nextIdleExpiry,
|
||||
type SessionSnapshot,
|
||||
} from './auth-session.policy';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const NOW = new Date('2026-10-26T09:00:00.000Z');
|
||||
const GRACE_MS = 30_000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const CURRENT = hashRefreshToken('current.token');
|
||||
const PREVIOUS = hashRefreshToken('previous.token');
|
||||
const OLDER = hashRefreshToken('older.token');
|
||||
|
||||
function session(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
|
||||
return {
|
||||
userEmail: ARA,
|
||||
tokenHash: CURRENT,
|
||||
previousTokenHash: PREVIOUS,
|
||||
rotatedAt: new Date(NOW.getTime() - 5_000),
|
||||
expiresAt: new Date(NOW.getTime() + DAY_MS),
|
||||
absoluteExpiresAt: new Date(NOW.getTime() + 10 * DAY_MS),
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const classify = (snapshot: SessionSnapshot | null, hash: string, now = NOW) =>
|
||||
classifyRefresh(snapshot, { sub: ARA }, hash, now, GRACE_MS);
|
||||
|
||||
describe('classifyRefresh', () => {
|
||||
it('지금 토큰이면 current', () => {
|
||||
expect(classify(session(), CURRENT)).toBe('current');
|
||||
});
|
||||
|
||||
it('바로 이전 토큰이 유예 시간 안에 오면 grace (동시 갱신)', () => {
|
||||
expect(classify(session(), PREVIOUS)).toBe('grace');
|
||||
});
|
||||
|
||||
it('바로 이전 토큰이라도 유예 시간이 지나면 재사용(reused)', () => {
|
||||
const rotatedLongAgo = session({
|
||||
rotatedAt: new Date(NOW.getTime() - GRACE_MS),
|
||||
});
|
||||
expect(classify(rotatedLongAgo, PREVIOUS)).toBe('reused');
|
||||
});
|
||||
|
||||
it('두 번 이상 지난 토큰은 시간과 무관하게 재사용(reused)', () => {
|
||||
expect(classify(session(), OLDER)).toBe('reused');
|
||||
});
|
||||
|
||||
it('회전한 적 없는 세션에 다른 토큰이 오면 재사용(reused)', () => {
|
||||
expect(
|
||||
classify(session({ previousTokenHash: null, rotatedAt: null }), OLDER),
|
||||
).toBe('reused');
|
||||
});
|
||||
|
||||
it('끊긴 세션은 어떤 토큰이든 revoked', () => {
|
||||
const revoked = session({ revokedAt: new Date(NOW.getTime() - 1) });
|
||||
expect(classify(revoked, CURRENT)).toBe('revoked');
|
||||
expect(classify(revoked, OLDER)).toBe('revoked');
|
||||
});
|
||||
|
||||
it('미사용 만료·절대 만료가 지나면 expired', () => {
|
||||
expect(classify(session({ expiresAt: NOW }), CURRENT)).toBe('expired');
|
||||
expect(classify(session({ absoluteExpiresAt: NOW }), CURRENT)).toBe(
|
||||
'expired',
|
||||
);
|
||||
});
|
||||
|
||||
it('세션이 없거나 주인이 다르면 missing', () => {
|
||||
expect(classify(null, CURRENT)).toBe('missing');
|
||||
expect(
|
||||
classify(session({ userEmail: 'doyun.lee@arasystem.test' }), CURRENT),
|
||||
).toBe('missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashRefreshToken', () => {
|
||||
it('SHA-256 16진 64자 — 원문을 담지 않는다', () => {
|
||||
const hash = hashRefreshToken('header.payload.signature');
|
||||
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(hash).not.toContain('payload');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextIdleExpiry', () => {
|
||||
it('지금 + 미사용 기간, 단 절대 만료를 넘지 않는다', () => {
|
||||
const absolute = new Date(NOW.getTime() + 20 * DAY_MS);
|
||||
|
||||
expect(nextIdleExpiry(NOW, 14 * 24 * 3600, absolute)).toEqual(
|
||||
new Date(NOW.getTime() + 14 * DAY_MS),
|
||||
);
|
||||
expect(nextIdleExpiry(NOW, 30 * 24 * 3600, absolute)).toEqual(absolute);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createHash, timingSafeEqual } from 'crypto';
|
||||
|
||||
/** 판단에 필요한 세션 값 — 엔티티의 일부 */
|
||||
export interface SessionSnapshot {
|
||||
readonly userEmail: string;
|
||||
readonly tokenHash: string;
|
||||
readonly previousTokenHash: string | null;
|
||||
readonly rotatedAt: Date | null;
|
||||
readonly expiresAt: Date;
|
||||
readonly absoluteExpiresAt: Date;
|
||||
readonly revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 요청이 세션의 어느 상태에 해당하는가 (계약 1절 "세션 규칙").
|
||||
*
|
||||
* - current: 지금 토큰 → 회전한다
|
||||
* - grace: 바로 이전 토큰이 회전 직후 유예 시간 안에 왔다 → 동시 갱신(탭 여러 개). 새 토큰 없이 성공만
|
||||
* - reused: 이미 쓴 토큰 → 탈취로 보고 세션 전체를 끊는다
|
||||
* - revoked / expired / missing: 끝난 세션 → AUTH_001
|
||||
*/
|
||||
export type RefreshState =
|
||||
| 'current'
|
||||
| 'grace'
|
||||
| 'reused'
|
||||
| 'revoked'
|
||||
| 'expired'
|
||||
| 'missing';
|
||||
|
||||
/** 리프레시 토큰을 저장할 형태 — SHA-256 16진 (토큰은 무작위 jti 를 담아 엔트로피가 충분하다) */
|
||||
export function hashRefreshToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function sameHash(a: string, b: string): boolean {
|
||||
const left = Buffer.from(a, 'hex');
|
||||
const right = Buffer.from(b, 'hex');
|
||||
return left.length === right.length && timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
export function classifyRefresh(
|
||||
session: SessionSnapshot | null,
|
||||
claims: { readonly sub: string },
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
graceMs: number,
|
||||
): RefreshState {
|
||||
// 서명이 맞는데 주인이 다르면 우리가 만든 토큰이 아니다 — 없는 세션과 같게 다룬다
|
||||
if (!session || session.userEmail !== claims.sub) return 'missing';
|
||||
if (session.revokedAt !== null) return 'revoked';
|
||||
if (session.expiresAt <= now || session.absoluteExpiresAt <= now) {
|
||||
return 'expired';
|
||||
}
|
||||
if (sameHash(session.tokenHash, presentedHash)) return 'current';
|
||||
|
||||
const withinGrace =
|
||||
session.previousTokenHash !== null &&
|
||||
session.rotatedAt !== null &&
|
||||
sameHash(session.previousTokenHash, presentedHash) &&
|
||||
now.getTime() - session.rotatedAt.getTime() < graceMs;
|
||||
return withinGrace ? 'grace' : 'reused';
|
||||
}
|
||||
|
||||
/** 다음 미사용 만료 — 지금 + 미사용 기간, 단 절대 만료를 넘지 않는다 */
|
||||
export function nextIdleExpiry(
|
||||
now: Date,
|
||||
idleTtlSeconds: number,
|
||||
absoluteExpiresAt: Date,
|
||||
): Date {
|
||||
const idle = now.getTime() + idleTtlSeconds * 1000;
|
||||
return new Date(Math.min(idle, absoluteExpiresAt.getTime()));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, MoreThan, Not, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AuthSessionEntity,
|
||||
type SessionRevokedReason,
|
||||
} from './auth-session.entity';
|
||||
|
||||
export interface RotateSessionCommand {
|
||||
readonly id: string;
|
||||
/** 요청에 실려 온 토큰의 해시 — 지금 토큰이 이것일 때만 바꾼다 */
|
||||
readonly presentedHash: string;
|
||||
readonly nextHash: string;
|
||||
readonly nextExpiresAt: Date;
|
||||
readonly now: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 표 접근 — 판단(회전·재사용·만료)은 AuthService 와 auth-session.policy.ts 가 한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthSessionRepository {
|
||||
constructor(
|
||||
@InjectRepository(AuthSessionEntity)
|
||||
private readonly sessions: Repository<AuthSessionEntity>,
|
||||
) {}
|
||||
|
||||
async create(session: AuthSessionEntity): Promise<void> {
|
||||
await this.sessions.insert(session);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<AuthSessionEntity | null> {
|
||||
return this.sessions.findOneBy({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 1회용 회전 — **조건부 UPDATE 한 번**으로 한다.
|
||||
* 지금 토큰이 요청의 토큰이고 세션이 살아 있을 때만 바뀐다. 같은 토큰으로 동시에 온 두 요청 중
|
||||
* 하나만 성공한다 (PostgreSQL 은 먼저 잡은 쪽이 커밋한 뒤 나머지의 WHERE 를 다시 평가한다).
|
||||
* @returns 바꿨으면 true
|
||||
*/
|
||||
async rotate(command: RotateSessionCommand): Promise<boolean> {
|
||||
const result = await this.sessions.update(
|
||||
{
|
||||
id: command.id,
|
||||
tokenHash: command.presentedHash,
|
||||
revokedAt: IsNull(),
|
||||
expiresAt: MoreThan(command.now),
|
||||
absoluteExpiresAt: MoreThan(command.now),
|
||||
},
|
||||
{
|
||||
tokenHash: command.nextHash,
|
||||
previousTokenHash: command.presentedHash,
|
||||
rotatedAt: command.now,
|
||||
expiresAt: command.nextExpiresAt,
|
||||
},
|
||||
);
|
||||
return result.affected === 1;
|
||||
}
|
||||
|
||||
/** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다 */
|
||||
async revoke(
|
||||
id: string,
|
||||
reason: SessionRevokedReason,
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
await this.sessions.update(
|
||||
{ id, revokedAt: IsNull() },
|
||||
{ revokedAt: now, revokedReason: reason },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 직원의 살아 있는 세션을 모두 끝낸다 — `keepId` 세션만 남긴다 (없으면 모두) → 끝낸 수.
|
||||
* 이미 끝난(만료·폐기) 세션은 건드리지 않는다 (끝난 사유를 덮어쓰지 않게). 비밀번호를 바꿨을 때 쓴다.
|
||||
* user_email 에는 인덱스가 없다 — 표가 작고(끝난 세션은 7일 뒤 정리) 드문 일이라 순차 조회로 충분하다.
|
||||
*/
|
||||
async revokeAllOf(
|
||||
userEmail: string,
|
||||
reason: SessionRevokedReason,
|
||||
now: Date,
|
||||
keepId: string | null,
|
||||
): Promise<number> {
|
||||
const result = await this.sessions.update(
|
||||
{
|
||||
userEmail,
|
||||
revokedAt: IsNull(),
|
||||
expiresAt: MoreThan(now),
|
||||
absoluteExpiresAt: MoreThan(now),
|
||||
...(keepId !== null ? { id: Not(keepId) } : {}),
|
||||
},
|
||||
{ revokedAt: now, revokedReason: reason },
|
||||
);
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* `before` 보다 먼저 끝난(만료·폐기) 세션을 지운다 → 지운 수.
|
||||
* 미사용 만료(expires_at)는 항상 절대 만료 이하로 저장하므로(nextIdleExpiry) 절대 만료를 따로 볼 필요가 없다.
|
||||
* 두 조건 모두 인덱스를 탄다 — expires_at 인덱스, revoked_at 부분 인덱스(폐기된 행만).
|
||||
*/
|
||||
async deleteEndedBefore(before: Date): Promise<number> {
|
||||
const result = await this.sessions
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(AuthSessionEntity)
|
||||
.where('expires_at < :before OR revoked_at < :before', { before })
|
||||
.execute();
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* JWT 페이로드.
|
||||
* `typ` 으로 액세스/리프레시를 구분한다 — 리프레시 토큰을 액세스 쿠키에 넣어도 통하지 않고, 그 반대도 마찬가지다.
|
||||
*/
|
||||
export type AuthTokenType = 'access' | 'refresh';
|
||||
|
||||
export interface AuthTokenPayload {
|
||||
/** 직원 메일 주소 */
|
||||
readonly sub: string;
|
||||
readonly typ: AuthTokenType;
|
||||
/** 표시 이름 — 액세스 토큰에만 넣는다 (/auth/me 가 메일 엔진에 묻지 않고 답한다) */
|
||||
readonly name?: string;
|
||||
/** 세션 id — 리프레시 토큰에만 넣는다 (auth_sessions.id). 회전·폐기를 이 세션 기준으로 판단한다 */
|
||||
readonly sid?: string;
|
||||
/** 토큰 id — 같은 순간에 발급한 토큰도 서로 다른 값(해시)이 되게 한다 */
|
||||
readonly jti?: string;
|
||||
readonly iat?: number;
|
||||
readonly exp?: number;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 인증 상수 — 계약(docs/mail/api-contract.md 1절)의 쿠키 규칙과 한도를 한 곳에 모은다.
|
||||
*/
|
||||
|
||||
/** 액세스 JWT 쿠키 — 모든 API 경로로 간다 */
|
||||
export const ACCESS_TOKEN_COOKIE = 'access_token';
|
||||
export const ACCESS_TOKEN_COOKIE_PATH = '/api';
|
||||
|
||||
/** 리프레시 JWT 쿠키 — 인증 경로로만 간다 (다른 API 요청에 실려 다니지 않게) */
|
||||
export const REFRESH_TOKEN_COOKIE = 'refresh_token';
|
||||
export const REFRESH_TOKEN_COOKIE_PATH = '/api/auth';
|
||||
|
||||
const MINUTE_SECONDS = 60;
|
||||
const HOUR_SECONDS = 60 * MINUTE_SECONDS;
|
||||
const DAY_SECONDS = 24 * HOUR_SECONDS;
|
||||
|
||||
/** env 가 없을 때의 만료 기본값 (JWT_ACCESS_EXPIRES_IN · JWT_REFRESH_EXPIRES_IN) */
|
||||
export const DEFAULT_ACCESS_TTL_SECONDS = HOUR_SECONDS;
|
||||
/** 리프레시 = 세션의 **미사용** 만료 — 이 기간 동안 갱신하지 않으면 세션이 끝난다 (계약 1절) */
|
||||
export const DEFAULT_REFRESH_TTL_SECONDS = 14 * DAY_SECONDS;
|
||||
|
||||
/**
|
||||
* 액세스 토큰 만료 허용 범위 (env 검증) — 액세스 토큰은 서버 조회 없이 검증되므로
|
||||
* 세션을 끊어도 만료까지 유효하다. 그래서 짧게 둔다 (.claude/rules/security.md).
|
||||
*/
|
||||
export const ACCESS_TTL_MIN_SECONDS = 5 * MINUTE_SECONDS;
|
||||
export const ACCESS_TTL_MAX_SECONDS = HOUR_SECONDS;
|
||||
|
||||
/** 세션 절대 만료 — 로그인 후 이 기간이 지나면 갱신해도 끝난다 (계약 1절) */
|
||||
export const SESSION_ABSOLUTE_TTL_SECONDS = 30 * DAY_SECONDS;
|
||||
/** 리프레시(미사용 만료)는 절대 만료를 넘을 수 없다 */
|
||||
export const REFRESH_TTL_MAX_SECONDS = SESSION_ABSOLUTE_TTL_SECONDS;
|
||||
|
||||
/**
|
||||
* 직전 리프레시 토큰의 유예 시간 (밀리초).
|
||||
* 탭 두 개가 같은 리프레시 쿠키로 동시에 갱신하면 한쪽은 이미 회전된 토큰을 내게 된다.
|
||||
* 회전 직후 이 시간 안에 **바로 이전** 토큰이 오면 탈취로 보지 않고, 새 토큰 없이 성공만 돌려준다
|
||||
* (이긴 쪽 응답이 이미 새 쿠키를 구웠다). 유예 중에도 토큰을 새로 주지 않으므로 훔친 토큰으로 얻는 것은 없다.
|
||||
* 이 시간이 지난 뒤 오거나 두 번 이상 지난 토큰이 오면 재사용으로 보고 세션 전체를 끊는다.
|
||||
*/
|
||||
export const REFRESH_REUSE_GRACE_MS = 30_000;
|
||||
|
||||
/** 끝난 세션(만료·폐기)을 지우기 전까지 남겨 두는 기간 — "왜 로그아웃됐나요" 문의 확인용 */
|
||||
export const ENDED_SESSION_RETENTION_MS = 7 * DAY_SECONDS * 1000;
|
||||
|
||||
/** 서명 알고리즘 — 검증 때도 이것만 받는다 (alg 바꿔치기 방지) */
|
||||
export const JWT_ALGORITHM = 'HS256';
|
||||
|
||||
/**
|
||||
* 로그인 **실패** 한도 (계약 1절) — 성공한 로그인은 세지 않는다.
|
||||
* 사무실처럼 여러 직원이 한 IP 를 쓰면 성공까지 세는 빡빡한 한도는 정상 로그인까지 막기 때문이다.
|
||||
* 메일 엔진은 백엔드(신뢰 대역)를 자동 차단에서 빼므로 무차별 대입 방어는 백엔드 몫이다 (mail/README.md).
|
||||
* 두 한도 모두 없는 주소에도 똑같이 적용해 계정 존재 여부를 드러내지 않는다.
|
||||
*/
|
||||
/** 같은 IP 에서 1분에 실패 20회 — 여러 계정을 돌아가며 찔러 보는 공격 */
|
||||
export const LOGIN_IP_FAILURE_LIMIT = 20;
|
||||
export const LOGIN_IP_FAILURE_WINDOW_MS = 60_000;
|
||||
|
||||
/** 같은 주소로 15분에 실패 10회 — IP 를 바꿔 가며 한 계정을 노리는 공격 */
|
||||
export const LOGIN_ADDRESS_FAILURE_LIMIT = 10;
|
||||
export const LOGIN_ADDRESS_FAILURE_WINDOW_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* 로그인 요청 수 상한 (IP 당 1분) — 실패 한도와 별개로 요청 자체를 넉넉하게 제한한다.
|
||||
* 실패 한도가 무차별 대입을 막고, 이 상한은 한 IP 가 엔진 확인을 폭주시키지 못하게 한다.
|
||||
* 사무실 NAT(여러 직원이 한 IP)를 고려해 정상 사용으로는 닿지 않는 값으로 둔다.
|
||||
*/
|
||||
export const LOGIN_REQUEST_LIMIT = 60;
|
||||
export const LOGIN_REQUEST_WINDOW_MS = 60_000;
|
||||
|
||||
/** 비밀번호 최대 길이 — 아주 긴 값으로 메일 엔진의 해시 계산을 오래 붙잡지 못하게 한다 */
|
||||
export const MAX_PASSWORD_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기의 새 비밀번호 (계약 1절 "PUT /api/auth/password") — 8~128자, 길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다(웹 클라이언트와 같다).
|
||||
* 엔진도 8자 미만을 거절한다(2026-10-28 확인). 지금 비밀번호는 로그인과 같은 검사(MAX_PASSWORD_LENGTH) — 로그인보다 긴 비밀번호로는 들어올 수 없다
|
||||
*/
|
||||
export const NEW_PASSWORD_MIN_LENGTH = 8;
|
||||
export const NEW_PASSWORD_MAX_LENGTH = 128;
|
||||
@@ -0,0 +1,222 @@
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { AuthController } from './auth.controller';
|
||||
import type { AuthService } from './auth.service';
|
||||
import type { AuthSettings } from './auth.settings';
|
||||
|
||||
const settings: AuthSettings = {
|
||||
jwtSecret: 'secret',
|
||||
accessTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 1209600,
|
||||
secureCookies: false,
|
||||
};
|
||||
|
||||
const session = {
|
||||
user: { email: 'ara.kim@arasystem.test', name: '김아라' },
|
||||
tokens: {
|
||||
accessToken: 'new.access',
|
||||
refreshToken: 'new.refresh',
|
||||
refreshTtlSeconds: 1209600,
|
||||
},
|
||||
};
|
||||
|
||||
function fakeResponse() {
|
||||
return { cookie: jest.fn(), clearCookie: jest.fn() };
|
||||
}
|
||||
|
||||
/** 핸들러 함수 객체 — Nest 는 라우트 메타데이터(가드·한도)를 이 함수에 붙인다 */
|
||||
function handlerOf(
|
||||
name: 'login' | 'me' | 'refresh' | 'logout' | 'changePassword',
|
||||
): object {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
AuthController.prototype,
|
||||
name,
|
||||
);
|
||||
return descriptor?.value as object;
|
||||
}
|
||||
|
||||
describe('AuthController', () => {
|
||||
let auth: {
|
||||
login: jest.Mock;
|
||||
refresh: jest.Mock;
|
||||
logout: jest.Mock;
|
||||
changePassword: jest.Mock;
|
||||
};
|
||||
let controller: AuthController;
|
||||
|
||||
beforeEach(() => {
|
||||
auth = {
|
||||
login: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
logout: jest.fn().mockResolvedValue(undefined),
|
||||
changePassword: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
controller = new AuthController(auth as unknown as AuthService, settings);
|
||||
});
|
||||
|
||||
it('/auth/refresh 에는 가드가 없다 — 액세스 쿠키 없이 리프레시 쿠키만으로 동작한다', () => {
|
||||
const refreshGuards: unknown = Reflect.getMetadata(
|
||||
GUARDS_METADATA,
|
||||
handlerOf('refresh'),
|
||||
);
|
||||
const meGuards: unknown = Reflect.getMetadata(
|
||||
GUARDS_METADATA,
|
||||
handlerOf('me'),
|
||||
);
|
||||
|
||||
expect(refreshGuards).toBeUndefined();
|
||||
expect(meGuards).toEqual([JwtAuthGuard]);
|
||||
expect(
|
||||
Reflect.getMetadata(GUARDS_METADATA, AuthController),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('로그인에는 넉넉한 요청 수 상한(IP 당 1분 60회)이 따로 걸린다 — 무차별 대입은 실패 횟수로 막는다', () => {
|
||||
const handler = handlerOf('login');
|
||||
|
||||
expect(Reflect.getMetadata('THROTTLER:LIMITdefault', handler)).toBe(60);
|
||||
expect(Reflect.getMetadata('THROTTLER:TTLdefault', handler)).toBe(60_000);
|
||||
// 다른 인증 엔드포인트는 전역 기본값을 쓴다
|
||||
expect(
|
||||
Reflect.getMetadata('THROTTLER:LIMITdefault', handlerOf('refresh')),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('로그인은 클라이언트 IP 를 서비스에 넘기고 두 쿠키를 굽는다', async () => {
|
||||
auth.login.mockResolvedValue(session);
|
||||
const res = fakeResponse();
|
||||
|
||||
const user = await controller.login(
|
||||
{ email: 'ara.kim@arasystem.test', password: 'pw' },
|
||||
'203.0.113.10',
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
expect(auth.login).toHaveBeenCalledWith(
|
||||
'ara.kim@arasystem.test',
|
||||
'pw',
|
||||
'203.0.113.10',
|
||||
);
|
||||
expect(user).toEqual(session.user);
|
||||
expect(res.cookie).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('리프레시는 리프레시 쿠키만 읽어 서비스에 넘기고 두 쿠키를 새로 굽는다', async () => {
|
||||
auth.refresh.mockResolvedValue(session);
|
||||
const res = fakeResponse();
|
||||
const req = { cookies: { refresh_token: 'old.refresh' } };
|
||||
|
||||
const user = await controller.refresh(
|
||||
req as unknown as Request,
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
expect(auth.refresh).toHaveBeenCalledWith('old.refresh');
|
||||
expect(user).toEqual(session.user);
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
'new.access',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'refresh_token',
|
||||
'new.refresh',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('동시 갱신 유예(tokens: null)면 쿠키를 굽지 않는다 — 먼저 끝난 요청의 새 쿠키를 덮어쓰지 않게', async () => {
|
||||
auth.refresh.mockResolvedValue({ user: session.user, tokens: null });
|
||||
const res = fakeResponse();
|
||||
|
||||
const user = await controller.refresh(
|
||||
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
expect(user).toEqual(session.user);
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
expect(res.clearCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('리프레시가 AUTH_001 이면 두 쿠키를 지운다', async () => {
|
||||
auth.refresh.mockRejectedValue(new BusinessException('AUTH_001'));
|
||||
const res = fakeResponse();
|
||||
|
||||
await expect(
|
||||
controller.refresh(
|
||||
{ cookies: {} } as unknown as Request,
|
||||
res as unknown as Response,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
expect(res.clearCookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(res.clearCookie).toHaveBeenCalledWith(
|
||||
'refresh_token',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('메일 엔진 장애(MAIL_001)면 세션을 남긴다 (쿠키를 지우지 않는다)', async () => {
|
||||
auth.refresh.mockRejectedValue(new BusinessException('MAIL_001'));
|
||||
const res = fakeResponse();
|
||||
|
||||
await expect(
|
||||
controller.refresh(
|
||||
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
|
||||
res as unknown as Response,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
expect(res.clearCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('비밀번호 바꾸기는 로그인이 필요하고(JwtAuthGuard), 직원 · 두 비밀번호 · IP · 리프레시 쿠키를 서비스에 넘겨 null 을 돌려준다 — 쿠키는 굽지도 지우지도 않는다', async () => {
|
||||
expect(
|
||||
Reflect.getMetadata(GUARDS_METADATA, handlerOf('changePassword')),
|
||||
).toEqual([JwtAuthGuard]);
|
||||
// 요청 수 상한은 전역 기본값 — 무차별 대입은 로그인과 같은 실패 한도가 막는다
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
'THROTTLER:LIMITdefault',
|
||||
handlerOf('changePassword'),
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
const user = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
const req = { cookies: { refresh_token: 'this.refresh' } };
|
||||
|
||||
await expect(
|
||||
controller.changePassword(
|
||||
user,
|
||||
{ currentPassword: 'old-password', newPassword: 'new-password-1' },
|
||||
'203.0.113.10',
|
||||
req as unknown as Request,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(auth.changePassword).toHaveBeenCalledWith(
|
||||
user,
|
||||
'old-password',
|
||||
'new-password-1',
|
||||
'203.0.113.10',
|
||||
'this.refresh',
|
||||
);
|
||||
});
|
||||
|
||||
it('로그아웃은 리프레시 쿠키의 세션을 끊고, 항상 null 을 돌려주며 두 쿠키를 지운다', async () => {
|
||||
const res = fakeResponse();
|
||||
|
||||
await expect(
|
||||
controller.logout(
|
||||
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
|
||||
res as unknown as Response,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(auth.logout).toHaveBeenCalledWith('old.refresh');
|
||||
expect(res.clearCookie).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Ip,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { readCookie } from '@/shared/utils/cookies';
|
||||
import { clearSessionCookies, setSessionCookies } from './auth-cookies';
|
||||
import {
|
||||
LOGIN_REQUEST_LIMIT,
|
||||
LOGIN_REQUEST_WINDOW_MS,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
} from './auth.constants';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { AuthUserDto } from './dto/auth-user.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
/**
|
||||
* 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다.
|
||||
* 쿠키 굽기·지우기는 HTTP 응답 처리라 여기서 하고, 판단은 모두 AuthService 가 한다.
|
||||
*/
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
@Inject(AUTH_SETTINGS) private readonly settings: AuthSettings,
|
||||
) {}
|
||||
|
||||
// 무차별 대입은 **실패** 횟수로 막는다 (LoginAttemptService: IP 1분 20회 · 주소 15분 10회 — 엔진에 묻기 전에 차지).
|
||||
// 요청 수 상한은 그와 별개로 넉넉하게 — 사무실 NAT 에서 정상 로그인으로는 닿지 않는 값 (IP 당 1분 60회)
|
||||
@Post('login')
|
||||
@Throttle({
|
||||
default: { ttl: LOGIN_REQUEST_WINDOW_MS, limit: LOGIN_REQUEST_LIMIT },
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '로그인 — 성공하면 access_token·refresh_token 쿠키를 굽는다',
|
||||
})
|
||||
@ApiStandardResponse(AuthUserDto, { description: '로그인 성공' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_004',
|
||||
'계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소·메일 엔진 관리자 계정 (구분하지 않는다)',
|
||||
)
|
||||
@ApiErrorResponse('VAL_001', '형식 오류')
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'시도 과다 — 같은 IP 1분에 실패 20회 · 같은 주소 15분에 실패 10회(성공은 세지 않는다) · 같은 주소의 로그인이 처리 중 · IP 당 1분 60회 요청',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Ip() ip: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthUserDto> {
|
||||
const result = await this.auth.login(dto.email, dto.password, ip);
|
||||
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
|
||||
return result.user;
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: '현재 로그인한 직원 (앱 시작 시 세션 복원용)' })
|
||||
@ApiStandardResponse(AuthUserDto, { description: '로그인 상태' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 안 됨 — 앱 시작 시에는 정상 상태이므로 알림을 띄우지 않는다',
|
||||
)
|
||||
me(@CurrentUser() user: AuthenticatedUser): AuthUserDto {
|
||||
return { email: user.email, name: user.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* 가드를 걸지 않는다 — 액세스 쿠키가 없거나 만료된 상태에서 부르는 엔드포인트다 (세션 복원).
|
||||
* 리프레시 쿠키만 검증한다.
|
||||
*/
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '토큰 회전 — 리프레시 쿠키만으로 두 쿠키를 새로 굽는다 (1회용)',
|
||||
description:
|
||||
'액세스 쿠키가 없거나 만료돼도 동작한다. 실패(AUTH_001)하면 두 쿠키를 지운다. ' +
|
||||
'이미 쓴 리프레시 토큰이 오면 세션 전체를 끊는다. 다른 탭이 방금 회전한 직후의 이전 토큰은 30초 동안 ' +
|
||||
'새 쿠키 없이 200 으로 답한다 (이긴 요청이 이미 새 쿠키를 구웠다).',
|
||||
})
|
||||
@ApiStandardResponse(AuthUserDto, { description: '회전 성공' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'리프레시 쿠키 없음·만료·위조·이미 쓴 토큰(세션 폐기)·로그아웃한 세션·계정 비활성 — 쿠키도 지운다',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애 — 쿠키는 그대로 둔다')
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthUserDto> {
|
||||
try {
|
||||
const result = await this.auth.refresh(
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
);
|
||||
// 동시 갱신 유예면 쿠키를 굽지 않는다 — 먼저 끝난 요청이 구운 새 쿠키를 덮어쓰지 않게
|
||||
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
|
||||
return result.user;
|
||||
} catch (error) {
|
||||
// 더는 쓸 수 없는 세션이면 쿠키를 지운다. 메일 엔진 장애처럼 일시적인 실패는 세션을 남긴다
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_001') {
|
||||
clearSessionCookies(res, this.settings);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기 — 판단은 모두 AuthService 가 한다. 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다)로 이 세션을 알아
|
||||
* 그 세션만 남기고 그 직원의 다른 세션을 끊는다. 쿠키는 굽지도 지우지도 않는다 (이 세션은 그대로 이어 쓴다).
|
||||
* 요청 수 상한은 전역 기본값 — 무차별 대입은 로그인과 같은 실패 횟수 한도가 막는다.
|
||||
*/
|
||||
@Put('password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '비밀번호 바꾸기 — 지금 비밀번호로 메일 엔진에 본인 변경을 보낸다',
|
||||
description:
|
||||
'메일 엔진에 그 직원 본인의 자격(지금 비밀번호)으로 보낸다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다. ' +
|
||||
'바뀌면 이 요청의 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 (휴대폰·PC 메일 프로그램은 새 비밀번호를 다시 넣어야 한다). ' +
|
||||
'지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' +
|
||||
'지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다.',
|
||||
})
|
||||
@ApiStandardResponse(undefined, {
|
||||
description: '바꿨다 (data: null)',
|
||||
})
|
||||
// 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_005',
|
||||
'AUTH_005 지금 비밀번호가 틀림 · 지금 비밀번호로 들어갔지만 메일 엔진이 바꾸기를 거절함(변경 권한 없음 등 — 이것은 실패로 세지 않는다) — 세션은 그대로다. ' +
|
||||
'401 이 아니다(클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다) · ' +
|
||||
'VAL_001 새 비밀번호가 8자 미만 · 128자 초과(JS 문자열 길이) · 지금 비밀번호와 같음 · 메일 엔진 규칙에 어긋남, 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'지금 비밀번호 틀림이 잦음(로그인 실패와 같은 한도) · 같은 주소의 로그인·변경이 처리 중',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음·만료) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 (바뀌지 않았다) · 결과를 알 수 없게 끝남(시간 초과 · 연결 끊김 · 엔진 5xx — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 두었다)',
|
||||
)
|
||||
async changePassword(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
@Ip() ip: string,
|
||||
@Req() req: Request,
|
||||
): Promise<null> {
|
||||
await this.auth.changePassword(
|
||||
user,
|
||||
dto.currentPassword,
|
||||
dto.newPassword,
|
||||
ip,
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '로그아웃 — 항상 200, 서버의 세션을 끊고 두 쿠키를 지운다',
|
||||
})
|
||||
@ApiStandardResponse(undefined, { description: '로그아웃 (data: null)' })
|
||||
async logout(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<null> {
|
||||
await this.auth.logout(readCookie(req, REFRESH_TOKEN_COOKIE));
|
||||
clearSessionCookies(res, this.settings);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
import { AuthSessionCleanup } from './auth-session.cleanup';
|
||||
import { AuthSessionEntity } from './auth-session.entity';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AUTH_SETTINGS, buildAuthSettings } from './auth.settings';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { LoginAttemptService } from './login-attempt.service';
|
||||
|
||||
/**
|
||||
* 직원 인증 모듈 — 직원 계정 = 메일 계정 (자격 확인은 MailModule 의 포트로).
|
||||
*
|
||||
* 서명 키는 모듈 등록 값이 아니라 AUTH_SETTINGS 하나에서 읽는다 —
|
||||
* 임시 키(비운영, JWT_SECRET 없음)가 서명과 검증(JwtStrategy)에서 어긋나지 않게 하기 위해서다.
|
||||
* 보호 라우트는 `@UseGuards(JwtAuthGuard)` (common/guards) 로 건다.
|
||||
*
|
||||
* 로그인 세션은 DB(auth_sessions)에 둔다 — 스키마는 마이그레이션으로만 바꾼다 (src/migrations).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ session: false }),
|
||||
JwtModule.register({}),
|
||||
TypeOrmModule.forFeature([AuthSessionEntity]),
|
||||
MailModule,
|
||||
// 로그인에 성공한 직원을 사내 목록에 기록한다 (받는 사람 추천)
|
||||
MailDirectoryModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: AUTH_SETTINGS,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) =>
|
||||
buildAuthSettings(
|
||||
(key) => config.get<string>(key),
|
||||
(message) => new Logger('AuthModule').warn(message),
|
||||
),
|
||||
},
|
||||
AuthService,
|
||||
AuthSessionRepository,
|
||||
AuthSessionCleanup,
|
||||
LoginAttemptService,
|
||||
JwtStrategy,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,640 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
MailAccountProfile,
|
||||
PasswordChangeOutcome,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type { SessionTokens } from './auth-cookies';
|
||||
import type {
|
||||
AuthSessionEntity,
|
||||
SessionRevokedReason,
|
||||
} from './auth-session.entity';
|
||||
import {
|
||||
classifyRefresh,
|
||||
hashRefreshToken,
|
||||
nextIdleExpiry,
|
||||
type RefreshState,
|
||||
} from './auth-session.policy';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import type { AuthTokenPayload } from './auth-token.types';
|
||||
import {
|
||||
JWT_ALGORITHM,
|
||||
REFRESH_REUSE_GRACE_MS,
|
||||
SESSION_ABSOLUTE_TTL_SECONDS,
|
||||
} from './auth.constants';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { isEmployeeAddress, normalizeEmail } from './employee-address';
|
||||
import {
|
||||
LoginAttemptService,
|
||||
type LoginAttemptTicket,
|
||||
} from './login-attempt.service';
|
||||
|
||||
export interface AuthResult {
|
||||
readonly user: AuthenticatedUser;
|
||||
/**
|
||||
* 구울 쿠키. 동시 갱신 유예(같은 세션의 다른 요청이 방금 회전함)면 null —
|
||||
* 이긴 요청의 응답이 이미 새 쿠키를 구웠으므로 덮어쓰지 않는다.
|
||||
*/
|
||||
readonly tokens: SessionTokens | null;
|
||||
}
|
||||
|
||||
/** 리프레시 토큰에서 꺼낸 값 */
|
||||
interface RefreshClaims {
|
||||
readonly sub: string;
|
||||
readonly sid: string;
|
||||
}
|
||||
|
||||
/** 세션 id 형식 — DB 의 uuid 열에 넣기 전에 확인한다 (형식이 틀리면 DB 오류가 SYS_001 로 새어 나간다) */
|
||||
const SESSION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function toUser(profile: MailAccountProfile): AuthenticatedUser {
|
||||
return { email: profile.email, name: profile.name };
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 로그인 — 직원 계정 = 메일 계정.
|
||||
* 비밀번호는 로그인할 때만 메일 엔진에 확인하고 저장하지 않는다.
|
||||
* 로그인하면 서버에 세션이 생기고(auth_sessions), 리프레시 토큰은 1회용으로 회전한다 (계약 1절 "세션 규칙").
|
||||
*
|
||||
* 로그에는 비밀번호·토큰을 절대 남기지 않고, 주소는 마스킹한다 (.claude/rules/logging.md).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly mail: MailProvider,
|
||||
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
|
||||
@Inject(AUTH_SETTINGS) private readonly settings: AuthSettings,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly attempts: LoginAttemptService,
|
||||
private readonly sessions: AuthSessionRepository,
|
||||
private readonly directory: MailDirectoryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소는 구분하지 않고 AUTH_004 (.claude/rules/security.md).
|
||||
* 메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 미리 차지한다 — 동시 요청으로 한도를 넘지 못하게 (LoginAttemptService).
|
||||
* 성공·엔진 장애는 차지한 자리를 돌려준다 (성공은 세지 않는다).
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
clientIp: string,
|
||||
): Promise<AuthResult> {
|
||||
const address = normalizeEmail(email);
|
||||
if (!this.mailSettings.domain) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'mail-not-configured' },
|
||||
});
|
||||
}
|
||||
|
||||
// IP 를 알 수 없는 요청(소켓이 이미 닫힘 등)도 한 묶음으로 센다.
|
||||
// 로그인할 수 없는 주소(사내 도메인 밖·게이트웨이·관리자)는 엔진에 묻지 않고 바로 거절하므로 주소 카운터에 넣지 않는다(IP 만 센다) —
|
||||
// 아무 주소나 뿌려 주소 카운터를 채우지 못하게. 응답은 같은 AUTH_004 라 이 차이로 드러나는 것은 없다
|
||||
const ticket = this.attempts.begin({
|
||||
email: this.isLoginAddress(address) ? address : null,
|
||||
ip: clientIp || 'unknown',
|
||||
});
|
||||
|
||||
let profile: MailAccountProfile;
|
||||
try {
|
||||
profile = await this.authenticate(address, password);
|
||||
ticket.succeed();
|
||||
} catch (error) {
|
||||
// AUTH_004 로 응답하는 경우만 실패로 센다 — 엔진 장애(MAIL_001)·엔진 한도(REQ_001)는 사용자 잘못이 아니다
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_004') {
|
||||
ticket.fail();
|
||||
} else {
|
||||
ticket.abort();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const tokens = await this.startSession(profile);
|
||||
await this.recordInDirectory(profile);
|
||||
this.logger.log(`로그인: ${maskEmail(profile.email)}`);
|
||||
return { user: toUser(profile), tokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인한 직원을 사내 목록에 기록한다 (받는 사람 추천 — 계약 2절). 관리자·게이트웨이·막은 계정은 넣지 않는다.
|
||||
* 기록하지 못해도 로그인은 그대로 성공한다 — 추천에서 빠질 뿐이다.
|
||||
*/
|
||||
private async recordInDirectory(profile: MailAccountProfile): Promise<void> {
|
||||
if (!this.isLoginAddress(profile.email)) return;
|
||||
try {
|
||||
await this.directory.recordLogin({
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 사내 목록에 로그인한 직원을 기록하지 못했습니다: user=${maskEmail(profile.email)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 쿠키만으로 두 토큰을 새로 발급한다(1회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다.
|
||||
*
|
||||
* - 이미 쓴 토큰이 오면 탈취로 보고 그 세션 전체를 끊는다 → AUTH_001
|
||||
* - 회전 직후의 바로 이전 토큰은 유예한다(탭 여러 개의 동시 갱신) → 새 토큰 없이 성공
|
||||
* - 계정이 아직 살아 있는지 메일 엔진에 다시 확인한다 (사라졌으면 세션을 끊고 AUTH_001)
|
||||
* - 메일 엔진 장애는 MAIL_001 그대로 — 회전하기 전에 확인하므로 같은 토큰으로 다시 시도할 수 있다
|
||||
*/
|
||||
async refresh(refreshToken: string | undefined): Promise<AuthResult> {
|
||||
if (!refreshToken) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'refresh-token-missing' },
|
||||
});
|
||||
}
|
||||
|
||||
const claims = await this.verifyRefreshToken(refreshToken, false);
|
||||
const presentedHash = hashRefreshToken(refreshToken);
|
||||
const now = new Date();
|
||||
const session = await this.sessions.findById(claims.sid);
|
||||
|
||||
return this.resolveRefresh(
|
||||
this.classify(session, claims, presentedHash, now),
|
||||
session,
|
||||
claims,
|
||||
presentedHash,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password") — 메일 엔진에 그 직원 **본인의 자격**(지금 비밀번호)으로 보낸다.
|
||||
*
|
||||
* - 새 비밀번호가 지금과 같으면 엔진에 묻지 않고 VAL_001
|
||||
* - 막는 계정(관리자 등 로그인할 수 없는 주소)은 엔진에 묻지 않고 AUTH_001 — 웹메일로 다루지 않는 계정이다
|
||||
* - 지금 비밀번호 틀림은 로그인 실패와 **같은 한도 · 같은 카운터**로 센다 — 엔진에 묻기 전에 실패 한 번을 IP·주소에 차지하고
|
||||
* (LoginAttemptService), 틀리면 남기고(400 AUTH_005 — 401 이 아니다: 클라이언트는 401 이면 갱신하고 다시 보낸다),
|
||||
* 바뀌면 로그인 성공처럼 돌려준다. 같은 주소의 로그인·변경이 처리 중이면 REQ_001
|
||||
* - 세지 않는 실패: 지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절함(400 AUTH_005 — 맞혀 보기가 아니다) · 엔진 규칙 거절(VAL_001) · 엔진 장애
|
||||
* - 바뀌면 그 직원의 **다른** 로그인 세션을 모두 끊는다 — 이 요청의 리프레시 쿠키(Path=/api/auth 라 함께 온다)가 가리키는 세션만 남긴다.
|
||||
* 쿠키로 이 세션을 알 수 없으면 모두 끊는다 (이 탭도 액세스 토큰이 끝나면 다시 로그인한다).
|
||||
* 결과를 알 수 없게 끝났어도(엔진이 바꿨을 수 있다) 다른 세션은 끊어 두고 503 MAIL_001 로 답한다.
|
||||
* 시도는 끊기가 끝난 뒤에 끝낸다 — 그동안 같은 주소의 로그인은 처리 중(REQ_001)이라 새 비밀번호로 막 만든 세션이 함께 끊기지 않는다
|
||||
* 비밀번호는 로그 · 오류 context 에 남기지 않는다. 바꾼 일은 info 로그에 가린 주소와 끊은 세션 수만.
|
||||
*/
|
||||
async changePassword(
|
||||
user: AuthenticatedUser,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
clientIp: string,
|
||||
refreshToken: string | undefined,
|
||||
): Promise<void> {
|
||||
const address = normalizeEmail(user.email);
|
||||
if (newPassword === currentPassword) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'new-password-same-as-current' },
|
||||
});
|
||||
}
|
||||
if (!this.mailSettings.domain) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'mail-not-configured' },
|
||||
});
|
||||
}
|
||||
if (!this.isLoginAddress(address)) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'password-change-not-allowed',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 로그인과 같은 카운터 — 지금 비밀번호를 맞혀 보는 창구가 로그인 한도를 비켜 가지 못하게
|
||||
const ticket = this.attempts.begin({
|
||||
email: address,
|
||||
ip: clientIp || 'unknown',
|
||||
});
|
||||
|
||||
let outcome: PasswordChangeOutcome;
|
||||
try {
|
||||
outcome = await this.mail.changePassword(
|
||||
address,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
} catch (error) {
|
||||
// 결과가 확실한 엔진 장애(MAIL_001) · 엔진 한도(REQ_001) — 바뀌지 않았고 사용자 잘못도 아니다. 세지 않는다
|
||||
ticket.abort();
|
||||
throw error;
|
||||
}
|
||||
|
||||
switch (outcome) {
|
||||
case 'current-password-rejected':
|
||||
ticket.fail();
|
||||
throw new BusinessException('AUTH_005', {
|
||||
context: {
|
||||
reason: 'current-password-rejected',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
case 'current-password-refused':
|
||||
// 같은 값으로 엔진에 들어가기는 했다 — 맞혀 보기가 아니라 세지 않는다 (변경 권한을 빼면 맞는 비밀번호로 로그인까지 막히지 않게).
|
||||
// 엔진의 거절 종류는 어댑터가 warn 으로 남겼다
|
||||
ticket.abort();
|
||||
throw new BusinessException('AUTH_005', {
|
||||
context: {
|
||||
reason: 'current-password-refused-by-mail-engine',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
case 'new-password-rejected':
|
||||
// 지금 비밀번호는 맞았다 — 실패로 세지 않는다
|
||||
ticket.abort();
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'new-password-rejected-by-mail-engine',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
case 'outcome-unknown': {
|
||||
// 엔진이 바꿨을 수 있다 — 탈취가 의심돼 바꾸는 중일 수 있으니 다른 세션은 끊어 두고 실패로 답한다 (이 세션은 남긴다)
|
||||
const revoked = await this.revokeBeforeSettling(
|
||||
ticket,
|
||||
'abort',
|
||||
address,
|
||||
refreshToken,
|
||||
'password-change-uncertain',
|
||||
);
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: {
|
||||
reason: 'password-change-outcome-unknown',
|
||||
email: maskEmail(address),
|
||||
revokedOtherSessions: revoked,
|
||||
},
|
||||
});
|
||||
}
|
||||
case 'changed': {
|
||||
const revoked = await this.revokeBeforeSettling(
|
||||
ticket,
|
||||
'succeed',
|
||||
address,
|
||||
refreshToken,
|
||||
'password-changed',
|
||||
);
|
||||
this.logger.log(
|
||||
`비밀번호 변경: ${maskEmail(address)}, 끊은 다른 세션 ${revoked}개`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 세션을 끊은 **뒤에** 시도를 끝낸다 → 끊은 수. 끊는 동안은 같은 주소의 로그인이 진행 중(REQ_001)으로 막혀,
|
||||
* 새 비밀번호로 막 로그인한 세션이 함께 끊기지 않는다. 끊다 실패해도 시도는 반드시 끝낸다 (그 주소가 계속 막히지 않게)
|
||||
*/
|
||||
private async revokeBeforeSettling(
|
||||
ticket: LoginAttemptTicket,
|
||||
settle: 'succeed' | 'abort',
|
||||
address: string,
|
||||
refreshToken: string | undefined,
|
||||
reason: SessionRevokedReason,
|
||||
): Promise<number> {
|
||||
try {
|
||||
return await this.revokeOtherSessions(address, refreshToken, reason);
|
||||
} finally {
|
||||
if (settle === 'succeed') ticket.succeed();
|
||||
else ticket.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 그 직원의 다른 세션을 끊는다 → 끊은 수.
|
||||
* 여기서 실패해도 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있어, 실패로 답하면 사용자가 옛 비밀번호로 다시 시도해
|
||||
* AUTH_005 를 받는다. 대신 error 로그를 남긴다 (다른 세션이 남아 있다 — 운영자가 끊을 수 있게 가린 주소와 함께)
|
||||
*/
|
||||
private async revokeOtherSessions(
|
||||
address: string,
|
||||
refreshToken: string | undefined,
|
||||
reason: SessionRevokedReason,
|
||||
): Promise<number> {
|
||||
const keepId = await this.sessionIdOf(refreshToken, address);
|
||||
try {
|
||||
return await this.sessions.revokeAllOf(
|
||||
address,
|
||||
reason,
|
||||
new Date(),
|
||||
keepId,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 비밀번호 바꾸기 뒤 다른 로그인 세션을 끊지 못했습니다 — 남은 세션은 만료까지 쓸 수 있습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다) */
|
||||
private async sessionIdOf(
|
||||
refreshToken: string | undefined,
|
||||
address: string,
|
||||
): Promise<string | null> {
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const claims = await this.verifyRefreshToken(refreshToken, true);
|
||||
return normalizeEmail(claims.sub) === address ? claims.sid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200):
|
||||
* 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
|
||||
*/
|
||||
async logout(refreshToken: string | undefined): Promise<void> {
|
||||
if (!refreshToken) return;
|
||||
|
||||
let claims: RefreshClaims;
|
||||
try {
|
||||
// 만료된 토큰으로도 그 세션은 끊을 수 있게 한다 (서명은 확인한다)
|
||||
claims = await this.verifyRefreshToken(refreshToken, true);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sessions.revoke(claims.sid, 'logout', new Date());
|
||||
this.logger.log(`로그아웃: ${maskEmail(claims.sub)}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 로그아웃 세션 폐기에 실패했습니다 (쿠키는 지운다): ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인할 수 있는 주소인가 — 사내 도메인 주소이고 게이트웨이·관리자(막는 계정)가 아니어야 한다.
|
||||
* 관리자 주소는 엔진에 묻기 전에 막는다: 공개된 로그인 창이 관리자 비밀번호를 시험해 보는 창구가 되지 않게.
|
||||
*/
|
||||
private isLoginAddress(address: string): boolean {
|
||||
const { domain, gatewayUser, blockedAccounts } = this.mailSettings;
|
||||
return (
|
||||
domain !== null &&
|
||||
isEmployeeAddress(address, domain, gatewayUser, blockedAccounts)
|
||||
);
|
||||
}
|
||||
|
||||
private async authenticate(
|
||||
address: string,
|
||||
password: string,
|
||||
): Promise<MailAccountProfile> {
|
||||
if (!this.isLoginAddress(address)) {
|
||||
throw this.loginFailed(address, 'not-employee-address');
|
||||
}
|
||||
|
||||
const account = await this.mail.verifyCredentials(address, password);
|
||||
// 엔진이 돌려준 정식 주소로 한 번 더 — 별칭으로 들어와도 관리자·게이트웨이 계정이면 막는다
|
||||
if (!account || !this.isLoginAddress(account)) {
|
||||
throw this.loginFailed(address, 'rejected');
|
||||
}
|
||||
return this.loadProfileForLogin(account, address);
|
||||
}
|
||||
|
||||
private classify(
|
||||
session: AuthSessionEntity | null,
|
||||
claims: RefreshClaims,
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
): RefreshState {
|
||||
return classifyRefresh(
|
||||
session,
|
||||
claims,
|
||||
presentedHash,
|
||||
now,
|
||||
REFRESH_REUSE_GRACE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRefresh(
|
||||
state: RefreshState,
|
||||
session: AuthSessionEntity | null,
|
||||
claims: RefreshClaims,
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
): Promise<AuthResult> {
|
||||
if (state === 'current' && session) {
|
||||
return this.rotate(session, claims, presentedHash, now);
|
||||
}
|
||||
|
||||
if (state === 'grace') {
|
||||
const profile = await this.mail.getProfile(claims.sub);
|
||||
return { user: toUser(profile), tokens: null };
|
||||
}
|
||||
|
||||
if (state === 'reused') {
|
||||
await this.sessions.revoke(claims.sid, 'refresh-token-reused', now);
|
||||
this.logger.warn(
|
||||
`[AUTH_001]${traceTag()} 이미 쓴 리프레시 토큰이 다시 와서 세션을 끊었습니다 (탈취 의심): user=${maskEmail(claims.sub)}, sid=${claims.sid}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: `session-${state}`, sid: claims.sid },
|
||||
});
|
||||
}
|
||||
|
||||
private async rotate(
|
||||
session: AuthSessionEntity,
|
||||
claims: RefreshClaims,
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
): Promise<AuthResult> {
|
||||
const profile = await this.loadProfileForRefresh(claims, now);
|
||||
const expiresAt = nextIdleExpiry(
|
||||
now,
|
||||
this.settings.refreshTtlSeconds,
|
||||
session.absoluteExpiresAt,
|
||||
);
|
||||
const tokens = await this.signTokens(profile, claims.sid, expiresAt, now);
|
||||
|
||||
const rotated = await this.sessions.rotate({
|
||||
id: claims.sid,
|
||||
presentedHash,
|
||||
nextHash: hashRefreshToken(tokens.refreshToken),
|
||||
nextExpiresAt: expiresAt,
|
||||
now,
|
||||
});
|
||||
if (rotated) return { user: toUser(profile), tokens };
|
||||
|
||||
// 그 사이 다른 요청이 먼저 회전했거나 세션을 끊었다 — 지금 상태로 다시 판단한다
|
||||
const latest = await this.sessions.findById(claims.sid);
|
||||
const state = this.classify(latest, claims, presentedHash, now);
|
||||
if (state === 'grace') return { user: toUser(profile), tokens: null };
|
||||
if (state === 'current') {
|
||||
// 조건부 UPDATE 와 같은 조건인데 바뀌지 않았다 — 서버 쪽 불일치(시계 어긋남 등)라 사용자 잘못이 아니다.
|
||||
// 세션을 끊지 않는다: AUTH_001 이 아니라 SYS_001(500)로 답해 컨트롤러가 쿠키를 지우지 않게 하고,
|
||||
// 클라이언트는 같은 리프레시 쿠키로 다시 시도할 수 있다. 5xx 라 예외 필터가 error 로 남긴다
|
||||
throw new BusinessException('SYS_001', {
|
||||
context: { reason: 'rotation-conflict', sid: claims.sid },
|
||||
});
|
||||
}
|
||||
return this.resolveRefresh(state, latest, claims, presentedHash, now);
|
||||
}
|
||||
|
||||
/** 계정이 사라졌으면 세션을 끊는다. 메일 엔진 장애는 그대로 던진다 (세션은 남는다) */
|
||||
private async loadProfileForRefresh(
|
||||
claims: RefreshClaims,
|
||||
now: Date,
|
||||
): Promise<MailAccountProfile> {
|
||||
try {
|
||||
return await this.mail.getProfile(claims.sub, { fresh: true });
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_001') {
|
||||
await this.sessions.revoke(claims.sid, 'account-unavailable', now);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호는 맞지만 메일함에 접근할 수 없는 계정도 같은 실패로 응답한다 (원인을 드러내지 않는다) */
|
||||
private async loadProfileForLogin(
|
||||
account: string,
|
||||
address: string,
|
||||
): Promise<MailAccountProfile> {
|
||||
try {
|
||||
return await this.mail.getProfile(account, { fresh: true });
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_001') {
|
||||
throw this.loginFailed(address, 'mailbox-unavailable');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간 */
|
||||
private async startSession(
|
||||
profile: MailAccountProfile,
|
||||
): Promise<SessionTokens> {
|
||||
const now = new Date();
|
||||
const id = randomUUID();
|
||||
const absoluteExpiresAt = new Date(
|
||||
now.getTime() + SESSION_ABSOLUTE_TTL_SECONDS * 1000,
|
||||
);
|
||||
const expiresAt = nextIdleExpiry(
|
||||
now,
|
||||
this.settings.refreshTtlSeconds,
|
||||
absoluteExpiresAt,
|
||||
);
|
||||
const tokens = await this.signTokens(profile, id, expiresAt, now);
|
||||
|
||||
await this.sessions.create({
|
||||
id,
|
||||
userEmail: profile.email,
|
||||
tokenHash: hashRefreshToken(tokens.refreshToken),
|
||||
previousTokenHash: null,
|
||||
rotatedAt: null,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
absoluteExpiresAt,
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private async verifyRefreshToken(
|
||||
token: string,
|
||||
ignoreExpiration: boolean,
|
||||
): Promise<RefreshClaims> {
|
||||
let payload: AuthTokenPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<AuthTokenPayload>(token, {
|
||||
secret: this.settings.jwtSecret,
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
ignoreExpiration,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'invalid-refresh-token',
|
||||
cause: errorMessage(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { typ, sub, sid } = payload;
|
||||
if (
|
||||
typ !== 'refresh' ||
|
||||
typeof sub !== 'string' ||
|
||||
typeof sid !== 'string' ||
|
||||
!SESSION_ID_PATTERN.test(sid)
|
||||
) {
|
||||
// 액세스 토큰을 리프레시 쿠키에 넣은 경우·세션 저장 전에 발급된 토큰 등
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'not-a-refresh-token' },
|
||||
});
|
||||
}
|
||||
return { sub, sid };
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 토큰을 서명한다. 리프레시 토큰은 세션의 미사용 만료에, 액세스 토큰은 그보다 늦지 않게 끝난다
|
||||
* (세션보다 오래 사는 토큰을 만들지 않는다).
|
||||
*/
|
||||
private async signTokens(
|
||||
profile: MailAccountProfile,
|
||||
sessionId: string,
|
||||
refreshExpiresAt: Date,
|
||||
now: Date,
|
||||
): Promise<SessionTokens> {
|
||||
const { jwtSecret, accessTtlSeconds } = this.settings;
|
||||
const refreshTtlSeconds = Math.max(
|
||||
1,
|
||||
Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000),
|
||||
);
|
||||
const access: AuthTokenPayload = {
|
||||
sub: profile.email,
|
||||
typ: 'access',
|
||||
name: profile.name,
|
||||
};
|
||||
const refresh: AuthTokenPayload = {
|
||||
sub: profile.email,
|
||||
typ: 'refresh',
|
||||
sid: sessionId,
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwt.signAsync(access, {
|
||||
secret: jwtSecret,
|
||||
algorithm: JWT_ALGORITHM,
|
||||
expiresIn: Math.min(accessTtlSeconds, refreshTtlSeconds),
|
||||
jwtid: randomUUID(),
|
||||
}),
|
||||
this.jwt.signAsync(refresh, {
|
||||
secret: jwtSecret,
|
||||
algorithm: JWT_ALGORITHM,
|
||||
expiresIn: refreshTtlSeconds,
|
||||
jwtid: randomUUID(),
|
||||
}),
|
||||
]);
|
||||
|
||||
return { accessToken, refreshToken, refreshTtlSeconds };
|
||||
}
|
||||
|
||||
private loginFailed(email: string, reason: string): BusinessException {
|
||||
return new BusinessException('AUTH_004', {
|
||||
context: { reason, email: maskEmail(email) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { buildAuthSettings } from './auth.settings';
|
||||
|
||||
const reader =
|
||||
(values: Record<string, string>) =>
|
||||
(key: string): string | undefined =>
|
||||
values[key];
|
||||
|
||||
describe('buildAuthSettings', () => {
|
||||
it('env 의 서명 키와 만료 기간을 초 단위로 쓴다', () => {
|
||||
const warn = jest.fn();
|
||||
|
||||
const settings = buildAuthSettings(
|
||||
reader({
|
||||
NODE_ENV: 'development',
|
||||
JWT_SECRET: 'configured-secret-value',
|
||||
JWT_ACCESS_EXPIRES_IN: '15m',
|
||||
JWT_REFRESH_EXPIRES_IN: '7d',
|
||||
}),
|
||||
warn,
|
||||
);
|
||||
|
||||
expect(settings).toEqual({
|
||||
jwtSecret: 'configured-secret-value',
|
||||
accessTtlSeconds: 15 * 60,
|
||||
refreshTtlSeconds: 7 * 24 * 60 * 60,
|
||||
secureCookies: false,
|
||||
});
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('만료 기간이 없으면 계약 기본값(1h · 14d)', () => {
|
||||
const settings = buildAuthSettings(
|
||||
reader({ JWT_SECRET: 'configured-secret-value' }),
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
expect(settings.accessTtlSeconds).toBe(60 * 60);
|
||||
expect(settings.refreshTtlSeconds).toBe(14 * 24 * 60 * 60);
|
||||
});
|
||||
|
||||
it('비운영에서 서명 키가 없으면 임시 키로 기동하고 경고한다 (CI e2e)', () => {
|
||||
const warn = jest.fn<void, [string]>();
|
||||
|
||||
const first = buildAuthSettings(reader({ NODE_ENV: 'test' }), warn);
|
||||
const second = buildAuthSettings(
|
||||
reader({ NODE_ENV: 'test', JWT_SECRET: ' ' }),
|
||||
warn,
|
||||
);
|
||||
|
||||
expect(first.jwtSecret.length).toBeGreaterThanOrEqual(64);
|
||||
expect(second.jwtSecret).not.toBe(first.jwtSecret);
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn.mock.calls[0][0]).toContain('임시 서명 키');
|
||||
});
|
||||
|
||||
it('운영에서 서명 키가 없으면 기동하지 않는다', () => {
|
||||
expect(() =>
|
||||
buildAuthSettings(reader({ NODE_ENV: 'production' }), jest.fn()),
|
||||
).toThrow(/JWT_SECRET/);
|
||||
});
|
||||
|
||||
it('운영에서만 Secure 쿠키를 쓴다', () => {
|
||||
const settings = buildAuthSettings(
|
||||
reader({
|
||||
NODE_ENV: 'production',
|
||||
JWT_SECRET: 'q7Vd2mXk9LpR4tWz8nBc6yHs3gJf5aEu',
|
||||
}),
|
||||
jest.fn(),
|
||||
);
|
||||
|
||||
expect(settings.secureCookies).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { parseDurationSeconds } from '@/shared/utils/duration';
|
||||
import {
|
||||
DEFAULT_ACCESS_TTL_SECONDS,
|
||||
DEFAULT_REFRESH_TTL_SECONDS,
|
||||
} from './auth.constants';
|
||||
|
||||
/**
|
||||
* 인증 설정 — 백엔드 고유 시크릿(`backend/.env.{APP_ENV}`)에서 읽는다.
|
||||
* 쿠키 Max-Age 와 토큰 만료가 같은 값에서 나오도록 초 단위로 한 번만 해석한다.
|
||||
*/
|
||||
export const AUTH_SETTINGS = Symbol('AUTH_SETTINGS');
|
||||
|
||||
export interface AuthSettings {
|
||||
readonly jwtSecret: string;
|
||||
readonly accessTtlSeconds: number;
|
||||
readonly refreshTtlSeconds: number;
|
||||
/** 운영에서만 Secure 쿠키 (개발은 http://localhost) */
|
||||
readonly secureCookies: boolean;
|
||||
}
|
||||
|
||||
/** JWT_SECRET 이 없을 때 만드는 임시 키 길이 (바이트) */
|
||||
const TEMPORARY_SECRET_BYTES = 48;
|
||||
|
||||
/**
|
||||
* env 값을 읽어 인증 설정을 만든다.
|
||||
* JWT_SECRET 이 없으면 **비운영에서만** 무작위 임시 키로 기동하고 경고를 남긴다
|
||||
* (재시작하면 모든 세션이 끊긴다). 운영은 env 검증이 기동을 막지만 여기서도 한 번 더 막는다.
|
||||
*/
|
||||
export function buildAuthSettings(
|
||||
read: (key: string) => string | undefined,
|
||||
warn: (message: string) => void,
|
||||
): AuthSettings {
|
||||
const isProduction = read('NODE_ENV')?.trim() === 'production';
|
||||
let jwtSecret = read('JWT_SECRET')?.trim();
|
||||
|
||||
if (!jwtSecret) {
|
||||
if (isProduction) {
|
||||
throw new Error('JWT_SECRET 가 설정되지 않았습니다. (운영 환경 필수)');
|
||||
}
|
||||
jwtSecret = randomBytes(TEMPORARY_SECRET_BYTES).toString('base64url');
|
||||
warn(
|
||||
'JWT_SECRET 이 없어 임시 서명 키로 기동합니다. 재시작하면 모든 로그인 세션이 끊깁니다. (개발·테스트 전용 — 운영은 기동하지 않는다)',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
jwtSecret,
|
||||
accessTtlSeconds:
|
||||
parseDurationSeconds(read('JWT_ACCESS_EXPIRES_IN')) ??
|
||||
DEFAULT_ACCESS_TTL_SECONDS,
|
||||
refreshTtlSeconds:
|
||||
parseDurationSeconds(read('JWT_REFRESH_EXPIRES_IN')) ??
|
||||
DEFAULT_REFRESH_TTL_SECONDS,
|
||||
secureCookies: isProduction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
|
||||
/** 로그인한 직원 — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다 */
|
||||
export class AuthUserDto implements AuthenticatedUser {
|
||||
@ApiProperty({
|
||||
description: '직원 메일 주소',
|
||||
example: 'ara.kim@arasystem.it.kr',
|
||||
})
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ description: '표시 이름', example: '김아라' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import {
|
||||
MAX_PASSWORD_LENGTH,
|
||||
NEW_PASSWORD_MAX_LENGTH,
|
||||
NEW_PASSWORD_MIN_LENGTH,
|
||||
} from '@/modules/auth/auth.constants';
|
||||
import { ChangePasswordDto } from './change-password.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) — 걸린 규칙 이름만 */
|
||||
async function constraintsOf(body: object): Promise<string[]> {
|
||||
const errors = await validate(plainToInstance(ChangePasswordDto, body), {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
describe('비밀번호 바꾸기 요청 본문 (PUT /api/auth/password)', () => {
|
||||
const valid = { currentPassword: 'old-password', newPassword: 'new-pass' };
|
||||
|
||||
it(`새 비밀번호는 ${NEW_PASSWORD_MIN_LENGTH}~${NEW_PASSWORD_MAX_LENGTH}자 — 밖이면 형식 오류`, async () => {
|
||||
expect(await constraintsOf(valid)).toEqual([]);
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...valid,
|
||||
newPassword: 'x'.repeat(NEW_PASSWORD_MAX_LENGTH),
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...valid,
|
||||
newPassword: 'x'.repeat(NEW_PASSWORD_MIN_LENGTH - 1),
|
||||
}),
|
||||
).toEqual(['minCodeUnits']);
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...valid,
|
||||
newPassword: 'x'.repeat(NEW_PASSWORD_MAX_LENGTH + 1),
|
||||
}),
|
||||
).toEqual(['maxCodeUnits']);
|
||||
});
|
||||
|
||||
it('길이는 JS 문자열 길이(UTF-16 코드 단위)로 센다 — 웹 클라이언트와 같은 수 (이모지는 2자)', async () => {
|
||||
// 이모지 4개 = 8 코드 단위 — validator.js 의 MinLength 는 4자로 세어 막았을 것이다
|
||||
expect(await constraintsOf({ ...valid, newPassword: '🔒🔒🔒🔒' })).toEqual(
|
||||
[],
|
||||
);
|
||||
// 이모지 64개 + 1자 = 129 코드 단위 — 넘는다
|
||||
expect(
|
||||
await constraintsOf({ ...valid, newPassword: `${'🔒'.repeat(64)}x` }),
|
||||
).toEqual(['maxCodeUnits']);
|
||||
});
|
||||
|
||||
it(`지금 비밀번호는 로그인과 같은 한도(${MAX_PASSWORD_LENGTH}자) — 비었거나 길면 형식 오류, 문자열이 아니면 형식 오류`, async () => {
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...valid,
|
||||
currentPassword: 'x'.repeat(MAX_PASSWORD_LENGTH),
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(await constraintsOf({ ...valid, currentPassword: '' })).toEqual([
|
||||
'isNotEmpty',
|
||||
]);
|
||||
expect(
|
||||
await constraintsOf({
|
||||
...valid,
|
||||
currentPassword: 'x'.repeat(MAX_PASSWORD_LENGTH + 1),
|
||||
}),
|
||||
).toEqual(['maxLength']);
|
||||
expect(
|
||||
await constraintsOf({ ...valid, currentPassword: 12345678 }),
|
||||
).toEqual(expect.arrayContaining(['isString']));
|
||||
expect(await constraintsOf({ newPassword: 'new-pass' })).toEqual(
|
||||
expect.arrayContaining(['isString']),
|
||||
);
|
||||
});
|
||||
|
||||
it('모르는 필드는 거부한다 (확인 칸은 화면만 본다)', async () => {
|
||||
expect(
|
||||
await constraintsOf({ ...valid, newPasswordConfirm: 'new-pass' }),
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import {
|
||||
MaxCodeUnits,
|
||||
MinCodeUnits,
|
||||
} from '@/common/decorators/code-units.decorator';
|
||||
import {
|
||||
MAX_PASSWORD_LENGTH,
|
||||
NEW_PASSWORD_MAX_LENGTH,
|
||||
NEW_PASSWORD_MIN_LENGTH,
|
||||
} from '@/modules/auth/auth.constants';
|
||||
|
||||
/**
|
||||
* PUT /api/auth/password — 계약 1절.
|
||||
* 지금 비밀번호는 로그인(dto/login.dto.ts)과 같은 검사 — 로그인할 수 있었던 비밀번호는 여기서도 통과하고, 그보다 긴 값은 들어올 수 없다.
|
||||
* 새 비밀번호의 길이는 JS 문자열 길이로 센다(웹 클라이언트와 같다). 지금 비밀번호와 같으면 서비스가 400 VAL_001 (엔진에 묻지 않는다).
|
||||
* 비밀번호는 저장하지 않고 로그에 남기지 않는다.
|
||||
*/
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 — 메일 엔진이 확인한다 (로그인과 같은 한도, ${MAX_PASSWORD_LENGTH}자 이하)`,
|
||||
format: 'password',
|
||||
maxLength: MAX_PASSWORD_LENGTH,
|
||||
example: 'current-password',
|
||||
})
|
||||
@IsString({ message: '지금 비밀번호를 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '지금 비밀번호를 입력해 주세요.' })
|
||||
@MaxLength(MAX_PASSWORD_LENGTH, { message: '지금 비밀번호가 너무 깁니다.' })
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `새 비밀번호 — ${NEW_PASSWORD_MIN_LENGTH}~${NEW_PASSWORD_MAX_LENGTH}자, 지금 비밀번호와 달라야 한다 (메일 엔진의 규칙에 어긋나면 400 VAL_001)`,
|
||||
format: 'password',
|
||||
minLength: NEW_PASSWORD_MIN_LENGTH,
|
||||
maxLength: NEW_PASSWORD_MAX_LENGTH,
|
||||
example: 'new-password-1234',
|
||||
})
|
||||
@IsString({ message: '새 비밀번호를 입력해 주세요.' })
|
||||
@MinCodeUnits(NEW_PASSWORD_MIN_LENGTH, {
|
||||
message: `새 비밀번호는 ${NEW_PASSWORD_MIN_LENGTH}자 이상이어야 합니다.`,
|
||||
})
|
||||
@MaxCodeUnits(NEW_PASSWORD_MAX_LENGTH, {
|
||||
message: `새 비밀번호는 ${NEW_PASSWORD_MAX_LENGTH}자 이하여야 합니다.`,
|
||||
})
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants';
|
||||
import { MAX_EMAIL_LENGTH } from '@/modules/mail/mail.constants';
|
||||
|
||||
/** POST /api/auth/login — 직원 메일 계정 */
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
description: '직원 메일 주소 (사내 도메인)',
|
||||
example: 'ara.kim@arasystem.it.kr',
|
||||
})
|
||||
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
|
||||
@MaxLength(MAX_EMAIL_LENGTH, { message: '이메일이 너무 깁니다.' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 계정 비밀번호 — 메일 엔진에 확인만 하고 저장하지 않는다',
|
||||
format: 'password',
|
||||
example: 'your-password',
|
||||
})
|
||||
@IsString({ message: '비밀번호를 입력해 주세요.' })
|
||||
@IsNotEmpty({ message: '비밀번호를 입력해 주세요.' })
|
||||
@MaxLength(MAX_PASSWORD_LENGTH, { message: '비밀번호가 너무 깁니다.' })
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
isEmployeeAddress,
|
||||
isLocalPart,
|
||||
normalizeEmail,
|
||||
} from './employee-address';
|
||||
|
||||
const DOMAIN = 'arasystem.test';
|
||||
const GATEWAY = 'mail-gateway@arasystem.test';
|
||||
|
||||
describe('로그인 주소 정책', () => {
|
||||
it('사내 도메인의 직원 주소를 받는다', () => {
|
||||
for (const email of [
|
||||
'ara.kim@arasystem.test',
|
||||
'doyun.lee@arasystem.test',
|
||||
'a@arasystem.test',
|
||||
'team_lead-01@arasystem.test',
|
||||
]) {
|
||||
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('사내 도메인이 아니면 거절한다 (하위 도메인·비슷한 도메인 포함)', () => {
|
||||
for (const email of [
|
||||
'ara.kim@gmail.com',
|
||||
'ara.kim@mail.arasystem.test',
|
||||
'ara.kim@arasystem.test.evil.example',
|
||||
'ara.kim@xarasystem.test',
|
||||
]) {
|
||||
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('게이트웨이 계정은 직접 로그인할 수 없다', () => {
|
||||
expect(isEmployeeAddress(GATEWAY, DOMAIN, GATEWAY)).toBe(false);
|
||||
});
|
||||
|
||||
it('막는 계정(메일 관리자 등)은 사내 도메인 주소여도 로그인할 수 없다 — 다른 직원은 그대로', () => {
|
||||
const blocked = new Set(['admin@arasystem.test', 'ops@arasystem.test']);
|
||||
|
||||
expect(
|
||||
isEmployeeAddress('admin@arasystem.test', DOMAIN, GATEWAY, blocked),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isEmployeeAddress('ops@arasystem.test', DOMAIN, GATEWAY, blocked),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isEmployeeAddress('ara.kim@arasystem.test', DOMAIN, GATEWAY, blocked),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('대리 접근 구분자(%)·콜론 등이 섞인 주소는 거절한다', () => {
|
||||
for (const email of [
|
||||
'ara.kim%mail-gateway@arasystem.test',
|
||||
'ara:kim@arasystem.test',
|
||||
'"ara kim"@arasystem.test',
|
||||
'.ara@arasystem.test',
|
||||
'ara.@arasystem.test',
|
||||
'@arasystem.test',
|
||||
]) {
|
||||
expect(isEmployeeAddress(email, DOMAIN, GATEWAY)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('로컬 부분(아이디) 형식 — 직원 주소와 같은 규칙, 대소문자는 가리지 않는다 (관리자 아이디 검증에도 쓴다)', () => {
|
||||
for (const value of ['admin', 'Mail-Admin', 'a', 'ara_ops.1']) {
|
||||
expect(isLocalPart(value)).toBe(true);
|
||||
}
|
||||
for (const value of [
|
||||
'admin@arasystem.test',
|
||||
'admin%gw',
|
||||
'ad min',
|
||||
'.admin',
|
||||
'',
|
||||
'a'.repeat(65),
|
||||
]) {
|
||||
expect(isLocalPart(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('비교 전에 공백을 지우고 소문자로 맞춘다', () => {
|
||||
expect(normalizeEmail(' Ara.Kim@AraSystem.TEST ')).toBe(
|
||||
'ara.kim@arasystem.test',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 로그인 주소 정책 (순수 함수).
|
||||
* 직원 계정 = 사내 도메인의 메일 계정. 그 밖의 주소는 메일 엔진에 묻지도 않고 거절한다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 사내 주소의 로컬 부분 — 영문 소문자·숫자·`.`·`_`·`-` (앞뒤는 영문·숫자, 64자 이하).
|
||||
* 특히 `%`·`:` 를 막는다. Stalwart 는 `<대상>%<계정>` 을 대리 접근으로 해석하므로,
|
||||
* 로그인 주소에 `%` 가 섞이면 백엔드(신뢰 대역)를 통해 대리 접근 로그인이 시도될 수 있다.
|
||||
* ⚠️ mail/scripts/setup.mjs 의 LOCAL_PART_PATTERN 과 같은 규칙이다 — MAIL_ADMIN_LOCAL_PART 를 두 쪽이 함께 읽으므로,
|
||||
* 한쪽을 바꾸면 다른 쪽도 함께 바꾼다.
|
||||
*/
|
||||
const LOCAL_PART_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||||
|
||||
/**
|
||||
* 사내 주소의 로컬 부분(아이디) 형식인가 — 대소문자는 가리지 않는다 (주소는 소문자로 맞춰 비교한다).
|
||||
* 메일 엔진 관리자 아이디(MAIL_ADMIN_LOCAL_PART) 검증에도 쓴다 — env.validation.ts
|
||||
*/
|
||||
export function isLocalPart(value: string): boolean {
|
||||
return LOCAL_PART_PATTERN.test(value.toLowerCase());
|
||||
}
|
||||
|
||||
/** 비교용 정규화 — 앞뒤 공백 제거 + 소문자 */
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인할 수 있는 직원 주소인지 확인한다.
|
||||
* - 사내 도메인(MAIL_DOMAIN) 주소만
|
||||
* - 게이트웨이 서비스 계정은 직접 로그인할 수 없다
|
||||
* - 막는 계정(메일 관리자 `<MAIL_ADMIN_LOCAL_PART>@<도메인>`·MAIL_BLOCKED_ACCOUNTS)도 직원 주소가 아니다 (mail.settings.ts 의 blockedAccounts)
|
||||
*/
|
||||
export function isEmployeeAddress(
|
||||
email: string,
|
||||
domain: string,
|
||||
gatewayUser: string | null,
|
||||
blockedAccounts: ReadonlySet<string> = new Set(),
|
||||
): boolean {
|
||||
const at = email.lastIndexOf('@');
|
||||
if (at <= 0) return false;
|
||||
|
||||
const localPart = email.slice(0, at);
|
||||
const host = email.slice(at + 1);
|
||||
|
||||
return (
|
||||
host === domain &&
|
||||
LOCAL_PART_PATTERN.test(localPart) &&
|
||||
email !== gatewayUser &&
|
||||
!blockedAccounts.has(email)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
FixedWindowCounter,
|
||||
type ReserveResult,
|
||||
type WindowSlot,
|
||||
} from './fixed-window-counter';
|
||||
|
||||
const T0 = 1_000_000;
|
||||
const WINDOW = 60_000;
|
||||
|
||||
/** 자리를 차지했다고 확인하고 그 자리를 꺼낸다 */
|
||||
function slotOf(result: ReserveResult): WindowSlot {
|
||||
if (!result.ok) throw new Error(`자리를 얻지 못했다: ${result.reason}`);
|
||||
return result.slot;
|
||||
}
|
||||
|
||||
describe('FixedWindowCounter', () => {
|
||||
it('한도까지 자리를 내주고, 한도에 닿으면 limit 으로 거절', () => {
|
||||
const counter = new FixedWindowCounter(3, WINDOW, 100);
|
||||
|
||||
expect(counter.reserve('a', T0).ok).toBe(true);
|
||||
expect(counter.reserve('a', T0).ok).toBe(true);
|
||||
expect(counter.reserve('a', T0).ok).toBe(true);
|
||||
expect(counter.reserve('a', T0)).toEqual({ ok: false, reason: 'limit' });
|
||||
expect(counter.isExhausted('a', T0)).toBe(true);
|
||||
// 다른 키는 따로 센다
|
||||
expect(counter.reserve('b', T0).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('구간은 첫 기록부터 고정이다 — 기록할 때마다 늘어나지 않는다', () => {
|
||||
const counter = new FixedWindowCounter(2, WINDOW, 100);
|
||||
|
||||
counter.reserve('a', T0);
|
||||
counter.reserve('a', T0 + WINDOW - 1);
|
||||
expect(counter.reserve('a', T0 + WINDOW - 1).ok).toBe(false);
|
||||
|
||||
// 첫 기록 + WINDOW 가 지나면 0 부터 다시 센다
|
||||
expect(counter.count('a', T0 + WINDOW)).toBe(0);
|
||||
expect(counter.reserve('a', T0 + WINDOW).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('돌려준 자리는 다시 쓸 수 있다', () => {
|
||||
const counter = new FixedWindowCounter(1, WINDOW, 100);
|
||||
|
||||
const slot = slotOf(counter.reserve('a', T0));
|
||||
expect(counter.reserve('a', T0).ok).toBe(false);
|
||||
|
||||
counter.release(slot, T0 + 1);
|
||||
expect(counter.count('a', T0 + 1)).toBe(0);
|
||||
expect(counter.reserve('a', T0 + 1).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('구간이 바뀐 뒤에 돌려주면 새 구간의 수를 건드리지 않는다', () => {
|
||||
const counter = new FixedWindowCounter(5, WINDOW, 100);
|
||||
|
||||
const oldSlot = slotOf(counter.reserve('a', T0));
|
||||
counter.reserve('a', T0 + WINDOW); // 새 구간의 첫 기록
|
||||
counter.release(oldSlot, T0 + WINDOW + 1);
|
||||
|
||||
expect(counter.count('a', T0 + WINDOW + 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('clear 는 그 키의 기록을 지운다', () => {
|
||||
const counter = new FixedWindowCounter(1, WINDOW, 100);
|
||||
|
||||
counter.reserve('a', T0);
|
||||
counter.clear('a');
|
||||
expect(counter.reserve('a', T0).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('sweep 은 끝난 구간만 지운다', () => {
|
||||
const counter = new FixedWindowCounter(5, WINDOW, 100);
|
||||
|
||||
counter.reserve('old', T0);
|
||||
counter.reserve('new', T0 + 10_000);
|
||||
|
||||
expect(counter.sweep(T0 + WINDOW)).toEqual({
|
||||
removed: 1,
|
||||
evicted: 0,
|
||||
rejected: 0,
|
||||
});
|
||||
expect(counter.size).toBe(1);
|
||||
expect(counter.count('new', T0 + WINDOW)).toBe(1);
|
||||
});
|
||||
|
||||
describe('키 개수 상한', () => {
|
||||
it('상한에 닿으면 가장 먼저 끝나는 잠기지 않은 구간을 밀어내고, 밀어낸 수를 알려 준다', () => {
|
||||
const counter = new FixedWindowCounter(5, WINDOW, 3);
|
||||
|
||||
counter.reserve('k1', T0);
|
||||
counter.reserve('k2', T0 + 1);
|
||||
counter.reserve('k3', T0 + 2);
|
||||
expect(counter.reserve('k4', T0 + 3).ok).toBe(true);
|
||||
|
||||
expect(counter.size).toBe(3);
|
||||
expect(counter.count('k1', T0 + 3)).toBe(0);
|
||||
expect(counter.count('k4', T0 + 3)).toBe(1);
|
||||
expect(counter.sweep(T0 + 3).evicted).toBe(1);
|
||||
expect(counter.sweep(T0 + 3).evicted).toBe(0);
|
||||
});
|
||||
|
||||
it('잠금 중인 구간(한도에 닿은 것)은 밀어내지 않는다 — 그 다음의 잠기지 않은 구간을 밀어낸다', () => {
|
||||
const counter = new FixedWindowCounter(2, WINDOW, 3);
|
||||
|
||||
// locked 는 한도(2)에 닿아 잠겨 있다
|
||||
counter.reserve('locked', T0);
|
||||
counter.reserve('locked', T0);
|
||||
counter.reserve('open', T0 + 1);
|
||||
counter.reserve('other', T0 + 2);
|
||||
|
||||
expect(counter.reserve('new', T0 + 3).ok).toBe(true);
|
||||
expect(counter.isExhausted('locked', T0 + 3)).toBe(true);
|
||||
expect(counter.count('open', T0 + 3)).toBe(0);
|
||||
});
|
||||
|
||||
it('밀어낼 것이 없으면(모두 잠금 중) 새 키를 capacity 로 거절한다 — fail-closed, 이미 아는 키는 그대로 센다', () => {
|
||||
const counter = new FixedWindowCounter(1, WINDOW, 2);
|
||||
|
||||
counter.reserve('a', T0);
|
||||
counter.reserve('b', T0);
|
||||
|
||||
expect(counter.reserve('new', T0 + 1)).toEqual({
|
||||
ok: false,
|
||||
reason: 'capacity',
|
||||
});
|
||||
expect(counter.isExhausted('a', T0 + 1)).toBe(true);
|
||||
expect(counter.reserve('a', T0 + 1)).toEqual({
|
||||
ok: false,
|
||||
reason: 'limit',
|
||||
});
|
||||
expect(counter.sweep(T0 + 1)).toMatchObject({ rejected: 1 });
|
||||
});
|
||||
|
||||
it('잠긴 구간도 끝나면 다시 자리가 난다', () => {
|
||||
const counter = new FixedWindowCounter(1, WINDOW, 1);
|
||||
|
||||
counter.reserve('a', T0);
|
||||
expect(counter.reserve('new', T0 + 1).ok).toBe(false);
|
||||
expect(counter.reserve('new', T0 + WINDOW).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('끝난 구간은 밀어내도 경고 대상(evicted)으로 세지 않는다', () => {
|
||||
const counter = new FixedWindowCounter(5, WINDOW, 1);
|
||||
|
||||
counter.reserve('k1', T0);
|
||||
counter.reserve('k2', T0 + WINDOW);
|
||||
|
||||
expect(counter.sweep(T0 + WINDOW).evicted).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/** 차지한 자리 — 돌려줄 때 같은 구간인지 확인하는 데 쓴다 */
|
||||
export interface WindowSlot {
|
||||
readonly key: string;
|
||||
/** 자리를 차지한 구간의 끝 (ms) */
|
||||
readonly windowEndsAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자리 차지 결과.
|
||||
* - `limit`: 그 키가 이번 구간의 한도에 닿았다
|
||||
* - `capacity`: 기억하는 키가 상한에 닿았고 밀어낼 수 있는 기록(끝났거나 잠기지 않은 구간)이 없다 — 새 키를 받지 않는다(fail-closed)
|
||||
*/
|
||||
export type ReserveResult =
|
||||
| { readonly ok: true; readonly slot: WindowSlot }
|
||||
| { readonly ok: false; readonly reason: 'limit' | 'capacity' };
|
||||
|
||||
interface WindowState {
|
||||
count: number;
|
||||
endsAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상한에서 밀어낼 기록을 찾을 때 앞(가장 먼저 끝나는 쪽)에서부터 볼 최대 개수.
|
||||
* 잠긴 기록이 앞을 채우고 있어도 요청마다 전체를 훑지 않게 한다 (그 안에 없으면 새 키를 거절한다).
|
||||
*/
|
||||
const MAX_EVICTION_SCAN = 256;
|
||||
|
||||
/**
|
||||
* 고정 구간 카운터 (프로세스 메모리) — 로그인 실패 한도의 저장소.
|
||||
*
|
||||
* **확인과 증가가 한 동기 구간에서 끝난다**(await 없음). 그래서 동시에 들어온 요청들이
|
||||
* "아직 한 자리 남았다"를 함께 보고 모두 통과하는 일이 없다 — Node 는 한 스레드라 이 구간에 끼어들 수 없다.
|
||||
*
|
||||
* 기억하는 키 개수에 상한이 있다(많은 IP·주소로 메모리를 채우는 공격 대비). 상한에 닿으면
|
||||
* - 끝난 구간과 **잠기지 않은**(한도에 닿지 않은) 구간만 밀어낸다 — 잠금 중인 구간을 밀어내면 그 공격이 풀린다
|
||||
* - 밀어낼 것이 없으면 새 키를 받지 않는다(fail-closed) — 이미 기억하는 키는 그대로 센다
|
||||
*
|
||||
* ⚠️ 인스턴스마다 따로 센다. 백엔드를 여러 대로 늘리면 한도가 인스턴스 수만큼 늘어나므로,
|
||||
* 그때는 Redis 의 원자 연산(INCR + PEXPIRE 를 한 Lua 스크립트로)으로 옮겨야 한다 (.claude/skills/nestjs/SKILL.md).
|
||||
*
|
||||
* 구간은 첫 기록부터 `windowMs` 동안이다 (기록할 때마다 늘어나지 않는다).
|
||||
*/
|
||||
export class FixedWindowCounter {
|
||||
private readonly windows = new Map<string, WindowState>();
|
||||
private evictedSinceSweep = 0;
|
||||
private rejectedSinceSweep = 0;
|
||||
|
||||
constructor(
|
||||
readonly limit: number,
|
||||
readonly windowMs: number,
|
||||
/** 기억하는 키 개수 상한 — 많은 IP 로 무작위 주소를 뿌려 메모리를 채우는 공격 대비 */
|
||||
private readonly maxKeys: number,
|
||||
) {}
|
||||
|
||||
/** 지금 구간에 센 수 (구간이 끝났으면 0) */
|
||||
count(key: string, now: number = Date.now()): number {
|
||||
return this.current(key, now)?.count ?? 0;
|
||||
}
|
||||
|
||||
isExhausted(key: string, now: number = Date.now()): boolean {
|
||||
return this.count(key, now) >= this.limit;
|
||||
}
|
||||
|
||||
/** 자리 하나를 차지한다 — 확인과 증가가 한 번에 일어난다 */
|
||||
reserve(key: string, now: number = Date.now()): ReserveResult {
|
||||
const state = this.current(key, now);
|
||||
if (state) {
|
||||
if (state.count >= this.limit) return { ok: false, reason: 'limit' };
|
||||
state.count += 1;
|
||||
return { ok: true, slot: { key, windowEndsAt: state.endsAt } };
|
||||
}
|
||||
|
||||
if (!this.makeRoom(now)) {
|
||||
this.rejectedSinceSweep += 1;
|
||||
return { ok: false, reason: 'capacity' };
|
||||
}
|
||||
const endsAt = now + this.windowMs;
|
||||
this.windows.set(key, { count: 1, endsAt });
|
||||
return { ok: true, slot: { key, windowEndsAt: endsAt } };
|
||||
}
|
||||
|
||||
/** 차지한 자리를 돌려준다 — 그 구간이 아직 이어질 때만 (구간이 바뀌었으면 이미 0 부터 다시 센다) */
|
||||
release(slot: WindowSlot, now: number = Date.now()): void {
|
||||
const state = this.current(slot.key, now);
|
||||
if (!state || state.endsAt !== slot.windowEndsAt) return;
|
||||
|
||||
state.count -= 1;
|
||||
if (state.count <= 0) this.windows.delete(slot.key);
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
this.windows.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝난 구간을 지운다. 지난 정리 이후 상한 때문에 밀려난 키 수(evicted)와
|
||||
* 상한이 차서 거절한 새 키 수(rejected)도 함께 돌려준다 (로그용)
|
||||
*/
|
||||
sweep(now: number = Date.now()): {
|
||||
removed: number;
|
||||
evicted: number;
|
||||
rejected: number;
|
||||
} {
|
||||
const removed = this.removeEnded(now);
|
||||
const result = {
|
||||
removed,
|
||||
evicted: this.evictedSinceSweep,
|
||||
rejected: this.rejectedSinceSweep,
|
||||
};
|
||||
this.evictedSinceSweep = 0;
|
||||
this.rejectedSinceSweep = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.windows.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 길이가 모두 같으므로 Map 의 삽입 순서 = 구간이 끝나는 순서다.
|
||||
* 앞에서부터 끝난 것만 지우고, 아직 이어지는 구간을 만나면 멈춘다.
|
||||
*/
|
||||
private removeEnded(now: number): number {
|
||||
let removed = 0;
|
||||
for (const [key, state] of this.windows) {
|
||||
if (state.endsAt > now) break;
|
||||
this.windows.delete(key);
|
||||
removed += 1;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private current(key: string, now: number): WindowState | undefined {
|
||||
const state = this.windows.get(key);
|
||||
if (state && state.endsAt <= now) {
|
||||
this.windows.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 키를 넣을 자리를 만든다 → 만들었으면 true.
|
||||
* 가장 먼저 끝나는 쪽(삽입 순서)부터 최대 MAX_EVICTION_SCAN 개를 보며, 끝났거나 잠기지 않은 구간 하나를 밀어낸다.
|
||||
* 잠금 중인 구간(한도에 닿은 것)은 건너뛴다.
|
||||
*/
|
||||
private makeRoom(now: number): boolean {
|
||||
if (this.windows.size < this.maxKeys) return true;
|
||||
|
||||
let scanned = 0;
|
||||
for (const [key, state] of this.windows) {
|
||||
if (scanned >= MAX_EVICTION_SCAN) break;
|
||||
scanned += 1;
|
||||
|
||||
const ended = state.endsAt <= now;
|
||||
if (ended || state.count < this.limit) {
|
||||
this.windows.delete(key);
|
||||
if (!ended) this.evictedSinceSweep += 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
const strategy = new JwtStrategy({
|
||||
jwtSecret: 'unit-test-secret-unit-test-secret-1234',
|
||||
accessTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 1209600,
|
||||
secureCookies: false,
|
||||
});
|
||||
|
||||
describe('JwtStrategy.validate', () => {
|
||||
it('액세스 토큰이면 직원 정보를 돌려준다', () => {
|
||||
expect(
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
}),
|
||||
).toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' });
|
||||
});
|
||||
|
||||
it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', () => {
|
||||
const user = strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
exp: 1_793_000_000,
|
||||
});
|
||||
|
||||
expect(user).toStrictEqual({
|
||||
email: 'ara.kim@arasystem.test',
|
||||
name: '김아라',
|
||||
accessTokenExpiresAt: 1_793_000_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => {
|
||||
expect(() =>
|
||||
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
|
||||
).toThrow(BusinessException);
|
||||
|
||||
try {
|
||||
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' });
|
||||
} catch (error) {
|
||||
expect((error as BusinessException).code).toBe('AUTH_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JWT_STRATEGY } from '@/common/guards/jwt-auth.guard';
|
||||
import { readCookie } from '@/shared/utils/cookies';
|
||||
import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import type { AuthTokenPayload } from './auth-token.types';
|
||||
|
||||
/** JWT 의 시각(exp)은 초 단위다 */
|
||||
const MS_PER_SECOND = 1000;
|
||||
|
||||
/** 액세스 토큰은 Authorization 헤더가 아니라 HttpOnly 쿠키에서만 읽는다 (계약 1절) */
|
||||
function accessTokenFromCookie(request: Request): string | null {
|
||||
return readCookie(request, ACCESS_TOKEN_COOKIE) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다.
|
||||
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양을 확인한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
constructor(@Inject(AUTH_SETTINGS) settings: AuthSettings) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]),
|
||||
secretOrKey: settings.jwtSecret,
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
ignoreExpiration: false,
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: AuthTokenPayload): AuthenticatedUser {
|
||||
if (
|
||||
payload.typ !== 'access' ||
|
||||
typeof payload.sub !== 'string' ||
|
||||
typeof payload.name !== 'string'
|
||||
) {
|
||||
// 리프레시 토큰을 액세스 쿠키에 넣은 경우 등
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'not-an-access-token' },
|
||||
});
|
||||
}
|
||||
return {
|
||||
email: payload.sub,
|
||||
name: payload.name,
|
||||
// 만료(exp, 초)는 passport-jwt 가 이미 확인했다 — 오래 열린 연결이 토큰보다 오래 가지 않게 넘겨 둔다 (실시간 알림)
|
||||
...(typeof payload.exp === 'number'
|
||||
? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_WINDOW_MS,
|
||||
} from './auth.constants';
|
||||
import { FixedWindowCounter } from './fixed-window-counter';
|
||||
import {
|
||||
LoginAttemptService,
|
||||
type LoginAttemptSource,
|
||||
} from './login-attempt.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const IP = '203.0.113.10';
|
||||
|
||||
function reasonOf(run: () => unknown): unknown {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) {
|
||||
return { code: error.code, ...error.context };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return 'allowed';
|
||||
}
|
||||
|
||||
describe('LoginAttemptService', () => {
|
||||
let service: LoginAttemptService;
|
||||
let now: number;
|
||||
|
||||
/** 시도 하나를 시작하자마자 결과를 정한다 */
|
||||
const attempt = (
|
||||
source: LoginAttemptSource,
|
||||
outcome: 'fail' | 'succeed' | 'abort',
|
||||
) => service.begin(source)[outcome]();
|
||||
|
||||
beforeEach(() => {
|
||||
service = new LoginAttemptService();
|
||||
now = 1_700_000_000_000;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('같은 주소로 실패 10회가 쌓이면 11번째 시도는 REQ_001 — IP 를 바꿔도 마찬가지', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toEqual({
|
||||
code: 'REQ_001',
|
||||
reason: 'login-failures-exceeded',
|
||||
scope: 'address',
|
||||
email: 'a***@arasystem.test',
|
||||
});
|
||||
});
|
||||
|
||||
it('같은 IP 에서 실패 20회가 쌓이면 처음 보는 주소도 REQ_001', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
|
||||
}
|
||||
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({ email: 'new.person@arasystem.test', ip: IP }),
|
||||
),
|
||||
).toMatchObject({ code: 'REQ_001', scope: 'ip' });
|
||||
// 다른 IP 는 영향이 없다
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({ email: 'new.person@arasystem.test', ip: '192.0.2.1' }),
|
||||
),
|
||||
).toBe('allowed');
|
||||
});
|
||||
|
||||
it('성공은 세지 않는다 — 같은 IP 에서 한도보다 많이 성공해도 막히지 않는다 (사무실 NAT)', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 3; i += 1) {
|
||||
attempt({ email: `staff${i}@arasystem.test`, ip: IP }, 'succeed');
|
||||
}
|
||||
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
|
||||
'allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('성공하면 그 주소의 실패 기록을 지운다 — IP 기록은 남긴다', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT - 1; i += 1) {
|
||||
attempt({ email: ARA, ip: IP }, 'fail');
|
||||
}
|
||||
attempt({ email: ARA, ip: IP }, 'succeed');
|
||||
|
||||
// 주소: 다시 10번 실패할 수 있다
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.9' })),
|
||||
).toMatchObject({ scope: 'address' });
|
||||
|
||||
// IP: 앞의 실패 9회는 지워지지 않았다 → 11번 더 실패하면 한도(20)
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT - 9; i += 1) {
|
||||
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
|
||||
}
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: 'x@arasystem.test', ip: IP })),
|
||||
).toMatchObject({ scope: 'ip' });
|
||||
});
|
||||
|
||||
it('판단하지 못한 시도(엔진 장애)는 두 자리를 모두 돌려준다', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT * 3; i += 1) {
|
||||
attempt({ email: ARA, ip: IP }, 'abort');
|
||||
}
|
||||
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
|
||||
'allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('같은 주소의 시도가 진행 중이면 다음 시도는 기다리지 않고 REQ_001 — 끝나면 다시 받는다', () => {
|
||||
const first = service.begin({ email: ARA, ip: IP });
|
||||
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.1' })),
|
||||
).toMatchObject({ code: 'REQ_001', reason: 'login-in-progress' });
|
||||
// 다른 주소는 같은 IP 라도 동시에 진행할 수 있다
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: 'doyun@arasystem.test', ip: IP })),
|
||||
).toBe('allowed');
|
||||
|
||||
first.fail();
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
|
||||
'allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('결과는 한 번만 반영된다 — 실패 뒤 abort 가 불려도 실패가 지워지지 않는다', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
const ticket = service.begin({ email: ARA, ip: IP });
|
||||
ticket.fail();
|
||||
ticket.abort();
|
||||
ticket.succeed();
|
||||
}
|
||||
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toMatchObject(
|
||||
{ scope: 'address' },
|
||||
);
|
||||
});
|
||||
|
||||
it('구간이 지나면 다시 시도할 수 있다 (IP 1분 · 주소 15분)', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
|
||||
}
|
||||
now += LOGIN_IP_FAILURE_WINDOW_MS;
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: 'later@arasystem.test', ip: IP })),
|
||||
).toBe('allowed');
|
||||
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
now += LOGIN_ADDRESS_FAILURE_WINDOW_MS - 1;
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.2' })),
|
||||
).toMatchObject({ scope: 'address' });
|
||||
now += 1;
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.3' }))).toBe(
|
||||
'allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('주소 한도로 거부되면 IP 자리도 돌려준다 — 막힌 주소를 두드려 IP 한도를 대신 소모시키지 못한다', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 2; i += 1) {
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: ARA, ip: IP })),
|
||||
).toMatchObject({ scope: 'address' });
|
||||
}
|
||||
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: 'doyun@arasystem.test', ip: IP })),
|
||||
).toBe('allowed');
|
||||
});
|
||||
|
||||
it('정리 작업은 상한 때문에 일찍 지운 기록이 있을 때만 경고한다', () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
service.sweep(now);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('IP 묶기 (요청 수 한도와 같은 clientIpKey)', () => {
|
||||
it('IPv6 는 /64 로 묶는다 — 같은 회선에서 주소를 바꿔 가며 IP 한도를 피하지 못한다', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
attempt(
|
||||
{ email: `guess${i}@arasystem.test`, ip: `2001:db8:7:7::${i + 1}` },
|
||||
'fail',
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({
|
||||
email: 'new@arasystem.test',
|
||||
ip: '2001:db8:7:7:abcd::1',
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ scope: 'ip', ip: '2001:db8:7:7::/64' });
|
||||
// 다른 /64 는 영향이 없다
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({ email: 'new@arasystem.test', ip: '2001:db8:7:8::1' }),
|
||||
),
|
||||
).toBe('allowed');
|
||||
});
|
||||
|
||||
it('IPv4-mapped 주소와 IPv4 주소는 같은 IP 로 센다', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT / 2; i += 1) {
|
||||
attempt({ email: `a${i}@arasystem.test`, ip: '203.0.113.7' }, 'fail');
|
||||
attempt(
|
||||
{ email: `b${i}@arasystem.test`, ip: '::ffff:203.0.113.7' },
|
||||
'fail',
|
||||
);
|
||||
}
|
||||
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({ email: 'c@arasystem.test', ip: '203.0.113.7' }),
|
||||
),
|
||||
).toMatchObject({ scope: 'ip' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('사내 도메인이 아닌 주소 (email: null)', () => {
|
||||
it('주소 카운터에 넣지 않고 IP 만 센다 — 진행 중 잠금도 없다', () => {
|
||||
// 같은 IP 에서 여러 번 실패해도 주소 한도는 걸리지 않고, IP 한도만 쌓인다
|
||||
const tickets = Array.from({ length: 3 }, () =>
|
||||
service.begin({ email: null, ip: IP }),
|
||||
);
|
||||
tickets.forEach((ticket) => ticket.fail());
|
||||
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT - 3; i += 1) {
|
||||
attempt({ email: null, ip: IP }, 'fail');
|
||||
}
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: null, ip: IP })),
|
||||
).toMatchObject({ scope: 'ip' });
|
||||
});
|
||||
|
||||
it('사내 주소의 주소 한도와 섞이지 않는다', () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: null, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: ARA, ip: '192.0.2.5' })),
|
||||
).toBe('allowed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('기억하는 키 상한 (fail-closed)', () => {
|
||||
/** 상한을 작게 바꾼 서비스 — 내부 카운터를 같은 규칙의 작은 카운터로 바꿔 끼운다 */
|
||||
function withCapacity(maxKeys: number): LoginAttemptService {
|
||||
const small = new LoginAttemptService();
|
||||
Reflect.set(
|
||||
small,
|
||||
'ipFailures',
|
||||
new FixedWindowCounter(
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_WINDOW_MS,
|
||||
maxKeys,
|
||||
),
|
||||
);
|
||||
return small;
|
||||
}
|
||||
|
||||
it('상한이 차고 모두 잠금 중이면 새 IP 는 REQ_001(login-tracking-full) 이고, 처음 한 번 error 로그를 남긴다', () => {
|
||||
const error = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const small = withCapacity(1);
|
||||
// 한 IP 를 한도까지 채워 잠근다 (밀어낼 수 없는 기록)
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
small.begin({ email: null, ip: '198.51.100.1' }).fail();
|
||||
}
|
||||
|
||||
for (const ip of ['198.51.100.2', '198.51.100.3']) {
|
||||
try {
|
||||
small.begin({ email: null, ip });
|
||||
fail('거절해야 한다');
|
||||
} catch (rejection) {
|
||||
expect((rejection as BusinessException).code).toBe('REQ_001');
|
||||
expect((rejection as BusinessException).context).toMatchObject({
|
||||
reason: 'login-tracking-full',
|
||||
});
|
||||
}
|
||||
}
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
expect(error.mock.calls[0][0]).toMatch(/\[REQ_001\].*fail-closed/);
|
||||
|
||||
// 정리 주기에 거절 건수를 error 로 합산하고, 다음 주기에는 다시 처음 한 번 알린다
|
||||
small.sweep(now);
|
||||
expect(error).toHaveBeenCalledTimes(2);
|
||||
expect(error.mock.calls[1][0]).toMatch(/2건을 거절했습니다/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { clientIpKey } from '@/shared/utils/client-ip';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import {
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_WINDOW_MS,
|
||||
} from './auth.constants';
|
||||
import {
|
||||
FixedWindowCounter,
|
||||
type ReserveResult,
|
||||
type WindowSlot,
|
||||
} from './fixed-window-counter';
|
||||
|
||||
/** 끝난 구간을 지우는 주기 */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 기억하는 IP·주소 개수 상한 (카운터마다). 키 하나는 수백 바이트라 상한에서도 수십 MB 안쪽이다.
|
||||
* 넘치면 끝났거나 잠기지 않은 구간만 밀어내고, 밀어낼 것이 없으면 새 키를 거절한다 (fixed-window-counter.ts).
|
||||
*/
|
||||
const MAX_TRACKED_KEYS = 100_000;
|
||||
|
||||
type Scope = 'ip' | 'address';
|
||||
type RejectReason =
|
||||
| 'login-failures-exceeded'
|
||||
| 'login-in-progress'
|
||||
| 'login-tracking-full';
|
||||
|
||||
/** 로그인 시도 하나를 세는 기준 */
|
||||
export interface LoginAttemptSource {
|
||||
/**
|
||||
* 정규화한 로그인 주소 — 사내 도메인 주소만 넣는다(없는 주소도 그대로 센다).
|
||||
* 사내 도메인이 아닌 주소는 null: 엔진에 묻지 않고 바로 거절하므로 IP 만 센다.
|
||||
* (도메인 규칙은 공개 정보라 이 차이로 알 수 있는 것은 없다)
|
||||
*/
|
||||
readonly email: string | null;
|
||||
/** 클라이언트 IP — 믿을 수 있는 프록시 뒤에서 읽은 값 (app.setup.ts 의 TRUST_PROXY). IPv6 는 /64 로 묶어 센다 */
|
||||
readonly ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 중인 로그인 시도 하나. 결과가 정해지면 셋 중 하나를 **한 번** 부른다 (두 번째부터는 무시).
|
||||
*/
|
||||
export interface LoginAttemptTicket {
|
||||
/** 실패(AUTH_004) — 미리 차지한 실패 한 번을 그대로 남긴다 */
|
||||
fail(): void;
|
||||
/** 성공 — 그 주소의 실패 기록을 지우고, 차지했던 IP 자리를 돌려준다 (성공은 세지 않는다) */
|
||||
succeed(): void;
|
||||
/** 판단하지 못함(메일 엔진 장애 등) — 차지했던 자리를 모두 돌려준다 */
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 **실패** 횟수 제한 (계약 1절).
|
||||
*
|
||||
* - 같은 IP 에서 1분에 실패 20회 / 같은 주소로 15분에 실패 10회를 넘으면 REQ_001.
|
||||
* 한도를 넘은 동안은 비밀번호가 맞아도 막는다 (무차별 대입이 계속 정답을 시험하지 못하게)
|
||||
* - IP 는 `clientIpKey()` 로 묶는다 — IPv6 는 /64, IPv4-mapped 는 IPv4 (요청 수 한도와 같은 키)
|
||||
* - **메일 엔진에 묻기 전에** 실패 한 번을 IP·주소 양쪽에 미리 차지한다(`begin`).
|
||||
* 확인과 차지가 한 동기 구간이라, 동시에 보낸 요청 100건이 "아직 한 번 남았다"를 함께 보고
|
||||
* 모두 엔진까지 가는 일이 없다. 결과가 성공이거나 판단할 수 없으면 차지한 자리를 돌려준다.
|
||||
* - 같은 주소의 시도가 진행 중이면 다음 시도는 기다리지 않고 REQ_001 — 한 주소는 한 번에 하나만 엔진에 묻는다.
|
||||
* - 성공하면 그 **주소**의 기록만 지운다. IP 기록은 지우지 않는다
|
||||
* (계정 하나를 아는 공격자가 성공 로그인으로 IP 카운터를 비워 가며 다른 계정을 찔러 보지 못하게)
|
||||
* - 기억하는 키가 상한에 닿고 밀어낼 기록도 없으면 새 IP·주소는 REQ_001 (fail-closed) + error 로그
|
||||
*
|
||||
* 저장소는 프로세스 메모리다. ⚠️ 여러 인스턴스로 늘리면 인스턴스마다 따로 세므로
|
||||
* Redis 원자 연산으로 옮겨야 한다 (fixed-window-counter.ts).
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginAttemptService {
|
||||
private readonly logger = new Logger(LoginAttemptService.name);
|
||||
|
||||
private readonly ipFailures = new FixedWindowCounter(
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_WINDOW_MS,
|
||||
MAX_TRACKED_KEYS,
|
||||
);
|
||||
private readonly addressFailures = new FixedWindowCounter(
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
|
||||
MAX_TRACKED_KEYS,
|
||||
);
|
||||
/** 엔진 확인이 진행 중인 주소 */
|
||||
private readonly inFlight = new Set<string>();
|
||||
/** 상한이 차서 거절하기 시작했다는 error 로그를 이번 정리 주기에 이미 남겼는가 (로그 폭주 방지) */
|
||||
private capacityAlarmRaised = false;
|
||||
|
||||
/**
|
||||
* 시도를 시작한다 — 한도를 넘었거나 같은 주소의 시도가 진행 중이면 REQ_001.
|
||||
* 이 메서드 안에는 await 가 없다 (확인과 차지 사이에 다른 요청이 끼어들지 못한다).
|
||||
*/
|
||||
begin(source: LoginAttemptSource): LoginAttemptTicket {
|
||||
const now = Date.now();
|
||||
const ipKey = digest(clientIpKey(source.ip));
|
||||
const addressKey = source.email === null ? null : digest(source.email);
|
||||
|
||||
if (addressKey !== null && this.inFlight.has(addressKey)) {
|
||||
throw this.rejected(source, 'address', 'login-in-progress');
|
||||
}
|
||||
|
||||
const ip = this.ipFailures.reserve(ipKey, now);
|
||||
if (!ip.ok) throw this.rejected(source, 'ip', this.reasonOf(ip));
|
||||
|
||||
let addressSlot: WindowSlot | null = null;
|
||||
if (addressKey !== null) {
|
||||
const address = this.addressFailures.reserve(addressKey, now);
|
||||
if (!address.ok) {
|
||||
this.ipFailures.release(ip.slot, now);
|
||||
throw this.rejected(source, 'address', this.reasonOf(address));
|
||||
}
|
||||
addressSlot = address.slot;
|
||||
this.inFlight.add(addressKey);
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = (finish: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (addressKey !== null) this.inFlight.delete(addressKey);
|
||||
finish();
|
||||
};
|
||||
|
||||
return {
|
||||
fail: () => settle(() => undefined),
|
||||
succeed: () =>
|
||||
settle(() => {
|
||||
this.ipFailures.release(ip.slot);
|
||||
if (addressKey !== null) this.addressFailures.clear(addressKey);
|
||||
}),
|
||||
abort: () =>
|
||||
settle(() => {
|
||||
this.ipFailures.release(ip.slot);
|
||||
if (addressSlot !== null) this.addressFailures.release(addressSlot);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝난 구간을 지운다. 상한 때문에 밀려난 기록이 있었다면 warn(대량 시도 징후),
|
||||
* 상한이 차서 새 키를 거절했다면 error (정상 사용자도 막히고 있다 — 즉시 확인이 필요하다)
|
||||
*/
|
||||
@Interval(SWEEP_INTERVAL_MS)
|
||||
sweep(now: number = Date.now()): void {
|
||||
const ip = this.ipFailures.sweep(now);
|
||||
const address = this.addressFailures.sweep(now);
|
||||
this.capacityAlarmRaised = false;
|
||||
|
||||
const evicted = ip.evicted + address.evicted;
|
||||
if (evicted > 0) {
|
||||
this.logger.warn(
|
||||
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿아 잠기지 않은 기록 ${evicted}건을 일찍 지웠습니다 — 많은 IP·주소로 시도하는 공격일 수 있습니다.`,
|
||||
);
|
||||
}
|
||||
const rejected = ip.rejected + address.rejected;
|
||||
if (rejected > 0) {
|
||||
this.logger.error(
|
||||
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿고 밀어낼 기록이 없어 지난 주기에 새 IP·주소의 로그인 ${rejected}건을 거절했습니다 (fail-closed).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private reasonOf(
|
||||
result: Extract<ReserveResult, { ok: false }>,
|
||||
): RejectReason {
|
||||
if (result.reason === 'limit') return 'login-failures-exceeded';
|
||||
|
||||
// 상한이 찼다 — 정상 사용자도 막힐 수 있는 상태라 처음 한 번은 바로 error 로 알린다 (나머지는 정리 주기에 합계로)
|
||||
if (!this.capacityAlarmRaised) {
|
||||
this.capacityAlarmRaised = true;
|
||||
this.logger.error(
|
||||
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿았고 밀어낼 수 있는 기록(끝났거나 잠기지 않은 것)이 없어 새 IP·주소의 로그인을 거절합니다 (fail-closed). 대량 시도가 의심됩니다.`,
|
||||
);
|
||||
}
|
||||
return 'login-tracking-full';
|
||||
}
|
||||
|
||||
private rejected(
|
||||
source: LoginAttemptSource,
|
||||
scope: Scope,
|
||||
reason: RejectReason,
|
||||
): BusinessException {
|
||||
return new BusinessException('REQ_001', {
|
||||
context: {
|
||||
reason,
|
||||
scope,
|
||||
...(scope === 'ip'
|
||||
? { ip: clientIpKey(source.ip) }
|
||||
: { email: maskEmail(source.email ?? '') }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 키에 주소·IP 원문을 남기지 않는다 (Redis 로 옮겨도 목록이 드러나지 않게) */
|
||||
function digest(subject: string): string {
|
||||
return createHash('sha256').update(subject).digest('base64url');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 표시 이름 최대 길이 — mail.constants 의 MAX_DISPLAY_NAME_LENGTH 와 같다 */
|
||||
export const DIRECTORY_NAME_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* 사내 목록 (계약 2절 "받는 사람 추천") — 웹메일 로그인에 성공한 직원의 주소·이름.
|
||||
* 메일 엔진의 계정 목록(Principal) 권한은 쓰지 않는다 — 일부러 막아 두었다 (mail/scripts/setup.mjs).
|
||||
* 메일 엔진 관리자·게이트웨이·막은 계정은 넣지 않는다 (로그인할 수도 없다).
|
||||
*/
|
||||
@Entity({ name: 'mail_directory' })
|
||||
export class MailDirectoryEntryEntity {
|
||||
/** 정규화한(소문자) 직원 주소 */
|
||||
@PrimaryColumn({ type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
email!: string;
|
||||
|
||||
/** 마지막으로 로그인했을 때 메일 엔진이 알려 준 표시 이름 */
|
||||
@Column({ type: 'varchar', length: DIRECTORY_NAME_LENGTH })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'last_login_at', type: 'timestamptz' })
|
||||
lastLoginAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MailDirectoryEntryEntity } from './mail-directory.entity';
|
||||
import { MailDirectoryService } from './mail-directory.service';
|
||||
|
||||
/**
|
||||
* 사내 목록 모듈 — 인증 모듈(로그인 때 기록)과 메일 모듈(받는 사람 추천)이 함께 쓴다.
|
||||
* 두 모듈 사이에 두어 서로를 가져오지 않게 한다 (AuthModule → MailModule 방향만 있다).
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([MailDirectoryEntryEntity])],
|
||||
providers: [MailDirectoryService],
|
||||
exports: [MailDirectoryService],
|
||||
})
|
||||
export class MailDirectoryModule {}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MoreThanOrEqual, type Repository } from 'typeorm';
|
||||
|
||||
import type { MailDirectoryEntryEntity } from './mail-directory.entity';
|
||||
import {
|
||||
MAIL_DIRECTORY_ACTIVE_DAYS,
|
||||
MAX_DIRECTORY_CONTACTS,
|
||||
MailDirectoryService,
|
||||
} from './mail-directory.service';
|
||||
|
||||
describe('MailDirectoryService (사내 목록)', () => {
|
||||
let upsert: jest.Mock;
|
||||
let find: jest.Mock;
|
||||
let service: MailDirectoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
upsert = jest.fn().mockResolvedValue(undefined);
|
||||
find = jest.fn().mockResolvedValue([]);
|
||||
service = new MailDirectoryService({
|
||||
upsert,
|
||||
find,
|
||||
} as unknown as Repository<MailDirectoryEntryEntity>);
|
||||
});
|
||||
|
||||
it('로그인한 직원을 주소 하나에 한 줄로 넣거나 고친다 — 주소는 소문자, 이름은 앞뒤 공백 없이 200자까지', async () => {
|
||||
const now = new Date('2026-10-27T03:00:00Z');
|
||||
|
||||
await service.recordLogin(
|
||||
{ email: ' Doyun.Lee@ARASYSTEM.test ', name: ` ${'가'.repeat(250)} ` },
|
||||
now,
|
||||
);
|
||||
|
||||
expect(upsert).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
name: '가'.repeat(200),
|
||||
lastLoginAt: now,
|
||||
},
|
||||
['email'],
|
||||
);
|
||||
});
|
||||
|
||||
it(`최근 ${MAIL_DIRECTORY_ACTIVE_DAYS}일 안에 로그인한 직원만, 최근에 로그인한 사람부터 상한까지 주소·이름만 읽는다 (퇴사자는 추천하지 않는다)`, async () => {
|
||||
const now = new Date('2026-10-27T03:00:00Z');
|
||||
find.mockResolvedValue([
|
||||
{
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
name: '이도윤',
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.listContacts(MAX_DIRECTORY_CONTACTS, now),
|
||||
).resolves.toEqual([{ email: 'doyun.lee@arasystem.test', name: '이도윤' }]);
|
||||
expect(find).toHaveBeenCalledWith({
|
||||
// 2026-10-27 의 90일 전 = 2026-07-29
|
||||
where: { lastLoginAt: MoreThanOrEqual(new Date('2026-07-29T03:00:00Z')) },
|
||||
order: { lastLoginAt: 'DESC' },
|
||||
take: MAX_DIRECTORY_CONTACTS,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThanOrEqual, type Repository } from 'typeorm';
|
||||
|
||||
import type { DirectoryContact } from '@/modules/mail/mail.types';
|
||||
import {
|
||||
DIRECTORY_NAME_LENGTH,
|
||||
MailDirectoryEntryEntity,
|
||||
} from './mail-directory.entity';
|
||||
|
||||
/** 추천에 읽어 오는 사내 목록 상한 — 최근에 로그인한 사람부터 (추천 목록 자체는 1,000개까지다) */
|
||||
export const MAX_DIRECTORY_CONTACTS = 2000;
|
||||
|
||||
/**
|
||||
* 이 기간 안에 로그인한 직원만 "사내"로 추천한다 — 퇴사자가 계속 사내 맨 앞에 나와 반송되지 않게.
|
||||
* 웹메일을 쓰는 직원은 세션이 길어야 30일이라 그 안에 다시 로그인한다. 표에서 지우지는 않는다 (다시 로그인하면 돌아온다).
|
||||
*/
|
||||
export const MAIL_DIRECTORY_ACTIVE_DAYS = 90;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 사내 목록 — 로그인에 성공한 직원을 기록하고(AuthService), 받는 사람 추천이 읽는다(RecipientSuggestionService).
|
||||
* 누가 넣을지(관리자·게이트웨이 제외)는 부르는 쪽이 정한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailDirectoryService {
|
||||
constructor(
|
||||
@InjectRepository(MailDirectoryEntryEntity)
|
||||
private readonly entries: Repository<MailDirectoryEntryEntity>,
|
||||
) {}
|
||||
|
||||
/** 로그인한 직원을 넣거나 이름·마지막 로그인 시각을 고친다 (주소 하나에 한 줄 — INSERT … ON CONFLICT) */
|
||||
async recordLogin(
|
||||
contact: DirectoryContact,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
await this.entries.upsert(
|
||||
{
|
||||
email: contact.email.trim().toLowerCase(),
|
||||
name: contact.name.trim().slice(0, DIRECTORY_NAME_LENGTH),
|
||||
lastLoginAt: now,
|
||||
},
|
||||
['email'],
|
||||
);
|
||||
}
|
||||
|
||||
/** 최근 MAIL_DIRECTORY_ACTIVE_DAYS 일 안에 로그인한 직원 — 최근에 로그인한 사람부터 */
|
||||
async listContacts(
|
||||
limit: number = MAX_DIRECTORY_CONTACTS,
|
||||
now: Date = new Date(),
|
||||
): Promise<DirectoryContact[]> {
|
||||
const activeSince = new Date(
|
||||
now.getTime() - MAIL_DIRECTORY_ACTIVE_DAYS * DAY_MS,
|
||||
);
|
||||
const rows = await this.entries.find({
|
||||
where: { lastLoginAt: MoreThanOrEqual(activeSince) },
|
||||
order: { lastLoginAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
return rows.map(({ email, name }) => ({ email, name }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import {
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from '@/modules/mail-signature/mail-signature.constants';
|
||||
import type {
|
||||
MailSignature,
|
||||
SignatureImage,
|
||||
} from '@/modules/mail-signature/mail-signature.types';
|
||||
import type { SignatureImageType } from '@/modules/mail-signature/signature-image-format';
|
||||
|
||||
const IMAGE_TYPES: SignatureImageType[] = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
];
|
||||
|
||||
/** PUT /api/mail-signature — 계약 2절 "서명" */
|
||||
export class UpdateMailSignatureDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
`서명 서식 HTML — 서식 본문과 같은 허용 목록 + 내 서명 그림(<img src="/api/mail-signature/images/{id}" width="1~600" alt="">). ` +
|
||||
`정화한 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001). null·빈 문자열이면 서식 서명 없음. ` +
|
||||
'서명 HTML 이 더는 가리키지 않는 그림은 저장할 때 지운다',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example:
|
||||
'<p style="margin:0"><strong>김아라</strong> · 아라시스템</p><img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '서명 형식이 올바르지 않습니다.' })
|
||||
bodyHtml?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: `텍스트 서명 — 텍스트 대체 본문에 넣는다. ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하(JS 문자열 길이, 넘으면 400 VAL_001)`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '김아라 · 아라시스템',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '텍스트 서명 형식이 올바르지 않습니다.' })
|
||||
body?: string | null;
|
||||
|
||||
@ApiProperty({ description: '새 메일에 넣는다', example: true })
|
||||
@IsBoolean({ message: '새 메일에 넣을지 여부를 다시 선택해 주세요.' })
|
||||
isForNew!: boolean;
|
||||
|
||||
@ApiProperty({ description: '답장·전달에 넣는다 (인용 위)', example: true })
|
||||
@IsBoolean({ message: '답장·전달에 넣을지 여부를 다시 선택해 주세요.' })
|
||||
isForReply!: boolean;
|
||||
}
|
||||
|
||||
export class SignatureImageDto implements SignatureImage {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'그림 id — 서명 HTML 의 <img src="/api/mail-signature/images/{id}"> 에 쓴다',
|
||||
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '파일 내용으로 확인한 형식',
|
||||
enum: IMAGE_TYPES,
|
||||
example: 'image/png',
|
||||
})
|
||||
type!: SignatureImageType;
|
||||
|
||||
@ApiProperty({
|
||||
description: `크기 (바이트, ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')} 이하)`,
|
||||
example: 18342,
|
||||
})
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({ description: '원본 가로 픽셀', example: 480 })
|
||||
width!: number;
|
||||
|
||||
@ApiProperty({ description: '원본 세로 픽셀', example: 120 })
|
||||
height!: number;
|
||||
}
|
||||
|
||||
export class MailSignatureDto implements MailSignature {
|
||||
@ApiProperty({
|
||||
description: `서명 서식 HTML (정화한 값, ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하) — 저장한 적 없거나 비었으면 null`,
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '텍스트 서명 — 저장한 적 없거나 비었으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
body!: string | null;
|
||||
|
||||
@ApiProperty({ description: '새 메일에 넣는다 (기본 true)', example: true })
|
||||
isForNew!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '답장·전달에 넣는다 — 인용 위 (기본 true)',
|
||||
example: true,
|
||||
})
|
||||
isForReply!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: `서명 HTML 이 쓰는 그림 — HTML 에 나온 순서 (${SIGNATURE_IMAGES_PER_SIGNATURE}장까지)`,
|
||||
type: [SignatureImageDto],
|
||||
})
|
||||
images!: SignatureImageDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '저장한 시각 (ISO 8601 UTC) — 저장한 적 없으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '2026-10-27T05:12:00.000Z',
|
||||
})
|
||||
updatedAt!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 'image/jpeg' 등 */
|
||||
const CONTENT_TYPE_LENGTH = 32;
|
||||
/** 서버가 만든 파일 이름 — `<uuid>.<확장자>` */
|
||||
const FILE_NAME_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* 서명 그림 (계약 2절 "서명") — 파일은 업로드 볼륨의 하위 폴더에 두고(mail-signature.constants.ts), 여기에는 정보만 둔다.
|
||||
* - 파일 이름은 서버가 만든다 (UUID + 내용으로 확인한 형식의 확장자). 올린 이름은 저장하지 않는다
|
||||
* - `attached_at`: 서명을 저장할 때 그 HTML 이 가리키면 채운다. 비어 있고 하루가 지난 그림은 정리 작업이 지운다
|
||||
*/
|
||||
@Entity({ name: 'mail_signature_images' })
|
||||
@Index('IDX_mail_signature_images_user_email', ['userEmail'])
|
||||
@Index('IDX_mail_signature_images_unattached', ['createdAt'], {
|
||||
where: '"attached_at" IS NULL',
|
||||
})
|
||||
export class MailSignatureImageEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
userEmail!: string;
|
||||
|
||||
/** 파일 내용으로 확인한 형식 (클라이언트가 보낸 값이 아니다) */
|
||||
@Column({
|
||||
name: 'content_type',
|
||||
type: 'varchar',
|
||||
length: CONTENT_TYPE_LENGTH,
|
||||
})
|
||||
contentType!: string;
|
||||
|
||||
/** 바이트 */
|
||||
@Column({ type: 'integer' })
|
||||
size!: number;
|
||||
|
||||
/** 원본 픽셀 — 파일 머리에서 읽었다 */
|
||||
@Column({ type: 'integer' })
|
||||
width!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
height!: number;
|
||||
|
||||
@Column({ name: 'file_name', type: 'varchar', length: FILE_NAME_LENGTH })
|
||||
fileName!: string;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
/** 저장한 서명이 처음 가리킨 시각 — null 이면 올리기만 한 그림 */
|
||||
@Column({ name: 'attached_at', type: 'timestamptz', nullable: true })
|
||||
attachedAt!: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import { MailSignatureImageCleanup } from './mail-signature.cleanup';
|
||||
import type { MailSignatureRepository } from './mail-signature.repository';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const NOW = new Date('2026-10-27T04:00:00Z');
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
const idOf = (n: number) => `0000000${n}-0000-4000-8000-000000000000`;
|
||||
|
||||
describe('MailSignatureImageCleanup (하루 한 번 서명 그림 정리)', () => {
|
||||
let dir: string;
|
||||
let storage: SignatureImageStorage;
|
||||
let repository: {
|
||||
findUnattachedBefore: jest.Mock<
|
||||
Promise<MailSignatureImageEntity[]>,
|
||||
[Date]
|
||||
>;
|
||||
deleteUnattachedImages: jest.Mock<
|
||||
Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]>,
|
||||
[readonly string[]]
|
||||
>;
|
||||
listFileNames: jest.Mock<Promise<Set<string>>, []>;
|
||||
};
|
||||
let cleanup: MailSignatureImageCleanup;
|
||||
|
||||
/** 파일을 만들고 수정 시각을 `hoursAgo` 시간 전으로 둔다 */
|
||||
function file(n: number, hoursAgo: number): string {
|
||||
const fileName = `${idOf(n)}.png`;
|
||||
const path = join(dir, fileName);
|
||||
writeFileSync(path, 'png');
|
||||
const at = new Date(NOW.getTime() - hoursAgo * HOUR);
|
||||
utimesSync(path, at, at);
|
||||
return fileName;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'signature-cleanup-'));
|
||||
storage = new SignatureImageStorage(dir);
|
||||
repository = {
|
||||
findUnattachedBefore: jest
|
||||
.fn<Promise<MailSignatureImageEntity[]>, [Date]>()
|
||||
.mockResolvedValue([]),
|
||||
deleteUnattachedImages: jest
|
||||
.fn<
|
||||
Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]>,
|
||||
[readonly string[]]
|
||||
>()
|
||||
.mockImplementation((ids) =>
|
||||
Promise.resolve(ids.map((id) => ({ id, fileName: `${id}.png` }))),
|
||||
),
|
||||
listFileNames: jest
|
||||
.fn<Promise<Set<string>>, []>()
|
||||
.mockResolvedValue(new Set()),
|
||||
};
|
||||
cleanup = new MailSignatureImageCleanup(
|
||||
repository as unknown as MailSignatureRepository,
|
||||
storage,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('서명에 붙지 않은 채 하루가 지난 그림을 표와 파일에서 지운다', async () => {
|
||||
const stale = file(1, 30);
|
||||
repository.findUnattachedBefore.mockResolvedValue([
|
||||
{ id: idOf(1), fileName: stale } as MailSignatureImageEntity,
|
||||
]);
|
||||
repository.listFileNames.mockResolvedValue(new Set());
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
|
||||
|
||||
expect(repository.findUnattachedBefore).toHaveBeenCalledWith(
|
||||
new Date(NOW.getTime() - 24 * HOUR),
|
||||
);
|
||||
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([idOf(1)]);
|
||||
expect(await storage.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('표에 없는 파일은 하루가 지났을 때만 지운다 — 표에 있는 파일·막 쓴 파일·다른 이름의 파일은 그대로', async () => {
|
||||
const known = file(1, 48);
|
||||
const orphan = file(2, 25);
|
||||
const fresh = file(3, 1);
|
||||
writeFileSync(join(dir, 'README.txt'), '그림이 아니다');
|
||||
repository.listFileNames.mockResolvedValue(new Set([known]));
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
|
||||
|
||||
const left = (await storage.list()).map((stored) => stored.fileName).sort();
|
||||
expect(left).toEqual([known, fresh].sort());
|
||||
expect(left).not.toContain(orphan);
|
||||
});
|
||||
|
||||
it('찾은 뒤 그사이 서명에 붙은 그림은 지우지 않는다 — 실제로 지운 행의 파일만 지운다', async () => {
|
||||
const stillUnattached = file(1, 30);
|
||||
const attachedMeanwhile = file(2, 30);
|
||||
repository.findUnattachedBefore.mockResolvedValue([
|
||||
{ id: idOf(1), fileName: stillUnattached } as MailSignatureImageEntity,
|
||||
{ id: idOf(2), fileName: attachedMeanwhile } as MailSignatureImageEntity,
|
||||
]);
|
||||
// 저장소는 지우는 순간 attached_at IS NULL 을 다시 본다 — 2번은 그사이 붙어 지우지 않았다
|
||||
repository.deleteUnattachedImages.mockResolvedValue([
|
||||
{ id: idOf(1), fileName: stillUnattached },
|
||||
]);
|
||||
repository.listFileNames.mockResolvedValue(new Set([attachedMeanwhile]));
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(1);
|
||||
|
||||
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([
|
||||
idOf(1),
|
||||
idOf(2),
|
||||
]);
|
||||
expect((await storage.list()).map((stored) => stored.fileName)).toEqual([
|
||||
attachedMeanwhile,
|
||||
]);
|
||||
});
|
||||
|
||||
it('정리에 실패해도 던지지 않는다 — 오류 로그를 남기고 다음 주기에 다시 한다', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
repository.findUnattachedBefore.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(0);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[SYS_001] 서명 그림 정리에 실패했습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
it('지울 것이 없으면 아무것도 지우지 않는다 (폴더가 아직 없어도)', async () => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
await expect(cleanup.purgeUnattachedImages(NOW)).resolves.toBe(0);
|
||||
expect(repository.deleteUnattachedImages).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS } from './mail-signature.constants';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
/**
|
||||
* 서명 그림 정리 — 하루에 한 번 (auth-session.cleanup.ts 와 같은 모양).
|
||||
* - 올리고 서명에 저장하지 않은 채 하루가 지난 그림을 지운다 (표와 파일)
|
||||
* - 표에 없는 파일(올리다 끊긴 것 등)도 하루가 지났으면 지운다
|
||||
*
|
||||
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 일을 한다 (결과는 같지만 낭비).
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureImageCleanup {
|
||||
private readonly logger = new Logger(MailSignatureImageCleanup.name);
|
||||
|
||||
constructor(
|
||||
private readonly repository: MailSignatureRepository,
|
||||
private readonly storage: SignatureImageStorage,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM, {
|
||||
name: 'mail-signature-image-cleanup',
|
||||
})
|
||||
async purgeUnattachedImages(now: Date = new Date()): Promise<number> {
|
||||
try {
|
||||
const before = new Date(
|
||||
now.getTime() - UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS,
|
||||
);
|
||||
|
||||
// 찾은 뒤 그사이 서명에 붙은 그림은 지우지 않는다 — 실제로 지운 행의 파일만 지운다
|
||||
const unattached = await this.repository.findUnattachedBefore(before);
|
||||
const deleted = await this.repository.deleteUnattachedImages(
|
||||
unattached.map((image) => image.id),
|
||||
);
|
||||
for (const image of deleted) await this.storage.remove(image.fileName);
|
||||
|
||||
const known = await this.repository.listFileNames();
|
||||
const orphans = (await this.storage.list()).filter(
|
||||
(file) => !known.has(file.fileName) && file.modifiedAt < before,
|
||||
);
|
||||
for (const file of orphans) await this.storage.remove(file.fileName);
|
||||
|
||||
const removed = deleted.length + orphans.length;
|
||||
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
|
||||
if (removed > 0) {
|
||||
this.logger.debug(`서명 그림 ${removed}개를 지웠습니다.`);
|
||||
}
|
||||
return removed;
|
||||
} catch (error) {
|
||||
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다
|
||||
this.logger.error(
|
||||
`[SYS_001] 서명 그림 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { INLINE_IMAGE_MAX_BYTES } from '@/modules/mail/html/inline-images';
|
||||
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
||||
|
||||
/**
|
||||
* 서명 한도 — 계약 2절 "서명"의 숫자를 한 곳에 모은다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 서명 그림 한 장 최대 크기 — 받은 메일의 내장 그림을 본문에 넣는 한도와 **같은 값**이다.
|
||||
* 더 크게 받으면 보낸 메일을 우리 웹메일로 다시 읽을 때(보낸편지함·받는 사람) 그림이 빠진다 (html/inline-images.ts).
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_MAX_BYTES = INLINE_IMAGE_MAX_BYTES;
|
||||
|
||||
/**
|
||||
* 서명 그림의 가로·세로 상한(픽셀, 각각) — 넘으면 올리기가 400 FILE_001.
|
||||
* 300KB 안에도 수만×수만 픽셀(압축 폭탄)이 들 수 있고, PNG 머리의 가로·세로(32비트)는 표의 integer 열을 넘을 수 있다.
|
||||
* 서명에 넣는 폭은 600px 까지라(보내는 정화의 width) 충분히 넉넉하다.
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_MAX_DIMENSION = 4096;
|
||||
|
||||
/** 서명 하나에 넣을 수 있는 서로 다른 그림 수 — 넘으면 서명 저장(PUT)이 400 VAL_001 */
|
||||
export const SIGNATURE_IMAGES_PER_SIGNATURE = 3;
|
||||
|
||||
/**
|
||||
* 한 사람이 둘 수 있는 서명 그림 수 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것. 넘으면 올리기가 400 VAL_001.
|
||||
* 편집기에서 그림을 넣었다 빼기를 되풀이해도(빼도 저장 전·10분 유예 동안은 남는다) 막히지 않게 서명 한도보다 넉넉히 둔다.
|
||||
* 저장하지 않은 그림은 하루 뒤 정리 작업이, 서명이 더는 가리키지 않는 그림은 다음 저장이 지운다.
|
||||
*/
|
||||
export const SIGNATURE_IMAGES_STORED_MAX = 10;
|
||||
|
||||
/** 서명 서식(HTML) 최대 길이 — 정화한 뒤 JS 문자열 길이 */
|
||||
export const SIGNATURE_HTML_MAX_LENGTH = 10_000;
|
||||
|
||||
/** 텍스트 서명 최대 길이 — JS 문자열 길이 (웹 클라이언트가 세는 방법과 같다) */
|
||||
export const SIGNATURE_TEXT_MAX_LENGTH = 2_000;
|
||||
|
||||
/** 올리고 서명에 저장하지 않은 그림을 지우기까지의 시간 */
|
||||
export const UNATTACHED_SIGNATURE_IMAGE_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 서명 저장(PUT)이 지우지 않고 남겨 두는, 막 올린 그림의 나이.
|
||||
* 올리기와 저장이 동시에 오면(그림을 넣자마자 [저장]) 저장 본문에는 아직 그 그림이 없다 — 그때 지우면
|
||||
* 편집기에 넣은 그림이 사라진다. 이 시간보다 오래된, 한 번도 저장하지 않은 그림은 저장할 때 지운다
|
||||
* (그 밖에는 하루 뒤 정리 작업이 지운다).
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_UPLOAD_GRACE_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* 서명 그림 파일 위치 — 업로드 볼륨(modules/upload 와 같은 UPLOAD_DIR) 안의 하위 폴더.
|
||||
* 운영은 named volume, 개발은 바인드 마운트다 (.claude/skills/docker/SKILL.md).
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_DIR = join(UPLOAD_DIR, 'mail-signatures');
|
||||
|
||||
/** 그림 응답의 브라우저 캐시 — 그림 id 는 내용이 바뀌지 않는다(새로 올리면 새 id). 본인만 보므로 private */
|
||||
export const SIGNATURE_IMAGE_CACHE_CONTROL = 'private, max-age=86400';
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiProduces,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
MailSignatureDto,
|
||||
SignatureImageDto,
|
||||
UpdateMailSignatureDto,
|
||||
} from './dto/mail-signature.dto';
|
||||
import {
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_CACHE_CONTROL,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import { SIGNATURE_IMAGE_EXTENSIONS } from './signature-image-format';
|
||||
import { SignatureImageIdPipe } from './signature-image-id.pipe';
|
||||
|
||||
/** 서명 그림 응답 형식 — 저장할 때 파일 내용으로 확인한 형식 그대로 */
|
||||
const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
|
||||
|
||||
// 메일 서명 (계약 2절 "서명") — 로그인한 직원 자신의 서명·그림만 다룬다.
|
||||
// 그림 업로드는 modules/upload 패턴을 따른다 (가드가 multer 보다 먼저 돌아 미인증 요청은 파일을 읽기 전에 막힌다).
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('mail-signature')
|
||||
export class MailSignatureController {
|
||||
constructor(private readonly signatures: MailSignatureService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '내 서명',
|
||||
description:
|
||||
'저장한 적 없으면 기본값(bodyHtml·body null, isForNew·isForReply true, images [])을 돌려준다.',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDto, { description: '서명' })
|
||||
getSignature(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.signatures.getSignature(user.email);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOperation({
|
||||
summary: '서명 저장 (전체 교체)',
|
||||
description:
|
||||
`bodyHtml 은 서식 본문 허용 목록 + 내 서명 그림으로 정화해 저장한다(정화 뒤 ${SIGNATURE_HTML_MAX_LENGTH.toLocaleString('ko-KR')}자 이하), ` +
|
||||
`body 는 ${SIGNATURE_TEXT_MAX_LENGTH.toLocaleString('ko-KR')}자 이하, 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(같은 그림 두 번은 한 장). ` +
|
||||
'서명 HTML 이 더는 가리키지 않는 그림은 지운다 — 막 올리고(10분 안) 아직 넣지 않은 그림은 남긴다.',
|
||||
})
|
||||
@ApiStandardResponse(MailSignatureDto, { description: '저장한 서명' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`형식 오류 · 텍스트 서명 2,000자 초과 · 정화한 서식 서명 10,000자 초과 · 서식 서명이 너무 깊게 중첩됨 · 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 초과`,
|
||||
)
|
||||
saveSignature(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: UpdateMailSignatureDto,
|
||||
) {
|
||||
return this.signatures.saveSignature(user.email, dto);
|
||||
}
|
||||
|
||||
@Post('images')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiOperation({
|
||||
summary: '서명 그림 올리기 (파일 1개)',
|
||||
description:
|
||||
`PNG·JPEG·GIF 만 — 확장자·MIME·파일 앞부분(내용)을 모두 본다(WebP·SVG 는 받지 않는다). ` +
|
||||
`한 장 ${SIGNATURE_IMAGE_MAX_BYTES.toLocaleString('ko-KR')}바이트 이하. 한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 서명에 붙은 것 + 올리고 아직 저장하지 않은 것 ` +
|
||||
`(서명 하나에는 ${SIGNATURE_IMAGES_PER_SIGNATURE}장 — PUT 이 본다). ` +
|
||||
'서명을 저장하지 않으면 하루 뒤 지운다.',
|
||||
})
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: { file: { type: 'string', format: 'binary' } },
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiStandardResponse(SignatureImageDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '올린 그림 — 이 id 를 서명 HTML 의 img 주소에 쓴다',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'FILE_001',
|
||||
'허용되지 않는 형식 · 내용이 확장자·MIME 과 다른 형식 (400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
`파일 없음 · 둘 수 있는 그림 ${SIGNATURE_IMAGES_STORED_MAX}장 초과 (400)`,
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '한 장 300KB 초과')
|
||||
uploadImage(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
) {
|
||||
if (!file?.buffer) {
|
||||
// 파일 없이 보낸 요청 — multer 는 오류를 내지 않으므로 여기서 막는다
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-image-file-missing' },
|
||||
});
|
||||
}
|
||||
return this.signatures.uploadImage(user.email, {
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
data: file.buffer,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('images/:id')
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary: '서명 그림 (표준 래퍼 없음, 내 그림만)',
|
||||
description:
|
||||
'편집기·미리 보기용 — 받는 사람은 이 주소를 쓰지 않는다(보낼 때 메일 안에 함께 싣는다). ' +
|
||||
'형식은 저장할 때 확인한 것, X-Content-Type-Options: nosniff, Content-Disposition: inline, 캐시는 private.',
|
||||
})
|
||||
@ApiProduces(...IMAGE_CONTENT_TYPES)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '그림 파일',
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '없는 그림 · 남의 그림 · 형식이 틀린 id')
|
||||
async downloadImage(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', SignatureImageIdPipe) id: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const image = await this.signatures.openImage(user.email, id);
|
||||
|
||||
// 본인만 보는 그림 — 공유 캐시에 남기지 않는다. 내용은 바뀌지 않으므로(새로 올리면 새 id) 브라우저는 하루 둔다
|
||||
res.setHeader('Cache-Control', SIGNATURE_IMAGE_CACHE_CONTROL);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
return new StreamableFile(image.data, {
|
||||
type: image.type,
|
||||
disposition: `inline; filename="signature.${SIGNATURE_IMAGE_EXTENSIONS[image.type]}"`,
|
||||
length: image.data.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
|
||||
/**
|
||||
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나. 메일 엔진(JMAP Identity)이 아니라 우리 DB 에 둔다 —
|
||||
* 엔진을 바꿔도 남고, DB 백업에 함께 들어간다.
|
||||
* HTML 은 저장하기 전에 보내는 정화(서명 그림 포함)를 거친 값이다.
|
||||
*/
|
||||
@Entity({ name: 'mail_signatures' })
|
||||
export class MailSignatureEntity {
|
||||
@PrimaryColumn({
|
||||
name: 'user_email',
|
||||
type: 'varchar',
|
||||
length: EMAIL_COLUMN_LENGTH,
|
||||
})
|
||||
userEmail!: string;
|
||||
|
||||
@Column({ name: 'body_html', type: 'text', nullable: true })
|
||||
bodyHtml!: string | null;
|
||||
|
||||
@Column({ name: 'body_text', type: 'text', nullable: true })
|
||||
bodyText!: string | null;
|
||||
|
||||
/** 새 메일에 넣는다 */
|
||||
@Column({ name: 'is_for_new', type: 'boolean', default: true })
|
||||
isForNew!: boolean;
|
||||
|
||||
/** 답장·전달에 넣는다 (인용 위) */
|
||||
@Column({ name: 'is_for_reply', type: 'boolean', default: true })
|
||||
isForReply!: boolean;
|
||||
|
||||
@Column({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import { MailSignatureImageCleanup } from './mail-signature.cleanup';
|
||||
import { SIGNATURE_IMAGE_MAX_BYTES } from './mail-signature.constants';
|
||||
import { MailSignatureController } from './mail-signature.controller';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import { SIGNATURE_IMAGE_TYPES_BY_EXTENSION } from './signature-image-format';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
/**
|
||||
* 메일 서명 모듈 (계약 2절 "서명") — 서명·그림은 서버 DB 와 업로드 볼륨에 둔다.
|
||||
* 메일 모듈이 발송·임시 저장 때 그림을 쓰도록 서비스를 내보낸다 (이 모듈은 메일 모듈을 가져오지 않는다).
|
||||
*
|
||||
* 그림 업로드: 한 장 300KB 이하라 메모리에 받는다 — 파일 내용(앞부분)을 본 뒤에만 디스크에 서버가 만든 이름으로 쓴다.
|
||||
* 형식 사전 검사는 확장자 + MIME 짝(FILE_001), 크기 초과는 multer → 413(FILE_002).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([MailSignatureEntity, MailSignatureImageEntity]),
|
||||
MulterModule.register({
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: SIGNATURE_IMAGE_MAX_BYTES, files: 1, fields: 10 },
|
||||
// 브라우저는 파일명을 UTF-8 그대로 보낸다 — 기본값(latin1)이면 한글 이름이 깨진다
|
||||
defParamCharset: 'utf8',
|
||||
fileFilter: (_req, file, callback) => {
|
||||
const type = SIGNATURE_IMAGE_TYPES_BY_EXTENSION.get(
|
||||
extname(file.originalname).toLowerCase(),
|
||||
);
|
||||
if (!type || type !== file.mimetype) {
|
||||
callback(
|
||||
new BusinessException('FILE_001', {
|
||||
context: { reason: 'signature-image-type-not-allowed' },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
callback(null, true);
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [MailSignatureController],
|
||||
providers: [
|
||||
MailSignatureRepository,
|
||||
{
|
||||
provide: SignatureImageStorage,
|
||||
useFactory: () => new SignatureImageStorage(),
|
||||
},
|
||||
MailSignatureService,
|
||||
MailSignatureImageCleanup,
|
||||
],
|
||||
exports: [MailSignatureService],
|
||||
})
|
||||
export class MailSignatureModule {}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, IsNull, LessThan, type Repository } from 'typeorm';
|
||||
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
|
||||
/**
|
||||
* 서명 저장을 사용자별로 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(주소))` (트랜잭션이 끝나면 풀린다).
|
||||
* 서명 행이 아직 없을 때도 잠긴다 (행 잠금 FOR UPDATE 는 없는 행을 잠그지 못한다). 첫 인자는 다른 잠금과 섞이지 않게 붙인 이름표 — 'SIG1' 의 ASCII.
|
||||
*/
|
||||
const SIGNATURE_SAVE_LOCK = 0x53494731;
|
||||
|
||||
/** 서명 저장 — 잠근 뒤 본 그림으로 저장할 서명을 정한다 (서비스가 그사이 없어진 그림을 서명에서 뺀다) */
|
||||
export type PrepareSignature = (
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
) => {
|
||||
readonly signature: MailSignatureEntity;
|
||||
readonly referencedImageIds: readonly string[];
|
||||
};
|
||||
|
||||
/** 서명 저장 결과 */
|
||||
export interface SavedSignature {
|
||||
readonly signature: MailSignatureEntity;
|
||||
/** 저장한 뒤 남은 그 사람의 그림 (올린 순서) */
|
||||
readonly images: MailSignatureImageEntity[];
|
||||
/** 지운 그림 — 파일은 부르는 쪽이 커밋 뒤에 지운다 */
|
||||
readonly removed: MailSignatureImageEntity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명·서명 그림 표 접근 — 판단(정화·한도·누가 볼 수 있나)은 MailSignatureService 가 한다.
|
||||
* 그림을 찾을 때는 늘 주인(user_email)을 함께 조건에 넣는다 — 남의 그림 id 로는 아무것도 나오지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureRepository {
|
||||
constructor(
|
||||
@InjectRepository(MailSignatureEntity)
|
||||
private readonly signatures: Repository<MailSignatureEntity>,
|
||||
@InjectRepository(MailSignatureImageEntity)
|
||||
private readonly images: Repository<MailSignatureImageEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findSignature(userEmail: string): Promise<MailSignatureEntity | null> {
|
||||
return this.signatures.findOneBy({ userEmail });
|
||||
}
|
||||
|
||||
/** 그 사람의 그림 — 올린 순서대로 */
|
||||
listImages(userEmail: string): Promise<MailSignatureImageEntity[]> {
|
||||
return this.images.find({
|
||||
where: { userEmail },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findImage(
|
||||
id: string,
|
||||
userEmail: string,
|
||||
): Promise<MailSignatureImageEntity | null> {
|
||||
return this.images.findOneBy({ id, userEmail });
|
||||
}
|
||||
|
||||
countImages(userEmail: string): Promise<number> {
|
||||
return this.images.countBy({ userEmail });
|
||||
}
|
||||
|
||||
async insertImage(image: MailSignatureImageEntity): Promise<void> {
|
||||
await this.images.insert(image);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 저장한다 — 한 트랜잭션에서, 사용자별로 차례대로 (두 탭이 동시에 저장해도 서로의 그림을 엉뚱하게 지우지 않게).
|
||||
* 1) 사용자 잠금 2) 그 사람의 그림을 잠가 읽는다(FOR UPDATE — 정리 작업이 그사이 지우지 못한다) 3) `prepare` 가 저장할 서명을 정한다
|
||||
* 4) 서명을 넣거나 고치고, 서명이 가리키는 그림은 붙은 것으로 표시하고, 가리키지 않는 그림은 지운다 —
|
||||
* 다만 한 번도 저장하지 않은 그림 중 `keepUploadedAfter` 뒤에 올린 것은 남긴다 (막 올린 그림 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
|
||||
*/
|
||||
saveSignature(
|
||||
userEmail: string,
|
||||
prepare: PrepareSignature,
|
||||
now: Date,
|
||||
keepUploadedAfter: Date,
|
||||
): Promise<SavedSignature> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
|
||||
SIGNATURE_SAVE_LOCK,
|
||||
userEmail,
|
||||
]);
|
||||
const images = await manager.find(MailSignatureImageEntity, {
|
||||
where: { userEmail },
|
||||
order: { createdAt: 'ASC' },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
const { signature, referencedImageIds } = prepare(images);
|
||||
await manager.upsert(MailSignatureEntity, signature, ['userEmail']);
|
||||
|
||||
const referenced = new Set(referencedImageIds);
|
||||
const attach = images
|
||||
.filter(
|
||||
(image) => referenced.has(image.id) && image.attachedAt === null,
|
||||
)
|
||||
.map((image) => image.id);
|
||||
const stale = images.filter(
|
||||
(image) =>
|
||||
!referenced.has(image.id) &&
|
||||
(image.attachedAt !== null || image.createdAt <= keepUploadedAfter),
|
||||
);
|
||||
|
||||
if (attach.length > 0) {
|
||||
await manager.update(
|
||||
MailSignatureImageEntity,
|
||||
{ id: In(attach) },
|
||||
{ attachedAt: now },
|
||||
);
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
await manager.delete(MailSignatureImageEntity, {
|
||||
id: In(stale.map((image) => image.id)),
|
||||
});
|
||||
}
|
||||
const attached = new Set(attach);
|
||||
return {
|
||||
signature,
|
||||
images: images
|
||||
.filter((image) => !stale.includes(image))
|
||||
.map((image) =>
|
||||
attached.has(image.id) ? { ...image, attachedAt: now } : image,
|
||||
),
|
||||
removed: stale,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 서명에 붙지 않은 채 `before` 전에 올린 그림 (정리 작업용 — 부분 인덱스를 탄다) */
|
||||
findUnattachedBefore(before: Date): Promise<MailSignatureImageEntity[]> {
|
||||
return this.images.find({
|
||||
where: { attachedAt: IsNull(), createdAt: LessThan(before) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명에 붙지 않은 그림을 지운다 — 지우는 그 순간에도 붙지 않았을 때만(`attached_at IS NULL` 을 다시 본다).
|
||||
* 찾은 뒤 그사이 서명에 붙은 그림은 남는다 (서명 저장은 그림 행을 잠그므로 저장이 끝난 뒤의 값으로 다시 본다).
|
||||
* @returns 실제로 지운 그림 — 이 그림들의 파일만 지운다
|
||||
*/
|
||||
async deleteUnattachedImages(
|
||||
ids: readonly string[],
|
||||
): Promise<Pick<MailSignatureImageEntity, 'id' | 'fileName'>[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const result = await this.images
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('id IN (:...ids)', { ids: [...ids] })
|
||||
.andWhere('attached_at IS NULL')
|
||||
// 배열은 엔티티 속성 이름이다 — SQL 은 RETURNING "id", "file_name" (행의 키는 열 이름)
|
||||
.returning(['id', 'fileName'])
|
||||
.execute();
|
||||
const rows = (result.raw ?? []) as { id: string; file_name: string }[];
|
||||
return rows.map((row) => ({ id: row.id, fileName: row.file_name }));
|
||||
}
|
||||
|
||||
/** 표에 있는 모든 그림의 파일 이름 (정리 작업이 표에 없는 파일을 찾는다) */
|
||||
async listFileNames(): Promise<Set<string>> {
|
||||
const rows = await this.images.find({ select: { fileName: true } });
|
||||
return new Set(rows.map((row) => row.fileName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { gifBytes, pngBytes } from '@/../test/image-fixtures';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import {
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import type {
|
||||
MailSignatureRepository,
|
||||
PrepareSignature,
|
||||
SavedSignature,
|
||||
} from './mail-signature.repository';
|
||||
import { MailSignatureService } from './mail-signature.service';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const DOYUN = 'doyun.lee@arasystem.test';
|
||||
const src = (id: string) => `/api/mail-signature/images/${id}`;
|
||||
|
||||
/**
|
||||
* 표 대역 — MailSignatureRepository 와 같은 판단(주인 조건·저장 때 지울 그림)을 메모리에서 한다.
|
||||
* 실제 SQL(트랜잭션·부분 인덱스)은 e2e 가 본다.
|
||||
*/
|
||||
class FakeRepository {
|
||||
readonly signatures = new Map<string, MailSignatureEntity>();
|
||||
images: MailSignatureImageEntity[] = [];
|
||||
/** 다음 insertImage 를 실패시킨다 */
|
||||
failNextInsert = false;
|
||||
/** 다음 저장이 잠그기 직전에 이 그림이 지워진다 (다른 탭의 저장·정리 작업이 먼저 끝난 경우) */
|
||||
deletedBeforeNextSave: string | null = null;
|
||||
readonly saveCalls: {
|
||||
referenced: readonly string[];
|
||||
keepUploadedAfter: Date;
|
||||
}[] = [];
|
||||
|
||||
findSignature(user: string): Promise<MailSignatureEntity | null> {
|
||||
return Promise.resolve(this.signatures.get(user) ?? null);
|
||||
}
|
||||
|
||||
listImages(user: string): Promise<MailSignatureImageEntity[]> {
|
||||
return Promise.resolve(this.images.filter((row) => row.userEmail === user));
|
||||
}
|
||||
|
||||
findImage(
|
||||
id: string,
|
||||
user: string,
|
||||
): Promise<MailSignatureImageEntity | null> {
|
||||
return Promise.resolve(
|
||||
this.images.find((row) => row.id === id && row.userEmail === user) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
countImages(user: string): Promise<number> {
|
||||
return Promise.resolve(
|
||||
this.images.filter((row) => row.userEmail === user).length,
|
||||
);
|
||||
}
|
||||
|
||||
insertImage(row: MailSignatureImageEntity): Promise<void> {
|
||||
if (this.failNextInsert) {
|
||||
this.failNextInsert = false;
|
||||
return Promise.reject(new Error('db down'));
|
||||
}
|
||||
this.images.push(row);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
saveSignature(
|
||||
user: string,
|
||||
prepare: PrepareSignature,
|
||||
now: Date,
|
||||
keepUploadedAfter: Date,
|
||||
): Promise<SavedSignature> {
|
||||
if (this.deletedBeforeNextSave) {
|
||||
const gone = this.deletedBeforeNextSave;
|
||||
this.deletedBeforeNextSave = null;
|
||||
this.images = this.images.filter((row) => row.id !== gone);
|
||||
}
|
||||
const mine = this.images.filter((row) => row.userEmail === user);
|
||||
const { signature, referencedImageIds: referenced } = prepare(mine);
|
||||
this.saveCalls.push({ referenced, keepUploadedAfter });
|
||||
this.signatures.set(signature.userEmail, signature);
|
||||
for (const row of mine) {
|
||||
if (referenced.includes(row.id) && row.attachedAt === null) {
|
||||
row.attachedAt = now;
|
||||
}
|
||||
}
|
||||
const stale = mine.filter(
|
||||
(row) =>
|
||||
!referenced.includes(row.id) &&
|
||||
(row.attachedAt !== null || row.createdAt <= keepUploadedAfter),
|
||||
);
|
||||
this.images = this.images.filter((row) => !stale.includes(row));
|
||||
return Promise.resolve({
|
||||
signature,
|
||||
images: mine.filter((row) => !stale.includes(row)),
|
||||
removed: stale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailSignatureService', () => {
|
||||
let dir: string;
|
||||
let repository: FakeRepository;
|
||||
let storage: SignatureImageStorage;
|
||||
let service: MailSignatureService;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
const upload = (data: Buffer, name = 'logo.png', mimeType = 'image/png') =>
|
||||
service.uploadImage(ARA, { originalName: name, mimeType, data });
|
||||
|
||||
async function failureOf(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<{ code?: string; reason?: unknown }> {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException
|
||||
? { code: error.code, reason: error.context?.reason }
|
||||
: { code: 'NOT_BUSINESS' };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** 이미 올려 둔 그림 한 장 (표 + 파일) */
|
||||
async function storedImage(
|
||||
overrides: Partial<MailSignatureImageEntity> = {},
|
||||
): Promise<MailSignatureImageEntity> {
|
||||
const id =
|
||||
overrides.id ??
|
||||
`0000000${repository.images.length}-0000-4000-8000-000000000000`;
|
||||
const row: MailSignatureImageEntity = {
|
||||
id,
|
||||
userEmail: ARA,
|
||||
contentType: 'image/png',
|
||||
size: 33,
|
||||
width: 10,
|
||||
height: 10,
|
||||
fileName: `${id}.png`,
|
||||
createdAt: new Date('2026-10-26T00:00:00Z'),
|
||||
attachedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
repository.images.push(row);
|
||||
await storage.write(row.fileName, pngBytes(row.width, row.height));
|
||||
return row;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'signature-images-'));
|
||||
repository = new FakeRepository();
|
||||
storage = new SignatureImageStorage(dir);
|
||||
service = new MailSignatureService(
|
||||
repository as unknown as MailSignatureRepository,
|
||||
storage,
|
||||
);
|
||||
warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('저장한 적 없으면 기본값을 돌려준다', async () => {
|
||||
await expect(service.getSignature(ARA)).resolves.toEqual({
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('저장', () => {
|
||||
it('서식 서명은 보내는 정화를 거쳐 저장한다 — 내 그림만 남고, 서명이 쓰는 그림을 함께 돌려준다', async () => {
|
||||
const mine = await storedImage();
|
||||
const html = `<p onclick="x()"><strong>김아라</strong><script>alert(1)</script></p><img src="${src(mine.id)}" width="144" alt="" style="border:0"><img src="${src('11111111-1111-4111-8111-111111111111')}">`;
|
||||
|
||||
const saved = await service.saveSignature(` ${ARA.toUpperCase()} `, {
|
||||
bodyHtml: html,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
});
|
||||
|
||||
expect(saved).toEqual({
|
||||
bodyHtml: `<p><strong>김아라</strong></p><img src="${src(mine.id)}" width="144" alt="" />`,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
images: [
|
||||
{ id: mine.id, type: 'image/png', size: 33, width: 10, height: 10 },
|
||||
],
|
||||
updatedAt: expect.any(String) as string,
|
||||
});
|
||||
// 주소는 소문자로 모은다 — 같은 사람의 서명이 둘로 나뉘지 않게
|
||||
expect(repository.signatures.has(ARA)).toBe(true);
|
||||
expect(repository.saveCalls[0].referenced).toEqual([mine.id]);
|
||||
await expect(service.getSignature(ARA)).resolves.toEqual(saved);
|
||||
});
|
||||
|
||||
it('막 올린 그림(10분 안)은 서명에 없어도 남기도록 기준 시각을 넘긴다 — 올리기와 저장이 엇갈려도 편집기의 그림이 사라지지 않게', async () => {
|
||||
const before = Date.now();
|
||||
|
||||
await service.saveSignature(ARA, {
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
const { keepUploadedAfter } = repository.saveCalls[0];
|
||||
expect(keepUploadedAfter.getTime()).toBeGreaterThanOrEqual(
|
||||
before - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
);
|
||||
expect(keepUploadedAfter.getTime()).toBeLessThanOrEqual(
|
||||
Date.now() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it('잠그기 전에 다른 저장·정리 작업이 지운 그림은 서명에서 뺀다 — 없는 그림을 가리키는 서명을 남기지 않는다', async () => {
|
||||
const kept = await storedImage();
|
||||
const gone = await storedImage();
|
||||
repository.deletedBeforeNextSave = gone.id;
|
||||
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
bodyHtml: `<p>서명</p><img src="${src(kept.id)}" width="96"><img src="${src(gone.id)}" width="96">`,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(saved.bodyHtml).toBe(
|
||||
`<p>서명</p><img src="${src(kept.id)}" width="96" />`,
|
||||
);
|
||||
expect(saved.images.map((image) => image.id)).toEqual([kept.id]);
|
||||
expect(repository.saveCalls[0].referenced).toEqual([kept.id]);
|
||||
|
||||
// 그림만 있던 서명에서 그림이 모두 사라지면 서식 서명이 없다
|
||||
repository.deletedBeforeNextSave = kept.id;
|
||||
const empty = await service.saveSignature(ARA, {
|
||||
bodyHtml: `<img src="${src(kept.id)}">`,
|
||||
body: '텍스트 서명',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(empty.bodyHtml).toBeNull();
|
||||
expect(empty.body).toBe('텍스트 서명');
|
||||
});
|
||||
|
||||
it('서명이 더는 가리키지 않는 그림은 표와 파일에서 지운다', async () => {
|
||||
const kept = await storedImage({ attachedAt: new Date() });
|
||||
const dropped = await storedImage({ attachedAt: new Date() });
|
||||
|
||||
await service.saveSignature(ARA, {
|
||||
bodyHtml: `<img src="${src(kept.id)}">`,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(repository.images.map((row) => row.id)).toEqual([kept.id]);
|
||||
expect(existsSync(join(dir, kept.fileName))).toBe(true);
|
||||
expect(existsSync(join(dir, dropped.fileName))).toBe(false);
|
||||
});
|
||||
|
||||
it('지운 그림의 파일을 지우지 못해도 저장은 끝난다 — 경고만 (남은 파일은 정리 작업이 지운다)', async () => {
|
||||
const dropped = await storedImage({ attachedAt: new Date() });
|
||||
jest
|
||||
.spyOn(storage, 'remove')
|
||||
.mockRejectedValue(new Error('EACCES: permission denied'));
|
||||
|
||||
await expect(
|
||||
service.saveSignature(ARA, {
|
||||
bodyHtml: '<p>그림 없음</p>',
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ bodyHtml: '<p>그림 없음</p>', images: [] });
|
||||
expect(repository.images).not.toContainEqual(dropped);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('서명 그림 파일을 지우지 못했습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
it('빈 서식·텍스트, 정화해서 비는 서식은 null 로 저장한다', async () => {
|
||||
for (const bodyHtml of ['', '<script>x()</script>', null, undefined]) {
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
bodyHtml,
|
||||
body: '',
|
||||
isForNew: false,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(saved.bodyHtml).toBeNull();
|
||||
expect(saved.body).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it(`서명 하나에 서로 다른 그림은 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001 (같은 그림 두 번은 한 장 · 남의 그림은 정화가 지워 세지 않는다)`, async () => {
|
||||
const images: MailSignatureImageEntity[] = [];
|
||||
for (
|
||||
let index = 0;
|
||||
index < SIGNATURE_IMAGES_PER_SIGNATURE + 1;
|
||||
index += 1
|
||||
) {
|
||||
images.push(await storedImage());
|
||||
}
|
||||
const img = (id: string) => `<img src="${src(id)}" width="96">`;
|
||||
const save = (bodyHtml: string) =>
|
||||
service.saveSignature(ARA, {
|
||||
bodyHtml,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
save(images.map((image) => img(image.id)).join('')),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'signature-too-many-images' });
|
||||
expect(repository.saveCalls).toHaveLength(0);
|
||||
|
||||
// 세 장 + 같은 그림 한 번 더 + 남의 그림 — 서로 다른 내 그림은 세 장이다
|
||||
const [a, b, c] = images;
|
||||
const saved = await save(
|
||||
[a, b, c, a].map((image) => img(image.id)).join('') +
|
||||
img('11111111-1111-4111-8111-111111111111'),
|
||||
);
|
||||
expect(saved.images.map((image) => image.id)).toEqual([a.id, b.id, c.id]);
|
||||
});
|
||||
|
||||
it('한도를 넘으면 400 VAL_001 — 저장하지 않는다 (텍스트 2,000자 · 정화한 서식 10,000자 · 정화 전 길이·비용)', async () => {
|
||||
const cases: [Record<string, string>, string][] = [
|
||||
[{ body: '가'.repeat(2_001) }, 'signature-text-too-long'],
|
||||
[
|
||||
{ bodyHtml: `<p>${'가'.repeat(10_000)}</p>` },
|
||||
'signature-html-too-long',
|
||||
],
|
||||
[
|
||||
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
|
||||
'signature-html-too-long',
|
||||
],
|
||||
[{ bodyHtml: '<b>'.repeat(166_666) }, 'signature-html-too-complex'],
|
||||
];
|
||||
|
||||
for (const [input, reason] of cases) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.saveSignature(ARA, {
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
...input,
|
||||
}),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason });
|
||||
}
|
||||
expect(repository.saveCalls).toHaveLength(0);
|
||||
|
||||
// 경계 — 정확히 2,000자 · 정화한 결과 10,000자는 저장한다
|
||||
const saved = await service.saveSignature(ARA, {
|
||||
body: '가'.repeat(2_000),
|
||||
bodyHtml: `<p>${'가'.repeat(10_000 - 7)}</p>`,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
});
|
||||
expect(saved.bodyHtml).toHaveLength(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('그림 올리기', () => {
|
||||
it('PNG 를 내용으로 확인하고 서버가 만든 이름으로 저장한다 — 가로·세로는 파일 머리에서 읽는다', async () => {
|
||||
const data = pngBytes(480, 120, 2_048);
|
||||
|
||||
const image = await upload(data, '로고 (최종).PNG');
|
||||
|
||||
expect(image).toEqual({
|
||||
id: expect.stringMatching(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
) as string,
|
||||
type: 'image/png',
|
||||
size: 2_048,
|
||||
width: 480,
|
||||
height: 120,
|
||||
});
|
||||
const [row] = repository.images;
|
||||
expect(row).toMatchObject({
|
||||
userEmail: ARA,
|
||||
fileName: `${image.id}.png`,
|
||||
attachedAt: null,
|
||||
});
|
||||
expect(existsSync(join(dir, `${image.id}.png`))).toBe(true);
|
||||
});
|
||||
|
||||
it('내용이 확장자·MIME 과 다른 형식이면 FILE_001 — 아무것도 저장하지 않는다', async () => {
|
||||
for (const [data, name, mimeType] of [
|
||||
// PNG 내용인데 이름은 JPEG
|
||||
[pngBytes(10, 10), 'logo.jpg', 'image/jpeg'],
|
||||
// GIF 내용인데 PNG 라고 한다
|
||||
[gifBytes(10, 10), 'logo.png', 'image/png'],
|
||||
// 그림이 아니다
|
||||
[Buffer.from('<svg onload="x()"></svg>'), 'logo.png', 'image/png'],
|
||||
// 이름과 MIME 이 서로 다르다
|
||||
[pngBytes(10, 10), 'logo.png', 'image/gif'],
|
||||
] as const) {
|
||||
expect(await failureOf(() => upload(data, name, mimeType))).toEqual({
|
||||
code: 'FILE_001',
|
||||
reason: 'signature-image-content-mismatch',
|
||||
});
|
||||
}
|
||||
expect(repository.images).toHaveLength(0);
|
||||
});
|
||||
|
||||
it(`가로·세로가 각각 ${SIGNATURE_IMAGE_MAX_DIMENSION} 픽셀을 넘으면 FILE_001 — 아무것도 저장하지 않는다 (작은 파일 안의 거대한 그림 · 32비트 PNG 값)`, async () => {
|
||||
for (const [width, height] of [
|
||||
[SIGNATURE_IMAGE_MAX_DIMENSION + 1, 10],
|
||||
[10, SIGNATURE_IMAGE_MAX_DIMENSION + 1],
|
||||
// 표의 integer(2^31-1)를 넘는 PNG 가로 — 예전에는 INSERT 가 500 이 됐다
|
||||
[2 ** 31, 10],
|
||||
[2 ** 32 - 1, 2 ** 32 - 1],
|
||||
]) {
|
||||
expect(await failureOf(() => upload(pngBytes(width, height)))).toEqual({
|
||||
code: 'FILE_001',
|
||||
reason: 'signature-image-dimensions',
|
||||
});
|
||||
}
|
||||
expect(repository.images).toHaveLength(0);
|
||||
expect(await storage.list()).toEqual([]);
|
||||
|
||||
await expect(
|
||||
upload(
|
||||
pngBytes(
|
||||
SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
width: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
height: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('300KB 를 넘으면 FILE_002 (multer 가 먼저 막지만 한 번 더)', async () => {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
upload(pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1)),
|
||||
),
|
||||
).toEqual({ code: 'FILE_002', reason: 'signature-image-too-large' });
|
||||
await expect(
|
||||
upload(pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES)),
|
||||
).resolves.toMatchObject({ size: SIGNATURE_IMAGE_MAX_BYTES });
|
||||
});
|
||||
|
||||
it(`한 사람이 둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지 — 넘으면 400 VAL_001. 동시에 올려도 넘지 않는다`, async () => {
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: SIGNATURE_IMAGES_STORED_MAX + 2 }, () =>
|
||||
upload(pngBytes(10, 10)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
);
|
||||
const rejected = results.flatMap((result): unknown[] =>
|
||||
result.status === 'rejected' ? [result.reason] : [],
|
||||
);
|
||||
expect(rejected).toHaveLength(2);
|
||||
for (const reason of rejected) {
|
||||
expect(reason).toBeInstanceOf(BusinessException);
|
||||
expect((reason as BusinessException).code).toBe('VAL_001');
|
||||
expect((reason as BusinessException).context?.reason).toBe(
|
||||
'signature-image-storage-full',
|
||||
);
|
||||
}
|
||||
expect(repository.images).toHaveLength(SIGNATURE_IMAGES_STORED_MAX);
|
||||
|
||||
// 다른 사람은 따로 센다
|
||||
await expect(
|
||||
service.uploadImage(DOYUN, {
|
||||
originalName: 'logo.png',
|
||||
mimeType: 'image/png',
|
||||
data: pngBytes(10, 10),
|
||||
}),
|
||||
).resolves.toMatchObject({ type: 'image/png' });
|
||||
});
|
||||
|
||||
it('표에 넣지 못하면 쓴 파일을 지우고 실패를 그대로 던진다 — 다음 올리기는 된다', async () => {
|
||||
repository.failNextInsert = true;
|
||||
|
||||
expect(await failureOf(() => upload(pngBytes(10, 10)))).toEqual({
|
||||
code: 'NOT_BUSINESS',
|
||||
});
|
||||
expect(repository.images).toHaveLength(0);
|
||||
expect(await storage.list()).toEqual([]);
|
||||
|
||||
await expect(upload(pngBytes(10, 10))).resolves.toMatchObject({
|
||||
type: 'image/png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('그림 읽기', () => {
|
||||
it('주인에게는 파일과 형식을 준다 — 남의 그림·없는 그림은 RES_001', async () => {
|
||||
const mine = await storedImage({ width: 7, height: 3 });
|
||||
|
||||
await expect(service.openImage(ARA, mine.id)).resolves.toEqual({
|
||||
data: pngBytes(7, 3),
|
||||
type: 'image/png',
|
||||
});
|
||||
for (const [user, id] of [
|
||||
[DOYUN, mine.id],
|
||||
[ARA, '11111111-1111-4111-8111-111111111111'],
|
||||
]) {
|
||||
expect(await failureOf(() => service.openImage(user, id))).toEqual({
|
||||
code: 'RES_001',
|
||||
reason: 'signature-image-not-found',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('표에는 있는데 파일이 없으면 RES_001 — 경고를 남긴다', async () => {
|
||||
const mine = await storedImage();
|
||||
await storage.remove(mine.fileName);
|
||||
|
||||
expect(await failureOf(() => service.openImage(ARA, mine.id))).toEqual({
|
||||
code: 'RES_001',
|
||||
reason: 'signature-image-not-found',
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('서명 그림 파일이 없습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
it('발송용 — 내 그림 id 전부, 그리고 요청한 순서대로 내 그림만 읽는다 (파일이 없는 그림은 빼고 경고)', async () => {
|
||||
const first = await storedImage();
|
||||
const second = await storedImage({ attachedAt: new Date() });
|
||||
const missing = await storedImage();
|
||||
await storage.remove(missing.fileName);
|
||||
const others = await storedImage({ userEmail: DOYUN });
|
||||
|
||||
await expect(service.imageIdsOf(ARA)).resolves.toEqual(
|
||||
new Set([first.id, second.id, missing.id]),
|
||||
);
|
||||
|
||||
const loaded = await service.loadImages(ARA, [
|
||||
second.id,
|
||||
others.id,
|
||||
missing.id,
|
||||
first.id,
|
||||
]);
|
||||
expect(loaded.map((image) => image.id)).toEqual([second.id, first.id]);
|
||||
expect(loaded[0]).toEqual({
|
||||
id: second.id,
|
||||
type: 'image/png',
|
||||
data: pngBytes(10, 10),
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
await expect(service.loadImages(ARA, [])).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { checkHtmlParseBudget } from '@/modules/mail/html/html-parse-budget';
|
||||
import { MAX_BODY_HTML_LENGTH } from '@/modules/mail/mail.constants';
|
||||
import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html';
|
||||
import {
|
||||
findSignatureImageIds,
|
||||
rewriteSignatureImages,
|
||||
signatureImagePath,
|
||||
} from '@/modules/mail/html/signature-images';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { MailSignatureImageEntity } from './mail-signature-image.entity';
|
||||
import {
|
||||
SIGNATURE_HTML_MAX_LENGTH,
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_IMAGE_UPLOAD_GRACE_MS,
|
||||
SIGNATURE_TEXT_MAX_LENGTH,
|
||||
} from './mail-signature.constants';
|
||||
import { MailSignatureEntity } from './mail-signature.entity';
|
||||
import { MailSignatureRepository } from './mail-signature.repository';
|
||||
import type {
|
||||
MailSignature,
|
||||
MailSignatureInput,
|
||||
SignatureImage,
|
||||
SignatureImageUpload,
|
||||
StoredSignatureImage,
|
||||
} from './mail-signature.types';
|
||||
import {
|
||||
SIGNATURE_IMAGE_EXTENSIONS,
|
||||
SIGNATURE_IMAGE_TYPES_BY_EXTENSION,
|
||||
type SignatureImageType,
|
||||
detectSignatureImage,
|
||||
isWithinSignatureImageDimensions,
|
||||
} from './signature-image-format';
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const DEFAULT_SIGNATURE: Omit<MailSignature, 'images'> = {
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
function normalizeUser(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function toImage(row: MailSignatureImageEntity): SignatureImage {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.contentType as SignatureImageType,
|
||||
size: row.size,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** 올린 이름의 확장자 (소문자, 점 포함) */
|
||||
function extensionOf(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot >= 0 ? name.slice(dot).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 서명 (계약 2절 "서명") — 사용자마다 하나, 서버 DB 에 둔다.
|
||||
* - 서명 HTML 은 보내는 정화(서명 그림 포함)를 거친 뒤 저장한다 — 서명은 쓰기 화면이 본문에 넣어 그대로 보내므로 같은 규칙이다
|
||||
* - 그림은 PNG·JPEG·GIF 만 — 확장자·MIME 은 multer 가, 파일 내용(앞부분)·크기는 여기서 본다
|
||||
* - 그림은 주인만 본다 — 남의 그림 id 는 없는 것과 같다 (RES_001)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSignatureService {
|
||||
private readonly logger = new Logger(MailSignatureService.name);
|
||||
/** 사용자별 그림 올리기 차례 — 보관 한도(10장)를 동시 업로드로 넘지 못하게 (프로세스 메모리 — 인스턴스를 늘리면 DB 잠금으로) */
|
||||
private readonly uploadQueues = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly repository: MailSignatureRepository,
|
||||
private readonly storage: SignatureImageStorage,
|
||||
) {}
|
||||
|
||||
async getSignature(userEmail: string): Promise<MailSignature> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const [row, images] = await Promise.all([
|
||||
this.repository.findSignature(user),
|
||||
this.repository.listImages(user),
|
||||
]);
|
||||
if (!row) return { ...DEFAULT_SIGNATURE, images: [] };
|
||||
return this.toSignature(row, images);
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명을 저장한다. 서명 HTML 이 더는 가리키지 않는 그림은 지운다(표와 파일) — 막 올린 그림(10분 안)은 남긴다
|
||||
* (올리기와 저장이 엇갈린 경우 — SIGNATURE_IMAGE_UPLOAD_GRACE_MS).
|
||||
* 한도 — 텍스트 2,000자·정화한 HTML 10,000자(JS 문자열 길이), 정화 전 비용, 서로 다른 그림 3장 — 를 넘으면 VAL_001.
|
||||
*/
|
||||
async saveSignature(
|
||||
userEmail: string,
|
||||
input: MailSignatureInput,
|
||||
): Promise<MailSignature> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const body = input.body ? input.body : null;
|
||||
if (body !== null && body.length > SIGNATURE_TEXT_MAX_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-text-too-long', length: body.length },
|
||||
});
|
||||
}
|
||||
|
||||
const bodyHtml = await this.sanitizeSignatureHtml(user, input.bodyHtml);
|
||||
// 정화한 HTML 에는 내 그림만 남는다 — 같은 그림을 두 번 넣으면 한 장으로 센다
|
||||
const referenced = bodyHtml ? findSignatureImageIds(bodyHtml) : [];
|
||||
if (referenced.length > SIGNATURE_IMAGES_PER_SIGNATURE) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-too-many-images',
|
||||
count: referenced.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const saved = await this.repository.saveSignature(
|
||||
user,
|
||||
(images) => {
|
||||
// 잠근 뒤 본 그림 기준으로 — 정화한 뒤 그사이 지워진 그림(다른 탭의 저장·정리 작업)은 서명에서 뺀다 (없는 그림을 가리키지 않게)
|
||||
const existing = new Set(images.map((image) => image.id));
|
||||
const html =
|
||||
bodyHtml === null
|
||||
? null
|
||||
: rewriteSignatureImages(bodyHtml, (id) =>
|
||||
existing.has(id) ? signatureImagePath(id) : null,
|
||||
);
|
||||
const kept = html !== null && html.trim() !== '' ? html : null;
|
||||
return {
|
||||
signature: {
|
||||
userEmail: user,
|
||||
bodyHtml: kept,
|
||||
bodyText: body,
|
||||
isForNew: input.isForNew,
|
||||
isForReply: input.isForReply,
|
||||
updatedAt: now,
|
||||
},
|
||||
referencedImageIds: kept ? findSignatureImageIds(kept) : [],
|
||||
};
|
||||
},
|
||||
now,
|
||||
new Date(now.getTime() - SIGNATURE_IMAGE_UPLOAD_GRACE_MS),
|
||||
);
|
||||
await this.removeFiles(saved.removed, user);
|
||||
|
||||
return this.toSignature(saved.signature, saved.images);
|
||||
}
|
||||
|
||||
/** 서명 그림을 올린다 — 내용이 PNG·JPEG·GIF 이고 확장자·MIME 과 같은 형식이어야 한다 (아니면 FILE_001) */
|
||||
uploadImage(
|
||||
userEmail: string,
|
||||
upload: SignatureImageUpload,
|
||||
): Promise<SignatureImage> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const info = detectSignatureImage(upload.data);
|
||||
const declared = SIGNATURE_IMAGE_TYPES_BY_EXTENSION.get(
|
||||
extensionOf(upload.originalName),
|
||||
);
|
||||
if (!info || info.type !== declared || info.type !== upload.mimeType) {
|
||||
return Promise.reject(
|
||||
new BusinessException('FILE_001', {
|
||||
context: { reason: 'signature-image-content-mismatch' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 가로·세로 각각 4096 픽셀까지 — 작은 파일 안의 거대한 그림(압축 폭탄)·표의 integer 를 넘는 값을 받지 않는다
|
||||
if (!isWithinSignatureImageDimensions(info)) {
|
||||
return Promise.reject(
|
||||
new BusinessException('FILE_001', {
|
||||
context: {
|
||||
reason: 'signature-image-dimensions',
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (upload.data.length > SIGNATURE_IMAGE_MAX_BYTES) {
|
||||
return Promise.reject(
|
||||
new BusinessException('FILE_002', {
|
||||
context: {
|
||||
reason: 'signature-image-too-large',
|
||||
size: upload.data.length,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.inUploadQueue(user, async () => {
|
||||
const count = await this.repository.countImages(user);
|
||||
if (count >= SIGNATURE_IMAGES_STORED_MAX) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-image-storage-full', count },
|
||||
});
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const row: MailSignatureImageEntity = {
|
||||
id,
|
||||
userEmail: user,
|
||||
contentType: info.type,
|
||||
size: upload.data.length,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
fileName: `${id}.${SIGNATURE_IMAGE_EXTENSIONS[info.type]}`,
|
||||
createdAt: new Date(),
|
||||
attachedAt: null,
|
||||
};
|
||||
await this.storage.write(row.fileName, upload.data);
|
||||
try {
|
||||
await this.repository.insertImage(row);
|
||||
} catch (error) {
|
||||
// 표에 넣지 못한 파일은 남기지 않는다
|
||||
await this.storage.remove(row.fileName).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return toImage(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** 그 사람의 그림 파일 — 남의 그림·없는 그림은 RES_001 */
|
||||
async openImage(
|
||||
userEmail: string,
|
||||
id: string,
|
||||
): Promise<{ data: Buffer; type: SignatureImageType }> {
|
||||
const user = normalizeUser(userEmail);
|
||||
const row = await this.repository.findImage(id, user);
|
||||
const data = row ? await this.storage.read(row.fileName) : null;
|
||||
if (!row || !data) {
|
||||
if (row) {
|
||||
this.logger.warn(
|
||||
`[RES_001]${traceTag()} 서명 그림 파일이 없습니다: user=${maskEmail(user)}, id=${id}`,
|
||||
);
|
||||
}
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'signature-image-not-found' },
|
||||
});
|
||||
}
|
||||
return { data, type: row.contentType as SignatureImageType };
|
||||
}
|
||||
|
||||
/** 보내는 정화가 남겨도 되는 그림 id — 그 사람의 그림 전부 (서명에 붙었든 아니든) */
|
||||
async imageIdsOf(userEmail: string): Promise<ReadonlySet<string>> {
|
||||
const images = await this.repository.listImages(normalizeUser(userEmail));
|
||||
return new Set(images.map((image) => image.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일에 함께 실을 그림을 읽는다 — 그 사람의 그림만, 요청한 순서대로. 파일이 없는 그림은 빼고 경고만 남긴다
|
||||
* (부르는 쪽이 그 img 를 지운다).
|
||||
*/
|
||||
async loadImages(
|
||||
userEmail: string,
|
||||
ids: readonly string[],
|
||||
): Promise<StoredSignatureImage[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const user = normalizeUser(userEmail);
|
||||
const rows = new Map(
|
||||
(await this.repository.listImages(user)).map((row) => [row.id, row]),
|
||||
);
|
||||
|
||||
const loaded: StoredSignatureImage[] = [];
|
||||
for (const id of ids) {
|
||||
const row = rows.get(id);
|
||||
const data = row ? await this.storage.read(row.fileName) : null;
|
||||
if (!row || !data) {
|
||||
this.logger.warn(
|
||||
`[RES_001]${traceTag()} 서명 그림을 메일에 싣지 못해 뺍니다: user=${maskEmail(user)}, id=${id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
loaded.push({ id, type: row.contentType as SignatureImageType, data });
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** 서명 HTML 정화 — 비었으면 null. 정화 전 비용·정화 뒤 길이 한도를 넘으면 VAL_001 */
|
||||
private async sanitizeSignatureHtml(
|
||||
user: string,
|
||||
html: string | null | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!html) return null;
|
||||
// 정화 전 — 쓰기 화면의 서식 본문과 같은 길이 한도와 파서 비용 (정화가 이벤트 루프를 오래 붙잡지 않게)
|
||||
if (html.length > MAX_BODY_HTML_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'signature-html-too-long', length: html.length },
|
||||
});
|
||||
}
|
||||
const budget = checkHtmlParseBudget(html);
|
||||
if (!budget.ok) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-html-too-complex',
|
||||
detail: budget.reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const sanitized = sanitizeOutgoingHtml(html, {
|
||||
signatureImageIds: await this.imageIdsOf(user),
|
||||
});
|
||||
if (sanitized.length > SIGNATURE_HTML_MAX_LENGTH) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'signature-html-too-long',
|
||||
length: sanitized.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
return sanitized === '' ? null : sanitized;
|
||||
}
|
||||
|
||||
private toSignature(
|
||||
row: MailSignatureEntity,
|
||||
images: readonly MailSignatureImageEntity[],
|
||||
): MailSignature {
|
||||
const byId = new Map(images.map((image) => [image.id, image]));
|
||||
const used = row.bodyHtml ? findSignatureImageIds(row.bodyHtml) : [];
|
||||
return {
|
||||
bodyHtml: row.bodyHtml,
|
||||
body: row.bodyText,
|
||||
isForNew: row.isForNew,
|
||||
isForReply: row.isForReply,
|
||||
images: used.flatMap((id) => {
|
||||
const image = byId.get(id);
|
||||
return image ? [toImage(image)] : [];
|
||||
}),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 지운 그림의 파일을 지운다 — 실패해도 서명 저장은 끝났다 (남은 파일은 정리 작업이 지운다) */
|
||||
private async removeFiles(
|
||||
removed: readonly MailSignatureImageEntity[],
|
||||
user: string,
|
||||
): Promise<void> {
|
||||
for (const image of removed) {
|
||||
try {
|
||||
await this.storage.remove(image.fileName);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 서명 그림 파일을 지우지 못했습니다: user=${maskEmail(user)}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 같은 사용자의 작업을 차례로 돌린다 (앞 작업이 실패해도 다음 작업은 돈다) */
|
||||
private inUploadQueue<T>(user: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = this.uploadQueues.get(user) ?? Promise.resolve();
|
||||
const run = previous.then(task, task);
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.uploadQueues.set(user, tail);
|
||||
void tail.then(() => {
|
||||
if (this.uploadQueues.get(user) === tail) this.uploadQueues.delete(user);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SignatureImageType } from './signature-image-format';
|
||||
|
||||
/**
|
||||
* 서명 도메인 타입 — 계약 2절 "서명"과 같은 모양이다.
|
||||
*/
|
||||
|
||||
export interface SignatureImage {
|
||||
readonly id: string;
|
||||
readonly type: SignatureImageType;
|
||||
/** 바이트 */
|
||||
readonly size: number;
|
||||
/** 원본 픽셀 */
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface MailSignature {
|
||||
/** 서명 서식 — 서식 본문 규칙 + 서명 그림. 정화 뒤 10,000자 이하 */
|
||||
readonly bodyHtml: string | null;
|
||||
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) — 2,000자 이하 */
|
||||
readonly body: string | null;
|
||||
readonly isForNew: boolean;
|
||||
readonly isForReply: boolean;
|
||||
/** 서명 HTML 이 쓰는 그림 (HTML 에 나온 순서) */
|
||||
readonly images: SignatureImage[];
|
||||
/** 저장한 적 없으면 null */
|
||||
readonly updatedAt: string | null;
|
||||
}
|
||||
|
||||
/** PUT /api/mail-signature 본문 */
|
||||
export interface MailSignatureInput {
|
||||
readonly bodyHtml?: string | null;
|
||||
readonly body?: string | null;
|
||||
readonly isForNew: boolean;
|
||||
readonly isForReply: boolean;
|
||||
}
|
||||
|
||||
/** 메일에 함께 실을 그림 — 발송 때 읽는다 */
|
||||
export interface StoredSignatureImage {
|
||||
readonly id: string;
|
||||
readonly type: SignatureImageType;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
/** 올린 그림 파일 (multer 가 메모리에 받은 것 — 한 장 300KB 이하) */
|
||||
export interface SignatureImageUpload {
|
||||
readonly originalName: string;
|
||||
readonly mimeType: string;
|
||||
readonly data: Buffer;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
gifBytes as gif,
|
||||
jpegBytes as jpeg,
|
||||
pngBytes as png,
|
||||
} from '@/../test/image-fixtures';
|
||||
import { SIGNATURE_IMAGE_MAX_DIMENSION } from './mail-signature.constants';
|
||||
import {
|
||||
detectSignatureImage,
|
||||
isWithinSignatureImageDimensions,
|
||||
} from './signature-image-format';
|
||||
|
||||
describe('isWithinSignatureImageDimensions (서명 그림 크기 상한)', () => {
|
||||
it(`가로·세로가 각각 1~${SIGNATURE_IMAGE_MAX_DIMENSION} 픽셀이면 받는다`, () => {
|
||||
expect(isWithinSignatureImageDimensions({ width: 1, height: 1 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isWithinSignatureImageDimensions({
|
||||
width: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
height: SIGNATURE_IMAGE_MAX_DIMENSION,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('하나라도 넘거나 0 이면 받지 않는다 — PNG 머리의 32비트 값(표의 integer 를 넘는다) 포함', () => {
|
||||
for (const [width, height] of [
|
||||
[SIGNATURE_IMAGE_MAX_DIMENSION + 1, 1],
|
||||
[1, SIGNATURE_IMAGE_MAX_DIMENSION + 1],
|
||||
[0, 10],
|
||||
[2 ** 31, 10],
|
||||
]) {
|
||||
expect(isWithinSignatureImageDimensions({ width, height })).toBe(false);
|
||||
}
|
||||
// PNG 는 머리에 32비트로 적는다 — 읽기는 하고(형식은 맞다) 크기 검사가 거른다
|
||||
const huge = detectSignatureImage(png(2 ** 32 - 1, 2 ** 32 - 1));
|
||||
expect(huge).toEqual({
|
||||
type: 'image/png',
|
||||
width: 2 ** 32 - 1,
|
||||
height: 2 ** 32 - 1,
|
||||
});
|
||||
expect(huge && isWithinSignatureImageDimensions(huge)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectSignatureImage (서명 그림 형식·크기)', () => {
|
||||
it('PNG·GIF(87a·89a)·JPEG(기본·점진) 의 형식과 가로·세로를 읽는다', () => {
|
||||
expect(detectSignatureImage(png(640, 120))).toEqual({
|
||||
type: 'image/png',
|
||||
width: 640,
|
||||
height: 120,
|
||||
});
|
||||
expect(detectSignatureImage(gif(300, 80))).toEqual({
|
||||
type: 'image/gif',
|
||||
width: 300,
|
||||
height: 80,
|
||||
});
|
||||
expect(detectSignatureImage(gif(1, 2, 'GIF87a'))).toEqual({
|
||||
type: 'image/gif',
|
||||
width: 1,
|
||||
height: 2,
|
||||
});
|
||||
expect(detectSignatureImage(jpeg(1024, 256))).toEqual({
|
||||
type: 'image/jpeg',
|
||||
width: 1024,
|
||||
height: 256,
|
||||
});
|
||||
// 점진(SOF2) · 표지 앞 채움 바이트
|
||||
expect(
|
||||
detectSignatureImage(jpeg(96, 48, { sof: 0xc2, fill: true })),
|
||||
).toEqual({ type: 'image/jpeg', width: 96, height: 48 });
|
||||
});
|
||||
|
||||
it('내용이 그 형식이 아니면 받지 않는다 — WebP·SVG·HTML·빈 파일', () => {
|
||||
for (const data of [
|
||||
Buffer.from('RIFF\x00\x00\x00\x00WEBPVP8 ', 'binary'),
|
||||
Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>'),
|
||||
Buffer.from('<html><script>alert(1)</script></html>'),
|
||||
Buffer.alloc(0),
|
||||
]) {
|
||||
expect(detectSignatureImage(data)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('머리가 잘렸거나 망가졌거나 크기가 0 이면 받지 않는다', () => {
|
||||
expect(detectSignatureImage(png(10, 10).subarray(0, 20))).toBeNull();
|
||||
expect(detectSignatureImage(png(0, 10))).toBeNull();
|
||||
expect(detectSignatureImage(gif(10, 0))).toBeNull();
|
||||
expect(detectSignatureImage(gif(10, 10).subarray(0, 8))).toBeNull();
|
||||
expect(detectSignatureImage(jpeg(0, 10))).toBeNull();
|
||||
|
||||
// IHDR 가 첫 덩어리가 아니다
|
||||
const broken = png(10, 10);
|
||||
broken.write('IDAT', 12, 'ascii');
|
||||
expect(detectSignatureImage(broken)).toBeNull();
|
||||
|
||||
// JPEG: SOF 전에 그림 자료(SOS)가 오거나, 조각 길이가 파일 밖을 가리킨다
|
||||
expect(
|
||||
detectSignatureImage(
|
||||
Buffer.from([0xff, 0xd8, 0xff, 0xda, 0x00, 0x02, 0xff, 0xd9]),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectSignatureImage(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x7f, 0xff])),
|
||||
).toBeNull();
|
||||
expect(
|
||||
detectSignatureImage(Buffer.from([0xff, 0xd8, 0xff, 0xd9])),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('긴 JPEG 도 조각 길이를 따라 앞으로만 간다 (입력 길이에 비례)', () => {
|
||||
// 64KB 짜리 주석(COM) 조각 4개 뒤에 SOF
|
||||
const comment = Buffer.alloc(0xffff + 2);
|
||||
comment[0] = 0xff;
|
||||
comment[1] = 0xfe;
|
||||
comment.writeUInt16BE(0xffff, 2);
|
||||
const data = Buffer.concat([
|
||||
Buffer.from([0xff, 0xd8]),
|
||||
comment,
|
||||
comment,
|
||||
comment,
|
||||
comment,
|
||||
jpeg(20, 10).subarray(2),
|
||||
]);
|
||||
|
||||
expect(detectSignatureImage(data)).toEqual({
|
||||
type: 'image/jpeg',
|
||||
width: 20,
|
||||
height: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 서명 그림 형식 확인 (순수 함수) — 파일 앞부분(매직 바이트)으로 형식을 알아내고 머리에서 가로·세로 픽셀을 읽는다.
|
||||
* 확장자·MIME 은 클라이언트가 정하므로 믿지 않는다 — 이름이 .png 여도 내용이 PNG 가 아니면 받지 않는다 (.claude/rules/security.md).
|
||||
* 그림을 풀지(디코드) 않는다 — 머리만 읽으므로 네이티브 라이브러리·새 의존성이 필요 없다.
|
||||
*/
|
||||
|
||||
import { SIGNATURE_IMAGE_MAX_DIMENSION } from './mail-signature.constants';
|
||||
|
||||
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif';
|
||||
|
||||
export interface SignatureImageInfo {
|
||||
readonly type: SignatureImageType;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 받을 수 있는 크기인가 — 가로·세로가 각각 1~SIGNATURE_IMAGE_MAX_DIMENSION(4096) 픽셀.
|
||||
* (0 은 detectSignatureImage 가 이미 거른다)
|
||||
*/
|
||||
export function isWithinSignatureImageDimensions(
|
||||
info: Pick<SignatureImageInfo, 'width' | 'height'>,
|
||||
): boolean {
|
||||
return (
|
||||
info.width >= 1 &&
|
||||
info.height >= 1 &&
|
||||
info.width <= SIGNATURE_IMAGE_MAX_DIMENSION &&
|
||||
info.height <= SIGNATURE_IMAGE_MAX_DIMENSION
|
||||
);
|
||||
}
|
||||
|
||||
/** 형식별 저장 확장자 — 서버가 만드는 파일 이름에 쓴다 */
|
||||
export const SIGNATURE_IMAGE_EXTENSIONS: Readonly<
|
||||
Record<SignatureImageType, string>
|
||||
> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
|
||||
/** 받는 확장자 → 형식 (업로드 사전 검사 — 확장자와 MIME 이 이 짝이어야 한다) */
|
||||
export const SIGNATURE_IMAGE_TYPES_BY_EXTENSION: ReadonlyMap<
|
||||
string,
|
||||
SignatureImageType
|
||||
> = new Map([
|
||||
['.png', 'image/png'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.gif', 'image/gif'],
|
||||
]);
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
/** PNG 첫 덩어리 IHDR — 길이(4) 'IHDR'(4) 가로(4) 세로(4) … */
|
||||
const PNG_IHDR_LENGTH = 13;
|
||||
const PNG_IHDR_TYPE = Buffer.from('IHDR', 'ascii');
|
||||
|
||||
const GIF_SIGNATURES = [
|
||||
Buffer.from('GIF87a', 'ascii'),
|
||||
Buffer.from('GIF89a', 'ascii'),
|
||||
];
|
||||
/** GIF 논리 화면 가로·세로 (little-endian 16비트) 위치 */
|
||||
const GIF_WIDTH_OFFSET = 6;
|
||||
const GIF_HEIGHT_OFFSET = 8;
|
||||
|
||||
const JPEG_SOI = [0xff, 0xd8, 0xff];
|
||||
const JPEG_MARKER_PREFIX = 0xff;
|
||||
/** 길이 없이 홀로 오는 표지 — TEM, RST0~7 */
|
||||
const JPEG_STANDALONE = new Set([
|
||||
0x01, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7,
|
||||
]);
|
||||
/** 그림 자료가 시작되거나 끝나는 표지 — 그 전에 SOF 가 있어야 한다 */
|
||||
const JPEG_SOS = 0xda;
|
||||
const JPEG_EOI = 0xd9;
|
||||
/** 프레임 머리(SOF0~15) 중 프레임이 아닌 것 — DHT(C4)·JPG(C8)·DAC(CC) */
|
||||
const JPEG_NOT_SOF = new Set([0xc4, 0xc8, 0xcc]);
|
||||
const JPEG_SOF_FIRST = 0xc0;
|
||||
const JPEG_SOF_LAST = 0xcf;
|
||||
|
||||
function startsWith(data: Buffer, prefix: Buffer | readonly number[]): boolean {
|
||||
if (data.length < prefix.length) return false;
|
||||
for (let index = 0; index < prefix.length; index++) {
|
||||
if (data[index] !== prefix[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sized(
|
||||
type: SignatureImageType,
|
||||
width: number,
|
||||
height: number,
|
||||
): SignatureImageInfo | null {
|
||||
return width > 0 && height > 0 ? { type, width, height } : null;
|
||||
}
|
||||
|
||||
function readPng(data: Buffer): SignatureImageInfo | null {
|
||||
// 서명 8 + 길이 4 + 형식 4 + 가로 4 + 세로 4
|
||||
if (data.length < 24) return null;
|
||||
if (data.readUInt32BE(8) !== PNG_IHDR_LENGTH) return null;
|
||||
if (!data.subarray(12, 16).equals(PNG_IHDR_TYPE)) return null;
|
||||
return sized('image/png', data.readUInt32BE(16), data.readUInt32BE(20));
|
||||
}
|
||||
|
||||
function readGif(data: Buffer): SignatureImageInfo | null {
|
||||
if (data.length < GIF_HEIGHT_OFFSET + 2) return null;
|
||||
return sized(
|
||||
'image/gif',
|
||||
data.readUInt16LE(GIF_WIDTH_OFFSET),
|
||||
data.readUInt16LE(GIF_HEIGHT_OFFSET),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JPEG 은 표지(0xFF xx)와 길이로 이어진 조각을 따라가 첫 프레임 머리(SOFn)에서 크기를 읽는다.
|
||||
* 조각 길이를 따라 앞으로만 가므로 입력 길이에 비례해 끝난다. SOF 전에 그림 자료(SOS)·끝(EOI)이 오면 받지 않는다.
|
||||
*/
|
||||
function readJpeg(data: Buffer): SignatureImageInfo | null {
|
||||
let index = 2;
|
||||
while (index < data.length) {
|
||||
if (data[index] !== JPEG_MARKER_PREFIX) return null;
|
||||
// 표지 앞의 채움 바이트(0xFF 여러 개)는 건너뛴다
|
||||
while (index < data.length && data[index] === JPEG_MARKER_PREFIX) index++;
|
||||
if (index >= data.length) return null;
|
||||
const marker = data[index];
|
||||
index++;
|
||||
|
||||
if (JPEG_STANDALONE.has(marker)) continue;
|
||||
if (marker === JPEG_SOS || marker === JPEG_EOI) return null;
|
||||
if (index + 2 > data.length) return null;
|
||||
|
||||
const length = data.readUInt16BE(index);
|
||||
if (length < 2 || index + length > data.length) return null;
|
||||
|
||||
const isFrame =
|
||||
marker >= JPEG_SOF_FIRST &&
|
||||
marker <= JPEG_SOF_LAST &&
|
||||
!JPEG_NOT_SOF.has(marker);
|
||||
if (isFrame) {
|
||||
// 길이(2) 정밀도(1) 세로(2) 가로(2)
|
||||
if (length < 7) return null;
|
||||
return sized(
|
||||
'image/jpeg',
|
||||
data.readUInt16BE(index + 5),
|
||||
data.readUInt16BE(index + 3),
|
||||
);
|
||||
}
|
||||
index += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 내용으로 형식과 크기를 알아낸다 — PNG·JPEG·GIF 가 아니거나 머리가 망가졌으면 null */
|
||||
export function detectSignatureImage(data: Buffer): SignatureImageInfo | null {
|
||||
if (startsWith(data, PNG_SIGNATURE)) return readPng(data);
|
||||
if (GIF_SIGNATURES.some((signature) => startsWith(data, signature))) {
|
||||
return readGif(data);
|
||||
}
|
||||
if (startsWith(data, JPEG_SOI)) return readJpeg(data);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, PipeTransform } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/** 서명 그림 id — 서버가 만든 UUID */
|
||||
const SIGNATURE_IMAGE_ID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* 경로의 서명 그림 id 형식 확인 — 형식이 틀리면 "그런 그림이 없다"와 같으므로 RES_001 (DB 의 uuid 열에 넣기 전에 막는다 —
|
||||
* 형식이 틀린 값이 DB 오류(SYS_001)로 새어 나가지 않게).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SignatureImageIdPipe implements PipeTransform<unknown, string> {
|
||||
transform(value: unknown): string {
|
||||
if (typeof value === 'string' && SIGNATURE_IMAGE_ID.test(value)) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'malformed-signature-image-id' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdtempSync, rmSync, statSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { SignatureImageStorage } from './signature-image-storage';
|
||||
|
||||
const NAME = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.png';
|
||||
|
||||
describe('SignatureImageStorage (서명 그림 파일)', () => {
|
||||
let root: string;
|
||||
let dir: string;
|
||||
let storage: SignatureImageStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'signature-storage-'));
|
||||
// 아직 없는 하위 폴더 — 처음 쓸 때 만든다
|
||||
dir = join(root, 'mail-signatures');
|
||||
storage = new SignatureImageStorage(dir);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('쓰고 읽고 지운다 — 없는 파일은 null, 두 번 지워도 된다', async () => {
|
||||
await expect(storage.read(NAME)).resolves.toBeNull();
|
||||
await expect(storage.list()).resolves.toEqual([]);
|
||||
|
||||
await storage.write(NAME, Buffer.from('png'));
|
||||
await expect(storage.read(NAME)).resolves.toEqual(Buffer.from('png'));
|
||||
expect((await storage.list()).map((file) => file.fileName)).toEqual([NAME]);
|
||||
|
||||
await storage.remove(NAME);
|
||||
await storage.remove(NAME);
|
||||
await expect(storage.read(NAME)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('같은 이름을 덮어쓰지 않는다', async () => {
|
||||
await storage.write(NAME, Buffer.from('first'));
|
||||
|
||||
await expect(storage.write(NAME, Buffer.from('second'))).rejects.toThrow();
|
||||
await expect(storage.read(NAME)).resolves.toEqual(Buffer.from('first'));
|
||||
});
|
||||
|
||||
it('서버가 만든 이름(UUID.png·jpg·gif)이 아니면 경로로 쓰지 않는다', async () => {
|
||||
for (const name of [
|
||||
'../../etc/passwd',
|
||||
`../${NAME}`,
|
||||
`${NAME}/x`,
|
||||
NAME.toUpperCase(),
|
||||
'3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.svg',
|
||||
'3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d.jpeg',
|
||||
'',
|
||||
]) {
|
||||
await expect(storage.write(name, Buffer.from('x'))).rejects.toThrow(
|
||||
'서명 그림 파일 이름 형식이 올바르지 않습니다.',
|
||||
);
|
||||
await expect(storage.read(name)).rejects.toThrow();
|
||||
await expect(storage.remove(name)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('그룹은 쓸 수 없고 다른 사람은 읽을 수도 없다 (POSIX — Windows 는 권한 비트가 없다)', async () => {
|
||||
await storage.write(NAME, Buffer.from('png'));
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// 만들 때 0o640 — umask 가 더 좁히는 것은 괜찮다
|
||||
const mode = statSync(join(dir, NAME)).mode;
|
||||
expect(mode & 0o027).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { SIGNATURE_IMAGE_DIR } from './mail-signature.constants';
|
||||
|
||||
/** 서버가 만든 파일 이름만 다룬다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */
|
||||
const STORED_FILE_NAME =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:png|jpg|gif)$/;
|
||||
|
||||
/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) */
|
||||
const FILE_MODE = 0o640;
|
||||
|
||||
export interface StoredFile {
|
||||
readonly fileName: string;
|
||||
readonly modifiedAt: Date;
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 그림 파일 저장소 — 업로드 볼륨의 하위 폴더(SIGNATURE_IMAGE_DIR).
|
||||
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 컨트롤러가 형식을 정해 준다 (.claude/rules/security.md "파일 업로드").
|
||||
* 모듈은 useFactory 로 만든다 (생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다.
|
||||
*/
|
||||
export class SignatureImageStorage {
|
||||
constructor(private readonly dir: string = SIGNATURE_IMAGE_DIR) {}
|
||||
|
||||
async write(fileName: string, data: Buffer): Promise<void> {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
// wx — 같은 이름이 이미 있으면 덮어쓰지 않고 실패한다 (UUID 라 생기지 않지만 지키는 선)
|
||||
await writeFile(this.pathOf(fileName), data, {
|
||||
flag: 'wx',
|
||||
mode: FILE_MODE,
|
||||
});
|
||||
}
|
||||
|
||||
/** 없으면 null */
|
||||
async read(fileName: string): Promise<Buffer | null> {
|
||||
try {
|
||||
return await readFile(this.pathOf(fileName));
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 지운다 — 이미 없으면 그대로 둔다 */
|
||||
async remove(fileName: string): Promise<void> {
|
||||
try {
|
||||
await unlink(this.pathOf(fileName));
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 폴더 안의 그림 파일 (정리 작업용). 폴더가 없으면 빈 목록 */
|
||||
async list(): Promise<StoredFile[]> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(this.dir);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const files: StoredFile[] = [];
|
||||
for (const fileName of names.filter((name) =>
|
||||
STORED_FILE_NAME.test(name),
|
||||
)) {
|
||||
try {
|
||||
const { mtime } = await stat(join(this.dir, fileName));
|
||||
files.push({ fileName, modifiedAt: mtime });
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private pathOf(fileName: string): string {
|
||||
if (!STORED_FILE_NAME.test(fileName)) {
|
||||
// 서버가 만든 이름이 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001)
|
||||
throw new Error('서명 그림 파일 이름 형식이 올바르지 않습니다.');
|
||||
}
|
||||
return join(this.dir, fileName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { ERROR_CODES } from '@/common/errors/error-codes';
|
||||
import {
|
||||
MAX_ATTACHMENT_LIST_LENGTH,
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
allowedAttachmentExtensions,
|
||||
isAllowedAttachment,
|
||||
messageSourceFileName,
|
||||
normalizeMimeType,
|
||||
resolveAttachmentType,
|
||||
resolveAttachmentsTotalLimit,
|
||||
sanitizeFileName,
|
||||
} from './attachment.policy';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe('첨부 개수 한도', () => {
|
||||
it('레지스트리의 MAIL_006 문구가 말하는 개수는 한도 상수와 같다 — 문구와 실제 거절 기준이 어긋나지 않게', () => {
|
||||
expect(ERROR_CODES.MAIL_006.message).toContain(
|
||||
`${MAX_ATTACHMENTS_PER_MESSAGE}개`,
|
||||
);
|
||||
});
|
||||
|
||||
it('형식 검증(DTO)의 목록 상한은 개수 한도보다 넉넉하다 — 한도를 넘긴 요청이 VAL_001 이 아니라 MAIL_006 을 받게', () => {
|
||||
expect(MAX_ATTACHMENT_LIST_LENGTH).toBeGreaterThan(
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('첨부 합계 한도 (resolveAttachmentsTotalLimit)', () => {
|
||||
it('설정 한도와 엔진 한도 중 작은 값을 쓴다', () => {
|
||||
expect(resolveAttachmentsTotalLimit(25 * MB, 50_000_000)).toBe(25 * MB);
|
||||
expect(resolveAttachmentsTotalLimit(25 * MB, 10 * MB)).toBe(10 * MB);
|
||||
});
|
||||
|
||||
it('엔진이 한도를 알려 주지 않으면 설정 한도만 쓴다', () => {
|
||||
for (const unknown of [null, undefined, 0, -1, 1.5, Number.NaN]) {
|
||||
expect(resolveAttachmentsTotalLimit(25 * MB, unknown)).toBe(25 * MB);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일 첨부 형식 정책', () => {
|
||||
it('계약의 허용 형식을 모두 받는다 (확장자 + MIME)', () => {
|
||||
const allowed: Array<[string, string]> = [
|
||||
['보고서.pdf', 'application/pdf'],
|
||||
['a.doc', 'application/msword'],
|
||||
[
|
||||
'a.docx',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
],
|
||||
['a.xls', 'application/vnd.ms-excel'],
|
||||
[
|
||||
'체크리스트.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
],
|
||||
['a.ppt', 'application/vnd.ms-powerpoint'],
|
||||
[
|
||||
'a.pptx',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
],
|
||||
['기안.hwp', 'application/x-hwp'],
|
||||
['기안.hwpx', 'application/hwp+zip'],
|
||||
['메모.txt', 'text/plain'],
|
||||
['목록.csv', 'text/csv'],
|
||||
['사진.png', 'image/png'],
|
||||
['사진.jpg', 'image/jpeg'],
|
||||
['사진.JPEG', 'image/jpeg'],
|
||||
['움짤.gif', 'image/gif'],
|
||||
['사진.webp', 'image/webp'],
|
||||
['묶음.zip', 'application/zip'],
|
||||
];
|
||||
|
||||
for (const [name, mime] of allowed) {
|
||||
expect(isAllowedAttachment(name, mime)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('브라우저·OS 가 보내는 다른 MIME 표기도 받는다', () => {
|
||||
expect(
|
||||
isAllowedAttachment('묶음.zip', 'application/x-zip-compressed'),
|
||||
).toBe(true);
|
||||
expect(isAllowedAttachment('목록.csv', 'application/vnd.ms-excel')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isAllowedAttachment('기안.hwp', 'application/haansofthwp')).toBe(
|
||||
true,
|
||||
);
|
||||
// 한컴오피스가 없는 PC 는 octet-stream 으로 보낸다
|
||||
expect(isAllowedAttachment('기안.hwp', 'application/octet-stream')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isAllowedAttachment('메모.txt', 'text/plain; charset=utf-8')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('브라우저가 그릴 수 있는 형식은 octet-stream 으로 위장하면 거부한다', () => {
|
||||
for (const name of ['사진.png', '보고서.pdf', '메모.txt', '사진.jpg']) {
|
||||
expect(isAllowedAttachment(name, 'application/octet-stream')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('확장자와 MIME 이 어긋나면 거부한다 (위장 방지)', () => {
|
||||
expect(isAllowedAttachment('payload.png', 'text/html')).toBe(false);
|
||||
expect(isAllowedAttachment('payload.html', 'image/png')).toBe(false);
|
||||
expect(isAllowedAttachment('noext', 'application/pdf')).toBe(false);
|
||||
});
|
||||
|
||||
it('실행·렌더링 가능한 형식은 허용 목록에 없다', () => {
|
||||
const extensions = allowedAttachmentExtensions();
|
||||
|
||||
for (const dangerous of [
|
||||
'.html',
|
||||
'.htm',
|
||||
'.svg',
|
||||
'.js',
|
||||
'.exe',
|
||||
'.sh',
|
||||
'.bat',
|
||||
]) {
|
||||
expect(extensions).not.toContain(dangerous);
|
||||
}
|
||||
expect(isAllowedAttachment('icon.svg', 'image/svg+xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('발송 형식은 확장자로 정한다 — 허용 목록 밖이면 null', () => {
|
||||
expect(resolveAttachmentType('체크리스트.XLSX')).toBe(
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
expect(resolveAttachmentType('사진.jpeg')).toBe('image/jpeg');
|
||||
expect(resolveAttachmentType('virus.exe')).toBeNull();
|
||||
expect(resolveAttachmentType('README')).toBeNull();
|
||||
});
|
||||
|
||||
it('MIME 을 소문자·매개변수 없이 맞춘다', () => {
|
||||
expect(normalizeMimeType(' Text/Plain; charset=UTF-8 ')).toBe('text/plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('경로를 버리고 파일명만 남긴다 (경로 탈출 방지)', () => {
|
||||
expect(sanitizeFileName('../../etc/passwd')).toBe('passwd');
|
||||
expect(sanitizeFileName('C:\\Users\\kim\\보고서.pdf')).toBe('보고서.pdf');
|
||||
});
|
||||
|
||||
it('제어 문자와 방향 제어 문자를 지운다 (확장자 뒤집기 위장 방지)', () => {
|
||||
expect(sanitizeFileName('a\r\nb.txt')).toBe('ab.txt');
|
||||
expect(sanitizeFileName('invoice\u202Etxt.exe')).toBe('invoicetxt.exe');
|
||||
});
|
||||
|
||||
it('너무 긴 이름은 확장자를 남기고 자른다', () => {
|
||||
const long = `${'가'.repeat(300)}.xlsx`;
|
||||
const result = sanitizeFileName(long);
|
||||
|
||||
expect(Array.from(result)).toHaveLength(255);
|
||||
expect(result.endsWith('.xlsx')).toBe(true);
|
||||
});
|
||||
|
||||
it('남는 것이 없으면 기본 이름을 쓴다', () => {
|
||||
expect(sanitizeFileName('')).toBe('첨부파일');
|
||||
expect(sanitizeFileName('dir/')).toBe('첨부파일');
|
||||
});
|
||||
});
|
||||
|
||||
describe('원문 파일 이름 (messageSourceFileName)', () => {
|
||||
it('제목 + .eml — 제목이 없거나 정리하고 비면 "메일"', () => {
|
||||
expect(messageSourceFileName('메일 서버 이전 일정 공유')).toBe(
|
||||
'메일 서버 이전 일정 공유.eml',
|
||||
);
|
||||
for (const subject of ['', ' ', '...', '\u0000\u202E']) {
|
||||
expect(messageSourceFileName(subject)).toBe('메일.eml');
|
||||
}
|
||||
});
|
||||
|
||||
it('경로 구분자와 Windows 에서 못 쓰는 문자는 _ 로, 제어 · 방향 제어 문자는 지우고, 공백(탭 · 줄바꿈)은 하나로', () => {
|
||||
expect(messageSourceFileName('Re: 견적서/수정본 <최종>?')).toBe(
|
||||
'Re_ 견적서_수정본 _최종__.eml',
|
||||
);
|
||||
expect(messageSourceFileName('..\\..\\secret|"x"*')).toBe(
|
||||
'_.._secret__x__.eml',
|
||||
);
|
||||
expect(messageSourceFileName('주간\t보고\r\n (10월)')).toBe(
|
||||
'주간 보고 (10월).eml',
|
||||
);
|
||||
expect(messageSourceFileName('계약서\u202Egpj.exe')).toBe(
|
||||
'계약서gpj.exe.eml',
|
||||
);
|
||||
});
|
||||
|
||||
it('앞뒤의 공백 · 점을 떼고 80글자(코드 포인트)까지 — 자른 끝의 공백 · 점도 뗀다', () => {
|
||||
expect(messageSourceFileName(' .숨김 파일. ')).toBe('숨김 파일.eml');
|
||||
expect(messageSourceFileName('가'.repeat(100))).toBe(
|
||||
`${'가'.repeat(80)}.eml`,
|
||||
);
|
||||
expect(messageSourceFileName('😀'.repeat(81))).toBe(
|
||||
`${'😀'.repeat(80)}.eml`,
|
||||
);
|
||||
expect(messageSourceFileName(`${'가'.repeat(79)} 끝`)).toBe(
|
||||
`${'가'.repeat(79)}.eml`,
|
||||
);
|
||||
});
|
||||
|
||||
it('아주 긴 제목도 입력 길이에 비례해 처리한다 (반복 정규식 없이)', () => {
|
||||
const long = `${' .'.repeat(200_000)}x${' .'.repeat(200_000)}`;
|
||||
expect(messageSourceFileName(long)).toBe('x.eml');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import { extname } from 'path';
|
||||
|
||||
import { MAX_FILE_NAME_LENGTH } from './mail.constants';
|
||||
|
||||
/**
|
||||
* 메일 첨부 형식·크기 정책 (순수 함수) — `modules/upload/upload.config.ts` 와 같은 원칙을 따른다.
|
||||
*
|
||||
* - **허용 목록**만 받는다. 확장자와 MIME 을 **둘 다** 검사한다 (.claude/rules/security.md "파일 업로드")
|
||||
* - 저장·발송에 쓰는 형식은 클라이언트가 보낸 MIME 이 아니라 **확장자로 정한 정식 형식**이다
|
||||
* - 브라우저가 실행할 수 있는 형식(.html·.svg·.js 등)은 넣지 않는다
|
||||
*/
|
||||
|
||||
const OCTET_STREAM = 'application/octet-stream';
|
||||
|
||||
/**
|
||||
* 메일 한 통에 붙일 수 있는 첨부 개수 — 넘으면 400 MAIL_006 (계약 2절. 화면은 첨부를 추가할 때 미리 막는다).
|
||||
* 업로드한 파일과 전달하는 원본 첨부를 함께 센다. **개수 한도는 이 상수 하나**다 —
|
||||
* 발송 검사(mail.service.ts)·API 문서가 이 값을 쓰고, 레지스트리의 MAIL_006 문구는 테스트가 이 값과 맞춰 본다.
|
||||
*/
|
||||
export const MAX_ATTACHMENTS_PER_MESSAGE = 20;
|
||||
|
||||
/**
|
||||
* 발송 본문 검증(DTO)이 받는 첨부 목록 길이 상한 — 한도가 아니라 **남용 방지용**이다 (넘으면 400 VAL_001).
|
||||
*
|
||||
* 100 으로 둔 근거:
|
||||
* - 한도(20)보다 넉넉해야 한다. 파일을 한꺼번에 많이 고른 경우(21~100개)는 검증을 통과시켜 MAIL_006 으로 답한다 —
|
||||
* VAL_001 문구("입력하신 정보를 다시 확인해 주세요")로는 무엇을 고칠지 알 수 없다.
|
||||
* - 끝은 있어야 한다. 화면으로는 만들 수 없는 길이는 형식 남용으로 보고 서비스·메일 엔진까지 보내지 않는다.
|
||||
* (본문 해석·검증 비용 자체는 발송 JSON 본문 한도 4MB 가 묶는다 — app.setup.ts 의 SEND_MESSAGE_JSON_BODY_LIMIT)
|
||||
* - 같은 DTO 의 다른 목록(받는 사람·참조·숨은 참조) 상한도 100 이다 (mail.constants.ts 의 MAX_RECIPIENTS).
|
||||
*/
|
||||
export const MAX_ATTACHMENT_LIST_LENGTH = 100;
|
||||
|
||||
interface AttachmentTypeRule {
|
||||
/** 서버가 저장·발송에 쓰는 정식 형식 */
|
||||
readonly type: string;
|
||||
/** 업로드 때 받아들이는 MIME */
|
||||
readonly accept: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장자 → 형식.
|
||||
*
|
||||
* 문서·압축 형식(브라우저가 직접 그리지 않는 형식)은 `application/octet-stream` 도 받는다.
|
||||
* 브라우저는 OS 에 등록되지 않은 형식(한컴오피스가 없는 PC 의 .hwp 등)을 octet-stream 으로 보내기 때문이다.
|
||||
* 이미지·PDF·텍스트처럼 브라우저가 그릴 수 있는 형식은 정확한 MIME 만 받는다.
|
||||
* 어느 쪽이든 내려줄 때는 항상 octet-stream + attachment 이므로 내용이 실행되지 않는다.
|
||||
*/
|
||||
const ATTACHMENT_TYPES: ReadonlyMap<string, AttachmentTypeRule> = new Map([
|
||||
['.pdf', { type: 'application/pdf', accept: ['application/pdf'] }],
|
||||
[
|
||||
'.doc',
|
||||
{
|
||||
type: 'application/msword',
|
||||
accept: ['application/msword', OCTET_STREAM],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.docx',
|
||||
{
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
accept: [
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.xls',
|
||||
{
|
||||
type: 'application/vnd.ms-excel',
|
||||
accept: ['application/vnd.ms-excel', OCTET_STREAM],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.xlsx',
|
||||
{
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
accept: [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.ppt',
|
||||
{
|
||||
type: 'application/vnd.ms-powerpoint',
|
||||
accept: ['application/vnd.ms-powerpoint', OCTET_STREAM],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.pptx',
|
||||
{
|
||||
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
accept: [
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.hwp',
|
||||
{
|
||||
type: 'application/x-hwp',
|
||||
accept: [
|
||||
'application/x-hwp',
|
||||
'application/haansofthwp',
|
||||
'application/vnd.hancom.hwp',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'.hwpx',
|
||||
{
|
||||
type: 'application/hwp+zip',
|
||||
accept: [
|
||||
'application/hwp+zip',
|
||||
'application/haansofthwpx',
|
||||
'application/vnd.hancom.hwpx',
|
||||
'application/x-hwpx',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
['.txt', { type: 'text/plain', accept: ['text/plain'] }],
|
||||
[
|
||||
'.csv',
|
||||
{
|
||||
type: 'text/csv',
|
||||
// 엑셀이 설치된 Windows 는 .csv 를 application/vnd.ms-excel 로 보낸다
|
||||
accept: [
|
||||
'text/csv',
|
||||
'application/csv',
|
||||
'text/x-csv',
|
||||
'application/vnd.ms-excel',
|
||||
'text/plain',
|
||||
],
|
||||
},
|
||||
],
|
||||
['.png', { type: 'image/png', accept: ['image/png'] }],
|
||||
['.jpg', { type: 'image/jpeg', accept: ['image/jpeg', 'image/pjpeg'] }],
|
||||
['.jpeg', { type: 'image/jpeg', accept: ['image/jpeg', 'image/pjpeg'] }],
|
||||
['.gif', { type: 'image/gif', accept: ['image/gif'] }],
|
||||
['.webp', { type: 'image/webp', accept: ['image/webp'] }],
|
||||
[
|
||||
'.zip',
|
||||
{
|
||||
type: 'application/zip',
|
||||
// Windows 의 Chrome 은 application/x-zip-compressed 로 보낸다
|
||||
accept: [
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-zip',
|
||||
'multipart/x-zip',
|
||||
OCTET_STREAM,
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
/** 정리 후 이름이 비면 쓰는 이름 */
|
||||
const FALLBACK_FILE_NAME = '첨부파일';
|
||||
|
||||
/** 'Text/Plain; charset=UTF-8' → 'text/plain' */
|
||||
export function normalizeMimeType(mimeType: string): string {
|
||||
return mimeType.split(';')[0].trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extensionOf(fileName: string): string {
|
||||
return extname(fileName).toLowerCase();
|
||||
}
|
||||
|
||||
/** 업로드 허용 여부 — 확장자가 허용 목록에 있고, MIME 이 그 확장자에 맞아야 통과한다 */
|
||||
export function isAllowedAttachment(
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
): boolean {
|
||||
const rule = ATTACHMENT_TYPES.get(extensionOf(fileName));
|
||||
return (
|
||||
rule !== undefined && rule.accept.includes(normalizeMimeType(mimeType))
|
||||
);
|
||||
}
|
||||
|
||||
/** 파일명 확장자로 정식 형식을 정한다. 허용 목록에 없으면 null */
|
||||
export function resolveAttachmentType(fileName: string): string | null {
|
||||
return ATTACHMENT_TYPES.get(extensionOf(fileName))?.type ?? null;
|
||||
}
|
||||
|
||||
/** 안내·문서용 허용 확장자 목록 */
|
||||
export function allowedAttachmentExtensions(): string[] {
|
||||
return [...ATTACHMENT_TYPES.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 한 통의 첨부 합계 한도(바이트) — 설정 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB)와 메일 엔진 한도 중 작은 값.
|
||||
*
|
||||
* **이 함수 하나만** 한도를 정한다. 발송 검사(어댑터의 FILE_002)와 `GET /api/mail-quota` 의
|
||||
* `attachmentTotalMaxBytes` 가 같은 값을 써야 화면이 미리 막는 값과 서버가 거절하는 값이 어긋나지 않는다.
|
||||
* 엔진이 한도를 알려 주지 않으면(null·0 이하·정수가 아님) 설정 한도만 쓴다.
|
||||
*/
|
||||
export function resolveAttachmentsTotalLimit(
|
||||
configuredBytes: number,
|
||||
engineBytes: number | null | undefined,
|
||||
): number {
|
||||
const engineKnown =
|
||||
typeof engineBytes === 'number' &&
|
||||
Number.isSafeInteger(engineBytes) &&
|
||||
engineBytes > 0;
|
||||
return engineKnown ? Math.min(configuredBytes, engineBytes) : configuredBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 표시·헤더용으로 파일명을 정리한다.
|
||||
* - 경로 구분자 앞부분을 버린다 (클라이언트가 보낸 경로를 쓰지 않는다)
|
||||
* - 제어 문자와 방향 제어 문자(확장자를 거꾸로 보이게 하는 U+202E 등)를 지운다
|
||||
* - 길이를 제한하되 확장자는 남긴다
|
||||
*/
|
||||
export function sanitizeFileName(rawName: string): string {
|
||||
const lastSegment = rawName.split(/[\\/]/).pop() ?? '';
|
||||
const cleaned = Array.from(lastSegment)
|
||||
.filter((char) => !isControlOrBidiChar(char.codePointAt(0) ?? 0))
|
||||
.join('')
|
||||
.trim();
|
||||
|
||||
const chars = Array.from(cleaned);
|
||||
if (chars.length === 0) return FALLBACK_FILE_NAME;
|
||||
if (chars.length <= MAX_FILE_NAME_LENGTH) return cleaned;
|
||||
|
||||
const extensionChars = Array.from(extname(cleaned));
|
||||
if (extensionChars.length >= MAX_FILE_NAME_LENGTH) {
|
||||
return chars.slice(0, MAX_FILE_NAME_LENGTH).join('');
|
||||
}
|
||||
return (
|
||||
chars.slice(0, MAX_FILE_NAME_LENGTH - extensionChars.length).join('') +
|
||||
extensionChars.join('')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문 파일 이름의 제목 부분 최대 글자 수(코드 포인트). 한글(UTF-8 3바이트)뿐이면 `.eml` 까지 240+4 바이트라
|
||||
* 파일 시스템의 이름 한도(255바이트) 안에 든다. 4바이트 문자(이모지 등)가 많으면 넘을 수 있는데,
|
||||
* 그때는 브라우저가 저장하면서 이름을 줄인다 (서버는 더 자르지 않는다)
|
||||
*/
|
||||
const SOURCE_NAME_MAX_CHARS = 80;
|
||||
/** 제목이 없거나 정리하고 비면 쓰는 이름 */
|
||||
const SOURCE_FALLBACK_NAME = '메일';
|
||||
/** 원문 파일의 확장자 */
|
||||
const SOURCE_EXTENSION = '.eml';
|
||||
/** 파일 이름에 쓸 수 없는 문자(경로 구분자와 Windows 예약 문자) — '_' 로 바꾼다 */
|
||||
const FILE_NAME_RESERVED_CHARS: ReadonlySet<string> = new Set([
|
||||
'\\',
|
||||
'/',
|
||||
':',
|
||||
'*',
|
||||
'?',
|
||||
'"',
|
||||
'<',
|
||||
'>',
|
||||
'|',
|
||||
]);
|
||||
/** 이름 앞뒤에서 떼는 문자 — 공백과 점(앞의 점은 숨김 파일, Windows 는 끝의 점을 지운다) */
|
||||
const NAME_EDGE_CHARS: ReadonlySet<string> = new Set([' ', '.']);
|
||||
const WHITESPACE = /\s/u;
|
||||
|
||||
/** 앞뒤의 공백·점을 뗀다 — 반복 정규식 없이 한 번 훑는다 (긴 제목에서도 입력 길이에 비례) */
|
||||
function trimNameEdges(chars: readonly string[]): string[] {
|
||||
let start = 0;
|
||||
let end = chars.length;
|
||||
while (start < end && NAME_EDGE_CHARS.has(chars[start])) start += 1;
|
||||
while (end > start && NAME_EDGE_CHARS.has(chars[end - 1])) end -= 1;
|
||||
return chars.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문 내려받기 파일 이름 — 제목(없으면 "메일") + `.eml` (계약 2절 "스팸 · 원문").
|
||||
* 공백(탭 · 줄바꿈 포함)은 하나로 줄이고, 제어 · 방향 제어 문자는 지우고, 경로 구분자와 Windows 에서 못 쓰는 문자는 '_' 로 바꾼다.
|
||||
* 앞뒤의 공백 · 점을 떼고 SOURCE_NAME_MAX_CHARS 글자까지 자른다. 헤더 인코딩은 content-disposition.ts 가 한다
|
||||
*/
|
||||
export function messageSourceFileName(subject: string): string {
|
||||
const chars: string[] = [];
|
||||
for (const char of subject) {
|
||||
if (WHITESPACE.test(char)) {
|
||||
if (chars.at(-1) !== ' ') chars.push(' ');
|
||||
continue;
|
||||
}
|
||||
if (isControlOrBidiChar(char.codePointAt(0) ?? 0)) continue;
|
||||
chars.push(FILE_NAME_RESERVED_CHARS.has(char) ? '_' : char);
|
||||
}
|
||||
const base = trimNameEdges(
|
||||
trimNameEdges(chars).slice(0, SOURCE_NAME_MAX_CHARS),
|
||||
).join('');
|
||||
return `${base || SOURCE_FALLBACK_NAME}${SOURCE_EXTENSION}`;
|
||||
}
|
||||
|
||||
/** C0·C1 제어 문자, DEL, 방향 제어 문자 */
|
||||
function isControlOrBidiChar(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint <= 0x1f ||
|
||||
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
||||
codePoint === 0x200e ||
|
||||
codePoint === 0x200f ||
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
||||
(codePoint >= 0x2066 && codePoint <= 0x2069)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { MAX_ATTACHMENTS_PER_MESSAGE } from './attachment.policy';
|
||||
import { DraftDto, DraftSavedDto } from './dto/mail-response.dto';
|
||||
import { DraftBodyDto } from './dto/send-message.dto';
|
||||
import { MailIdPipe } from './mail-id.pipe';
|
||||
import { DRAFTS_ROUTE } from './mail.constants';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
/** 저장 요청의 400 실패 — 발송 본문과 같은 한도 */
|
||||
const SAVE_BAD_REQUEST =
|
||||
`MAIL_002 받는 사람 합계 100명 초과 · MAIL_003 메일함 용량 · MAIL_004 첨부를 찾을 수 없음 · MAIL_006 첨부 ${MAX_ATTACHMENTS_PER_MESSAGE}개 초과 · ` +
|
||||
'VAL_001 형식(제목 255자 초과·draftId 같은 모르는 필드 포함) · FILE_001 첨부 형식 (모두 400)';
|
||||
|
||||
// 임시 저장 (계약 2절 "임시 저장") — 목록·버리기·되돌리기는 메일 API(GET·DELETE·PATCH /api/messages) 그대로다.
|
||||
// 저장 본문은 발송 본문과 같은 필드·한도이고 JSON 본문 4MB 까지 받는다 (app.setup.ts — 앞단 nginx 도 같은 값이어야 한다).
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller(DRAFTS_ROUTE)
|
||||
export class DraftController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: '이어 쓸 임시 저장본',
|
||||
description:
|
||||
'서식 본문은 보내는 정화(서식 본문 허용 목록 + 내 서명 그림)를 거친 값이다 — 다른 메일 프로그램이 만든 초안도 같다. ' +
|
||||
'임시보관함의 초안이 아니면(발송 중에 생긴 내부 초안 포함) 404.',
|
||||
})
|
||||
@ApiStandardResponse(DraftDto, { description: '임시 저장본' })
|
||||
@ApiErrorResponse('RES_001', '없는 메일 · 임시보관함의 초안이 아님')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
getDraft(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
) {
|
||||
return this.mail.getDraft(user, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: '임시 저장 — 처음 저장',
|
||||
description:
|
||||
'발송 본문과 같은 필드·한도(받는 사람 0명도 된다, draftId 없음). 발송 중복 방지용 Message-ID·지문 헤더는 넣지 않는다. ' +
|
||||
'본문이 너무 길면 400 MAIL_007.',
|
||||
})
|
||||
@ApiStandardResponse(DraftSavedDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '저장한 임시 저장본 — 다음 저장은 PUT /api/drafts/{id}',
|
||||
})
|
||||
@ApiErrorResponse('MAIL_002', SAVE_BAD_REQUEST)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_007',
|
||||
'MAIL_007 본문이 너무 김 (400) — 발송과 같은 한도 (텍스트 100,000자·서식 500,000자·정화 전 비용·정화 뒤 2,000,000바이트)',
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과 · JSON 본문 4MB 초과')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
createDraft(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: DraftBodyDto,
|
||||
) {
|
||||
return this.mail.createDraft(user, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({
|
||||
summary: '임시 저장 — 바꿔 저장 (전체 교체, 새 id)',
|
||||
description:
|
||||
'메일은 고칠 수 없어 새 초안을 만든 뒤 옛것을 지운다 — 응답의 id·attachments 로 바꿔 쓴다. ' +
|
||||
'{id} 가 임시보관함의 초안이 아니면 아무것도 지우지 않고 404 RES_001, ' +
|
||||
'옛 초안이 이미 없으면(다른 창·기기에서 먼저 고쳤거나 지웠음) 저장하지 않고 409 MAIL_008 — 클라이언트는 POST 로 새로 남긴다.',
|
||||
})
|
||||
@ApiStandardResponse(DraftSavedDto, {
|
||||
description: '새 임시 저장본 (새 id)',
|
||||
})
|
||||
@ApiErrorResponse('MAIL_002', SAVE_BAD_REQUEST)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_007',
|
||||
'MAIL_007 본문이 너무 김 (400) — 발송과 같은 한도',
|
||||
)
|
||||
@ApiErrorResponse('RES_001', '임시보관함의 초안이 아님 (지우지 않았다)')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_008',
|
||||
'다른 곳에서 먼저 바뀌었거나 지워짐 — 저장하지 않았다 (POST 로 새로 남긴다)',
|
||||
)
|
||||
@ApiErrorResponse('FILE_002', '첨부 합계 크기 초과 · JSON 본문 4MB 초과')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
replaceDraft(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Body() dto: DraftBodyDto,
|
||||
) {
|
||||
return this.mail.replaceDraft(user, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { MAX_BULK_MESSAGE_IDS } from '@/modules/mail/mail.constants';
|
||||
import { MessageDeletionDto, UpdateMessagesDto } from './bulk-messages.dto';
|
||||
|
||||
/** 전역 ValidationPipe 와 같은 옵션으로 검증한다 (app.setup.ts — stopAtFirstError 만 빼고 — 걸린 규칙을 모두 본다, 통과 · 실패는 같다) */
|
||||
async function constraintsOf(
|
||||
type: typeof UpdateMessagesDto | typeof MessageDeletionDto,
|
||||
body: object,
|
||||
): Promise<string[]> {
|
||||
const errors = await validate(plainToInstance(type, body), {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
return errors.flatMap((error) => Object.keys(error.constraints ?? {}));
|
||||
}
|
||||
|
||||
const ids = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => `m${index}`);
|
||||
|
||||
describe('여러 통 요청 본문 (PATCH /api/messages · POST /api/message-deletions)', () => {
|
||||
it.each([
|
||||
['PATCH', UpdateMessagesDto, { isRead: true }],
|
||||
['영구 삭제', MessageDeletionDto, {}],
|
||||
] as const)(
|
||||
`%s — ids 는 1~${MAX_BULK_MESSAGE_IDS}개, 같은 id 두 번 · 형식이 틀린 id 는 형식 오류`,
|
||||
async (_name, type, changes) => {
|
||||
expect(await constraintsOf(type, { ids: ['m1'], ...changes })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
await constraintsOf(type, {
|
||||
ids: ids(MAX_BULK_MESSAGE_IDS),
|
||||
...changes,
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
expect(await constraintsOf(type, { ids: [], ...changes })).toEqual([
|
||||
'arrayMinSize',
|
||||
]);
|
||||
expect(
|
||||
await constraintsOf(type, {
|
||||
ids: ids(MAX_BULK_MESSAGE_IDS + 1),
|
||||
...changes,
|
||||
}),
|
||||
).toEqual(['arrayMaxSize']);
|
||||
expect(
|
||||
await constraintsOf(type, { ids: ['m1', 'm1'], ...changes }),
|
||||
).toEqual(['arrayDistinct']);
|
||||
for (const bad of ['../m1', '', 'm 1', 1, null]) {
|
||||
expect(
|
||||
await constraintsOf(type, { ids: ['m1', bad], ...changes }),
|
||||
).toEqual(['matches']);
|
||||
}
|
||||
expect(await constraintsOf(type, { ...changes })).toContain('isArray');
|
||||
expect(await constraintsOf(type, { ids: 'm1', ...changes })).toContain(
|
||||
'isArray',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('PATCH 의 바꿀 것은 한 통 PATCH 와 같은 규칙이다 — null 은 형식 오류, 모르는 필드는 거부', async () => {
|
||||
expect(
|
||||
await constraintsOf(UpdateMessagesDto, {
|
||||
ids: ['m1'],
|
||||
isRead: false,
|
||||
isFlagged: true,
|
||||
mailboxId: 'b',
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await constraintsOf(UpdateMessagesDto, { ids: ['m1'], isRead: null }),
|
||||
).toEqual(['isBoolean']);
|
||||
expect(
|
||||
await constraintsOf(UpdateMessagesDto, {
|
||||
ids: ['m1'],
|
||||
mailboxId: '../b',
|
||||
}),
|
||||
).toEqual(['matches']);
|
||||
expect(
|
||||
await constraintsOf(MessageDeletionDto, { ids: ['m1'], isRead: true }),
|
||||
).toEqual(['whitelistValidation']);
|
||||
});
|
||||
|
||||
it('스팸 표시(isJunk)도 한 통 PATCH 와 같다 — true · false, null · 문자열은 형식 오류', async () => {
|
||||
for (const isJunk of [true, false]) {
|
||||
expect(
|
||||
await constraintsOf(UpdateMessagesDto, {
|
||||
ids: ['m1'],
|
||||
mailboxId: 'c',
|
||||
isJunk,
|
||||
}),
|
||||
).toEqual([]);
|
||||
}
|
||||
for (const isJunk of [null, 'true']) {
|
||||
expect(
|
||||
await constraintsOf(UpdateMessagesDto, { ids: ['m1'], isJunk }),
|
||||
).toEqual(['isBoolean']);
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user