generated from ttipo/project-base
Compare commits
213 Commits
32576b0ad1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b8a5114af9 | |||
| 198ac8f4f2 | |||
| b644e8b104 | |||
| bd916ccbed | |||
| 94ec659b78 | |||
| 487760bd9b | |||
| 37ffcfca2c | |||
| f4a85fda27 | |||
| 0ff1ec46af | |||
| f3586abcd7 | |||
| 6eba013416 | |||
| f0e9a50b01 | |||
| 5ce614cc0c | |||
| def96e833b | |||
| 1d01130c9d | |||
| 1b3f9ba7fe | |||
| cd958f3bf4 | |||
| abe4c1364c | |||
| 1962f07d79 | |||
| d6b804ba83 | |||
| 19dbb2babb | |||
| 907287021f | |||
| 0c3238936a | |||
| fceb6ed3e4 | |||
| 060265c8b0 | |||
| 071fe9a60f | |||
| 3c9b3727c6 | |||
| 7f39aa4eb2 | |||
| d02a983d57 | |||
| 1ca210dec4 | |||
| 099faeaafc | |||
| ff1948b7df | |||
| 7c70862647 | |||
| e9acb7809c | |||
| 433ef12cc3 | |||
| f2ba81513e | |||
| 1f8555340e | |||
| 178a53fd6f | |||
| 117ec6c767 | |||
| 644c6e9424 | |||
| c1d0808764 | |||
| 9bfa53c03a | |||
| 1448835aff | |||
| 41f220bf61 | |||
| dacc100d3e | |||
| f433f941e6 | |||
| 57340d43df | |||
| 5670017d2a | |||
| 3a25cc52f7 | |||
| d4ab06183c | |||
| 3f66f39f9d | |||
| b77f95fdff | |||
| c0edff10d1 | |||
| 6ecc38d927 | |||
| 08b8144087 | |||
| e03eda1fd3 | |||
| bf725ae96d | |||
| 5b96ec2e85 | |||
| f0ef782b24 | |||
| 9c5db05ada | |||
| facf9cd936 | |||
| bec78bce42 | |||
| 3278e1abc1 | |||
| e0036000e9 | |||
| 1bd4b3d626 | |||
| c9e6858b2a | |||
| 76cbdcf74c | |||
| ca6c68c34f | |||
| 23afa583c1 | |||
| 4ca2d41bad | |||
| 97a989be07 | |||
| 37f078e65c | |||
| b9336b6db5 | |||
| 438dac8e01 | |||
| 88ec6433b9 | |||
| 25280d8e90 | |||
| 94da1d2031 | |||
| 7546af0243 | |||
| 3a126bdf83 | |||
| cc4f550044 | |||
| 749997cb18 | |||
| f39f41ed4b | |||
| 80e48d318d | |||
| 695a343878 | |||
| 20cde01454 | |||
| 08aafdef5c | |||
| 9ba4bc1a41 | |||
| 0090a161ec | |||
| f4b1d0a206 | |||
| a37045a553 | |||
| 5cece8ebf6 | |||
| 726f9f854f | |||
| ef23afc94c | |||
| ad8b126abb | |||
| 67f63f05fc | |||
| 39623ae3a8 | |||
| a111cd2fd4 | |||
| 5d3a018708 | |||
| 6ee6c71ace | |||
| 4e779dedd2 | |||
| c14580e3a4 | |||
| dd02b9621a | |||
| 2bb7fa4cdb | |||
| dc0fb81387 | |||
| 35fcf5e4ef | |||
| 5823f600aa | |||
| bb560d39f9 | |||
| fe355f3943 | |||
| 1a4f1d3b56 | |||
| 8f02814a4f | |||
| 37d689e113 | |||
| ab16379836 | |||
| 59375f1347 | |||
| 425cbc0e29 | |||
| 53a8d7176d | |||
| f9fbf8a20f | |||
| d57e6bfe6c | |||
| 0fc5735959 | |||
| 9887035bcd | |||
| f49dc2a186 | |||
| c476a4ecf4 | |||
| ba80956d7e | |||
| 1171593724 | |||
| 0021c0a1fc | |||
| cdebcb85f9 | |||
| cda4093ffd | |||
| ddfb2d14ab | |||
| 7d0d5f34c0 | |||
| 9c8728138e | |||
| 8abcaed98e | |||
| bdd42a3cb2 | |||
| bc22f5f3ba | |||
| 349c312989 | |||
| 2cd2764da8 | |||
| 6dc2777834 | |||
| 3b59497d59 | |||
| 5829814dce | |||
| 3f8ba0081a | |||
| 9b530da6e1 | |||
| feabe0d8e1 | |||
| 0388cf609a | |||
| 07b7fcca0f | |||
| b43b747107 | |||
| 34d63cf740 | |||
| e664217485 | |||
| 9c232b575d | |||
| 24179edaaa | |||
| c8287c4819 | |||
| 6106351351 | |||
| a7b9d00931 | |||
| 6c8c54d117 | |||
| ad6cea79fc | |||
| 1881ec7a42 | |||
| 452d9fef1d | |||
| 73babeaa2c | |||
| f0e7890bf1 | |||
| 781ffb2e16 | |||
| 9a72ce91e1 | |||
| d1ef0da488 | |||
| c31442e639 | |||
| f9e3b1c57a | |||
| adb992e6d8 | |||
| a8ea204e47 | |||
| 8f0a6da83c | |||
| 3461c361f8 | |||
| 93154ad9a5 | |||
| 93147b88b7 | |||
| c5cc1a6705 | |||
| c3dba24be0 | |||
| 15141e7723 | |||
| 9466a85daa | |||
| 4047bd30e7 | |||
| 3029bb20f2 | |||
| 867543c49f | |||
| 258ebf2180 | |||
| 4c1737f222 | |||
| 30b3d28996 | |||
| b99ccfbf6c | |||
| b0412ac453 | |||
| fa6799d9f7 | |||
| bd2f55ad5a | |||
| e2c41d38bb | |||
| 08cf31fbe6 | |||
| 59420df1b9 | |||
| 7615329c32 | |||
| 7bf95c1335 | |||
| aef67aa310 | |||
| 6fd0911b86 | |||
| ab4124ba94 | |||
| 5ddc849c11 | |||
| 188b9e84f4 | |||
| be473078cc | |||
| 52c6ebecb6 | |||
| ec1919e4bf | |||
| a39f8bd630 | |||
| 894550732e | |||
| 7ce234750d | |||
| b4d30482fe | |||
| 41b5fdbe22 | |||
| b6aa73c611 | |||
| 4d95fc28d8 | |||
| 3634cee955 | |||
| 19c20eb617 | |||
| ac418e3aa9 | |||
| 181d2a4cbf | |||
| 458f12980d | |||
| 50165bd3b7 | |||
| f725908053 | |||
| e27e9feb0b | |||
| 2d9c6b87d6 | |||
| af1e581be1 | |||
| 05fc3909dd | |||
| cbfccaa499 |
@@ -26,7 +26,7 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 만들기 전에 `/nestjs` 의 "템플릿 기본 제공 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다.
|
||||
1. 만들기 전에 `/nestjs` 의 "기본 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다.
|
||||
2. 실패 경로마다 에러 코드를 정한다. 코드 없는 실패는 만들지 않는다 ([[error-codes]]).
|
||||
3. 목록 API 면 페이지네이션을 적용한다 ([[response-format]]).
|
||||
4. Swagger 데코레이터를 함께 쓴다 ([[api-design]]).
|
||||
|
||||
@@ -23,15 +23,16 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. `lib/features/user/` 의 model → repository → provider 수직 슬라이스를 복제한다.
|
||||
2. 네트워크는 `core/api/dio_client.dart` 의 인터셉터 패턴을 재사용한다. 새 클라이언트를 만들지 않는다.
|
||||
3. 에러 분기는 `e.error as ApiException` 의 **`code`** 로 한다. 문구를 새로 만들지 않는다.
|
||||
4. 하드코딩 픽셀 대신 `MediaQuery`/`Expanded`/`Flexible` 을 쓴다.
|
||||
1. 새 기능은 그 모듈 폴더(`lib/features/<모듈>/`)와 그 모듈의 라우트 묶음(`<모듈>_routes.dart`)에 둔다. 새 모듈은 스킬의 "새 모듈 붙이는 법"대로. 모듈끼리 import 하지 않는다.
|
||||
2. `lib/features/auth/` 의 model → repository → provider 수직 슬라이스를 복제한다(목록 페이지네이션은 `features/user` 예제).
|
||||
3. 네트워크는 `dioProvider` 의 Dio 를 쓴다(인터셉터 차례 — 스킬 "API 통신"). 새 클라이언트 · 인터셉터 줄을 만들지 않는다.
|
||||
4. 에러 분기는 `apiErrorCodeOf(e)` 의 **`code`** 로 한다. 문구를 새로 만들지 않는다.
|
||||
5. 화면은 `docs/design/app/README.md` 와 캔버스 확정본대로 만든다 — 그 전에는 자리 화면(`PlaceholderPage`). 하드코딩 픽셀 대신 `MediaQuery`/`Expanded`/`Flexible` · 테마 상수를 쓴다.
|
||||
|
||||
## 완료 기준 (실행하고 결과를 보고한다)
|
||||
|
||||
```bash
|
||||
cd flutter && flutter analyze && flutter test
|
||||
cd flutter && flutter analyze && flutter test && flutter build apk --debug
|
||||
```
|
||||
|
||||
- `flutter/.env` 가 없으면 analyze 가 asset 경고를 낸다 — `.env.example` 복사 여부를 먼저 확인한다.
|
||||
|
||||
@@ -16,6 +16,8 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill
|
||||
- 루트 `.env.{env}` 구조와 `*.example` ([[env-structure]])
|
||||
- 마이그레이션 **적용**(`migration:run`)과 배포 전 점검
|
||||
- `.github/workflows/` 와 `ci/` 러너
|
||||
- `mail/`(setup.mjs · resolver · clamav 설정 · mail/README) — 메일 엔진 설정은 setup 으로만 바꾼다
|
||||
- `ops/`(호스트 systemd 의 백업 · 복원 · 점검 스크립트와 유닛 — 13단계)
|
||||
|
||||
## 하지 않는 것
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
# 작업 점검 & 신규 프로젝트 셋업
|
||||
# 작업 점검 & 새 환경 셋업
|
||||
|
||||
> 이 저장소는 **재사용 템플릿**이다. 클론해 어떤 프로젝트든 시작할 수 있도록, 여기에는 **언제나 유효한 공용 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다).
|
||||
> 각 스택이 기본 제공하는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
|
||||
> 여기에는 **언제나 유효한 점검 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다 — 지금 상태는 각 스킬 · API 계약 · [로드맵](../../docs/roadmap.md)).
|
||||
> 각 스택에 이미 있는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 신규 프로젝트 셋업 (클론 직후 1회)
|
||||
## 1. 새 개발 환경 · 새 서버 셋업 (처음 한 번)
|
||||
|
||||
- [ ] 루트 `.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` 은
|
||||
운영은 `TRUST_PROXY`(앞단 프록시 홉 수 — 외부 리버스 프록시 뒤면 2, 없으면 1. 프록시를 다른 프로젝트와 함께 쓰거나 다른 서버에 두면
|
||||
주소 목록 `<EDGE_NETWORK_SUBNET>,<프록시 IP>` — [[docker]] "외부 리버스 프록시 요구사항")도 필수다. 앱 네트워크 대역 `APP_NETWORK_SUBNET` 은
|
||||
호스트의 다른 도커·사내 네트워크와 겹치지 않는 값으로 둔다 (메일 엔진이 이 대역만 신뢰한다 — [[docker]])
|
||||
대용량 첨부를 쓰면 운영은 `SHARE_PUBLIC_URL`(공개 내려받기 호스트 — 포털과 다른 호스트, `CORS_ORIGIN` 에 넣지 않는다)과 `CLAMAV_HOST=clamav` 도 필수다.
|
||||
그 호스트의 DNS · TLS · 외부 프록시 Proxy Host(같은 frontend:8080, 접근 로그 끔)를 함께 만든다 ([[docker]] "외부 리버스 프록시 요구사항")
|
||||
12단계부터 운영은 `MAIL_WEBHOOK_SECRET`(엔진 웹훅 서명 키 — 영문 · 숫자 32자 이상, `openssl rand -hex 24`)도 필수다 — setup 과 backend 가 같은 값을 읽는다
|
||||
13단계부터 운영은 `DB_IMAGE` 를 예시의 고정값(판 · digest)으로 두고, 첫 setup 은 복구 관리자와 함께(`--recovery` — 관리자 허용 IP 를 처음 걸 때 되돌릴 길) 돌린다.
|
||||
이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버" 차례를 따른다
|
||||
- [ ] 운영 백업 · 감시(13단계 — 호스트 systemd, mail/README "백업 › 설치" · "감시"): Object Storage 비공개 버킷 + Object Storage 만 쓰는 하위 계정 키(버킷에는 따로 키가 없고 NCP 는 키를 버킷 하나로 좁히지 못한다 — 같은 계정에 다른 버킷을 두지 않는다), restic 비밀번호(**서버 밖에도 보관** — 잃으면 백업을 풀 수 없다),
|
||||
`/etc/arasystem/ops.env`, 타이머 셋(백업 03:30 · 점검 15분 · 부팅 되살리기) 켜기, 첫 백업 뒤 복원 연습(`drill`) 한 번. Healthchecks 체크 둘 · UptimeRobot 감시 셋 — 알림은 우리 메일 서버가 아닌 주소로
|
||||
- [ ] `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 경고를 낸다**
|
||||
@@ -77,10 +85,10 @@
|
||||
|
||||
## 6. 기능 확장 시 따르는 순서
|
||||
|
||||
새 기능은 만들기 전에, 해당 스킬의 **"템플릿 기본 제공 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
|
||||
새 기능은 만들기 전에, 해당 스킬의 **"기본 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
|
||||
|
||||
1. 해당 디렉터리 스킬 규칙 확인 ([[nestjs]] / [[vue3]] / [[flutter]])
|
||||
2. 기본 제공 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용
|
||||
2. 기본 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용
|
||||
3. 표준 응답/에러 흐름 준수 ([[response-format]] · [[error-codes]] · [[api-design]])
|
||||
4. 실패 경로마다 에러 코드 지정 — `BusinessException('CODE')` 로 던진다
|
||||
5. env 신규 값은 루트 `.env` 부터 ([[env-structure]])
|
||||
|
||||
@@ -14,10 +14,11 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙
|
||||
Dart `const` 누락·await 누락, backend 에서 `HttpException` 직접 생성(→ `BusinessException` 사용)
|
||||
- 유틸 함수는 순수 함수로 분리 — TS는 `shared/utils/`, Dart는 `core/utils/`
|
||||
- 매직 넘버·하드코딩 문자열 지양 → 상수 또는 환경변수로 분리
|
||||
(반복 사용되는 키 문자열도 상수로: 예 `kJwtTokenKey`, `REQUEST_ID_HEADER`)
|
||||
(반복 사용되는 키 문자열도 상수로: 예 `kRefreshTokenKey`, `REQUEST_ID_HEADER`)
|
||||
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
|
||||
— backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`)
|
||||
- **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]])
|
||||
- 소스에 보이지 않는 글자(nbsp · U+2028 · U+FEFF 등)는 `\uXXXX` 이스케이프로 적되, **저장한 뒤 바이트로 확인한다** — 편집 도구가 U+00A0 이상의 이스케이프를 실제 글자로 풀어 저장한 일이 있다(2026-11-10 — 정규식 안의 U+2028 이 줄 끝이 되어 컴파일 실패, 16단계에서는 U+FEFF). 풀리면 `String.fromCharCode(0x2028)` 처럼 글자 코드로 만든다
|
||||
|
||||
## 네이밍 규약
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
|
||||
# 환경변수 구조 (단일 진실 공급원: 루트 `.env`)
|
||||
|
||||
이 템플릿은 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
|
||||
이 저장소는 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
|
||||
값을 한 곳에서만 관리하기 위해, 서비스(backend/frontend)는 자체 env에 값을 중복 작성하지 않고 **루트 → docker-compose 가 주입한 값을 그대로 사용**한다.
|
||||
|
||||
```
|
||||
@@ -16,24 +16,45 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
├─▶ 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 — 값이 틀리면 기동 중단)
|
||||
│ FRONTEND_PRIVATE_BIND(프록시가 다른 서버일 때만 — docker-compose.remote-proxy.yml, 사설 IPv4:포트) → 8080 게시
|
||||
│ SHARE_PUBLIC_URL · CORS_ORIGIN(11단계) → 공개 내려받기 server 이름 · 포털이 공개 경로를 닫는지 (nginx-env.envsh — 모양이 틀리면 기동 중단)
|
||||
│ MAIL_DOMAIN · MAIL_HOSTNAME(13단계) → 메일 프로그램 자동 설정 server(autoconfig. · autodiscover.) — 비면 꺼짐, 모양이 틀리거나 포털 · 공개 호스트와 겹치면 기동 중단
|
||||
├─▶ compose : COMPOSE_PROFILES · COMPOSE_FILE 도 --env-file 에서 읽는다 (운영의 remote-proxy 는 COMPOSE_FILE 로 — 빈 COMPOSE_FILE= 은 실패하니 주석으로)
|
||||
│ (호스트에서 `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 주소 목록용)
|
||||
│ MAIL_RESOLVER_SUBNET · MAIL_RESOLVER_IP → mail-dns(mail ↔ 메일 전용 DNS 리졸버, internal) 서브넷 · 리졸버 주소 고정
|
||||
│ (mail 의 dns · 엔진의 DNS 가 이 IP 를 쓴다 — 둘을 함께 바꾸고 setup 을 다시 실행한다)
|
||||
├─▶ db : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
|
||||
├─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
|
||||
├─▶ uploads : 운영은 named volume <PROJECT_NAME>_uploads (env 없음), 개발만 UPLOAD_DATA_DIR 바인드 마운트
|
||||
├─▶ files : 대용량 첨부(11단계) — backend 에 SHARE_PUBLIC_URL·SHARE_REPORT_ADDRESS·SHARE_DOWNLOAD_LIMIT·MAIL_LARGE_FILE_*·
|
||||
│ FILE_STORAGE_MAX_TOTAL_GB·FILE_STORAGE_MIN_FREE_GB·CLAMAV_HOST·CLAMAV_PORT (비운 값은 백엔드 기본값)
|
||||
│ 파일은 named volume <PROJECT_NAME>_files — 개발도. 경로 FILE_STORAGE_DIR=/app/files 는 compose 가 고정한다(루트 env 에 두지 않는다)
|
||||
│ 악성 검사기 clamav · clamav-updater 는 운영은 늘 뜨고 개발은 COMPOSE_PROFILES 에 clamav 를 넣고 CLAMAV_HOST=clamav 를 줄 때만
|
||||
├─▶ 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_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS·MAIL_DIRECTORY_LOCAL_PART)
|
||||
│ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포")
|
||||
│ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다
|
||||
│ MAIL_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본 mail-directory)도 함께 읽는다 — 계정은 setup 이 복구 관리자로만 만든다(운영 --recovery)
|
||||
│ MAIL_WEBHOOK_SECRET(12단계 — 엔진 웹훅 서명 키, 영문 · 숫자 32자 이상)도 setup · backend 가 함께 읽는다 — 운영 필수, 바꾸면 setup 다시 + backend 다시 띄우기
|
||||
│ backend 에는 APP_NETWORK_SUBNET 도 넘긴다(웹훅 받는 쪽이 접속 주소가 앱 네트워크인지 본다)
|
||||
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
|
||||
MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다
|
||||
BACKEND_PORT · MAIL_WEBHOOK_SECRET 도 읽는다(12단계 — 엔진 웹훅 주소 http://backend:<BACKEND_PORT>/… 와 서명 키, 셸 값과 다르면 멈춘다)
|
||||
MAIL_PILOT_LEGACY_HOST · MAIL_PILOT_LEGACY_IPS(10단계 파일럿 공존 — 옛 서버 주소 · 그 서버가 보내는 IP)는 setup 만 읽는다(compose · backend 에 넘기지 않는다)
|
||||
MAIL_SPAMHAUS_DQS_KEY(Spamhaus 조회 키 — 비밀)도 setup 만 읽는다. 운영은 .env.production 에만(셸에만 있으면 setup 이 멈춘다),
|
||||
개발은 비워 둔다 — 비면 Spamhaus 두 항목을 끈다. 키는 엔진의 스팸 필터 설정에 그대로 저장된다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
```
|
||||
|
||||
규칙:
|
||||
|
||||
- **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml` 의 `environment` / `args` 로 전달한다.
|
||||
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`)
|
||||
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`,
|
||||
앱 새 메일 알림의 `FCM_SERVICE_ACCOUNT_KEY` — 앱 1단계, **선택**: Firebase 서비스 계정 JSON 키를 통째로 base64 한 줄. 비면 알림만 끄고 기동한다(운영 필수 아님 — 기동 warn ·
|
||||
`GET /api/app-config` 의 `pushAvailable: false`). 값이 있으면 모든 환경에서 키 모양을 보고 틀리면 기동하지 않는다(어느 칸이 틀렸는지만 — 값은 싣지 않는다, `env.validation.ts` 의 `validateFcmSettings`).
|
||||
개발 · 운영 키를 따로 만든다. 매일 백업이 `backend/.env.production` 을 함께 올린다)
|
||||
- **compose 는 정의되지 않은 변수를 빈 문자열로 주입한다.** 코드에서 `??` 기본값만 쓰면 `Number('')===0` 같은 함정에 빠진다 —
|
||||
숫자 설정값은 `shared/utils/env.ts` 의 `parsePositiveInt()` 처럼 빈 문자열까지 방어하는 파서를 거친다.
|
||||
(기존 `.env` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
|
||||
@@ -47,10 +68,17 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
`vite.config.ts` 가 루트 `.env.{mode}` 를 `loadEnv` 로 읽어 포트·프록시 대상을 정한다.
|
||||
포트를 `frontend/.env` 나 `vite.config.ts` 에 중복 작성하지 말 것 — 루트가 SSOT 다.
|
||||
(읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — `VITE_` 없는 값의 번들 노출 금지, [[security]])
|
||||
- **운영 스크립트(`ops/` — 호스트 systemd 의 백업 · 점검 · 부팅 되살리기, 13단계)도 예외** — 설정은 루트 env 가 아니라 서버의 `/etc/arasystem/ops.env` · `restic-password`(root 0600)에 둔다.
|
||||
ops.env: Object Storage 주소 · 키, Healthchecks 주소, `PROJECT_NAME` · `BACKUP_HOST`(새 서버 복원 때 필수 — 옛 서버의 값), `BACKUP_FINAL_TIMEOUT`(메일을 멈춘 final 의 상한, 기본 15m) · `BACKUP_HEALTHY_WAIT`(기본 300초).
|
||||
스크립트는 `.env.production` 의 `PROJECT_NAME` · `MAIL_HOSTNAME` · `CORS_ORIGIN` · `SHARE_PUBLIC_URL` · `MAIL_ADMIN_PASSWORD` · `MAIL_SPAMHAUS_DQS_KEY` 를 **읽기만 하고 복사해 두지 않는다**.
|
||||
`OPS_ALLOW_NO_TTY` 는 운영 도구(CLI)를 자동 시험에서 파이프로 돌릴 때만 `docker exec -e` 로 준다 — env 파일 · compose 에 두지 않는다.
|
||||
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
|
||||
- **루트 env 를 Node 스크립트가 직접 읽을 때**(`node --env-file=…`) 두 가지를 주의한다.
|
||||
- Node 는 **셸에 이미 있는 변수를 덮어쓰지 않는다** — 셸에 `APP_ENV=development` 가 남은 채 `.env.production` 을 주면 개발 값으로 돈다.
|
||||
스크립트는 env 파일 값과 셸 값이 다르면 중단해야 한다 (`mail/scripts/setup.mjs` 의 `findShellOverrides()`).
|
||||
예외는 복구 관리자 `MAIL_RECOVERY_ADMIN`(원래 셸에서 일회성으로 주는 값이라 검사하지 않는다)과 파일럿 키 둘이다 — 파일럿 키는 개발은 한 번 시험하려고 셸로 줄 수 있고(`[참고]` 를 낸다),
|
||||
운영은 반대로 파일에 없는 파일럿 값이 셸에 있으면 중단한다 (파일럿은 엔진에 남는 설정이라 파일만 보고도 알 수 있어야 한다). 운영에서 파일럿을 끄는 것은 값을 비우고 `--end-pilot` 을 줄 때뿐이다 — 값이 빠졌다고 저절로 끄지 않는다.
|
||||
Spamhaus 키 `MAIL_SPAMHAUS_DQS_KEY` 도 운영은 파일에만 둔다 — 셸에만 있으면 그 실행만 켜지고 다음 실행이 끄므로 중단한다.
|
||||
- Node 는 따옴표 없는 값의 `#` 부터를 **공백이 없어도** 주석으로 자르고, compose 는 그대로 읽는다.
|
||||
비밀번호 등은 영문·숫자로만 만든다 (`#`·따옴표·`$`·공백 금지).
|
||||
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
|
||||
@@ -58,20 +86,29 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
|
||||
- 숫자 항목은 형식 검사 (모든 환경)
|
||||
- 운영 환경 필수: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME`, `JWT_SECRET`, `MAIL_JMAP_URL`/`MAIL_DOMAIN`/`MAIL_GATEWAY_*`,
|
||||
`CORS_ORIGIN`(화면의 공개 주소 — 상태 변경 요청의 출처 검사), `TRUST_PROXY`(백엔드 앞 프록시 홉 수 — 운영에서 `true`·`0`·`false` 는 거부)
|
||||
`CORS_ORIGIN`(화면의 공개 주소 — 상태 변경 요청의 출처 검사), `TRUST_PROXY`(백엔드 앞 프록시 홉 수 — 운영에서 `true`·`0`·`false` 는 거부),
|
||||
`SHARE_PUBLIC_URL`(대용량 첨부의 공개 내려받기 출처 — https), `CLAMAV_HOST`(악성 검사기 — 비면 검사 없이 받을 수 있게 되므로),
|
||||
`MAIL_WEBHOOK_SECRET`(12단계 — 엔진 웹훅 서명 키, 영문 · 숫자 32자 이상. 예시 값 `CHANGEME…` 는 setup · backend 모두 거부)
|
||||
- 운영 환경: **`SHARE_PUBLIC_URL` 의 호스트를 `CORS_ORIGIN` 에 넣지 않는다** — 같은 호스트면 공개 호스트 분리(포털에서 공개 링크를 닫는 것)가 꺼져 기동을 막는다.
|
||||
`SHARE_PUBLIC_URL` 의 모양은 백엔드와 nginx 템플릿(`nginx-env.envsh`)이 같은 규칙으로 본다 — 출처만(경로 · 끝의 `/` · 끝 점 없이). 한 번 정하면 바꾸지 않는다(보낸 메일의 링크가 그 주소다)
|
||||
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
|
||||
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
|
||||
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL`·`SHARE_PUBLIC_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
|
||||
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
|
||||
로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓸 때만 `ALLOW_LOCALHOST_URL=true` 로 건너뛴다
|
||||
(`BACKEND_API_URL` 은 앱도 프론트 빌드도 쓰지 않는다 — 같은 주소 배포라 "화면 주소 + /api" 이고, 이 검사를 위해서만 compose 가 backend 에 주입한다)
|
||||
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
|
||||
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
|
||||
|
||||
> 루트 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`),
|
||||
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, `COMPOSE_PROFILES`·`COMPOSE_FILE`(운영의 remote-proxy 일 때), 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`·`MAIL_RESOLVER_SUBNET`/`MAIL_RESOLVER_IP`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`, `FRONTEND_PRIVATE_BIND`(remote-proxy 일 때),
|
||||
> `DB_*`/`DB_DATA_DIR`·`DB_IMAGE`(13단계 — 판 · digest 고정), `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`),
|
||||
> 대용량 첨부(11단계) `SHARE_PUBLIC_URL`(운영 필수 — 포털과 다른 호스트)·`SHARE_REPORT_ADDRESS`·`SHARE_DOWNLOAD_LIMIT`·`MAIL_LARGE_FILE_MAX_SIZE_MB`/`_MAX_COUNT`/`_USER_QUOTA_GB`/`_RETENTION_DAYS`·
|
||||
> `FILE_STORAGE_MAX_TOTAL_GB`/`FILE_STORAGE_MIN_FREE_GB`·`CLAMAV_HOST`(운영 필수)/`CLAMAV_PORT`,
|
||||
> 메일 `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_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·`MAIL_DIRECTORY_LOCAL_PART`(사내 목록 계정 아이디, 기본 `mail-directory` — setup·backend 공용)·
|
||||
> `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_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)·
|
||||
> `MAIL_PILOT_LEGACY_HOST`/`MAIL_PILOT_LEGACY_IPS`(선택 — 파일럿 공존 동안만, setup 전용. mail/README.md "파일럿 공존")·
|
||||
> `MAIL_SPAMHAUS_DQS_KEY`(선택 · 비밀 — Spamhaus 조회 키, setup 전용, 운영 파일에만)·
|
||||
> `MAIL_WEBHOOK_SECRET`(비밀 — 엔진 웹훅 서명 키, setup · backend 공용, 운영 필수 — 만들기 `openssl rand -hex 24`)
|
||||
> (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조.
|
||||
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
|
||||
|
||||
@@ -29,24 +29,30 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
|
||||
|
||||
| 코드 | HTTP | 상황 | 메시지 |
|
||||
| --- | --- | --- | --- |
|
||||
| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." |
|
||||
| REQ_001 | 429 | Rate Limit 초과 | "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요." |
|
||||
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
|
||||
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
|
||||
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
|
||||
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." |
|
||||
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
|
||||
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
|
||||
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
|
||||
| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요." |
|
||||
| REQ_001 | 429 | Rate Limit 초과 · 실패 횟수 한도 · 같은 일이 진행 중 | "너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요." |
|
||||
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요." |
|
||||
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃했습니다. 다시 로그인해 주세요." |
|
||||
| AUTH_003 | 403 | 권한 부족 | "이 기능을 쓸 수 있는 권한이 없습니다." |
|
||||
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요." |
|
||||
| AUTH_005 | 400 | 로그인한 채 본인 확인 실패 — 비밀번호 변경의 지금 비밀번호가 틀림. **401 이 아니다** — 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." |
|
||||
| AUTH_006 | 400 | 2단계 인증 코드 틀림(로그인 코드 단계 · 켜기 · 끄기 · 비밀번호 바꾸기 — 12단계). 401 이 아니다(위와 같은 까닭) | "코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요." |
|
||||
| AUTH_007 | 400 | 2단계 인증을 켠 직원인데 코드가 없다 — 화면은 코드 칸을 보인다 | "인증 앱의 코드를 입력해 주세요." |
|
||||
| AUTH_008 | 409 | 2단계 등록이 그사이 바뀌었거나 10분이 지났다 · 이미 켜져 있다 — 처음부터(새 키) | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." |
|
||||
| AUTH_009 | 409 | 앱 비밀번호 상한(5개) | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." |
|
||||
| AUTH_010 | 403 | 제한된 세션(새 비밀번호 정하기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API. 화면은 알림 없이 `me` 를 다시 받아 그 단계로 간다. 403 의 상태 역매핑은 그대로 `AUTH_003` | "먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요." |
|
||||
| VAL_001 | 400 / 422 | 입력 검증 실패(빠진 값 · 형식 · 길이 · 개수 한도) | "입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다." |
|
||||
| RES_001 | 404 | 리소스 없음 | "찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다." |
|
||||
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "하려던 일을 마치지 못했습니다. 다시 시도해 주세요." |
|
||||
|
||||
### AUTH_002 (클라이언트 전용)
|
||||
|
||||
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
|
||||
|
||||
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
|
||||
웹은 그 전에 `/auth/refresh` 를 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절).
|
||||
웹 · 앱은 그 전에 갱신을 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절).
|
||||
- 웹: `useApi.ts` 의 `toAuthCode()` — `authStore.expireSession()` 을 불러 세션 만료 대화상자(`SessionExpiredDialog.vue`)를 띄운다
|
||||
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
|
||||
- 앱: `core/api/auth_interceptor.dart` · `token_refresher.dart` — 웹처럼 갱신(`/auth/app/refresh`)을 한 번 해 보고, 갱신까지 거절됐을 때만 승격한다 (리프레시 토큰 삭제 · 로그인 화면 — `docs/mail/api-contract.md` 1절 "앱 로그인"). 그 요청을 보낸 세션이 이미 끝났으면(세션 세대가 다르다) 알리지 않는다 — 사용자가 스스로 로그아웃했으면 승격하지 않고 `AUTH_001` 그대로(웹 `endedBySignOut` 과 같다), 세션이 끝난 뒤면 `AUTH_002`
|
||||
|
||||
## 도메인 코드 추가
|
||||
|
||||
|
||||
@@ -31,9 +31,11 @@
|
||||
아래 값은 **어떤 레벨에서도 원문 그대로 남기지 않는다.**
|
||||
|
||||
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
|
||||
- 앱 푸시(앱 1단계 — FCM)의 휴대폰 주소(FID — 지문도 남기지 않는다: 이어 붙여 기기를 좇지 못하게) · 서비스 계정 키 · 개인 키 · 서비스 계정 JWT · Google access token · FCM · Google 의 오류 설명 글(상태 · 오류 코드만 남긴다)
|
||||
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
|
||||
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
|
||||
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
|
||||
- DB 쿼리 로그(매개변수) — **개발 환경도** 남기지 않는다. 메일 글 · 제목 · 주소가 DB 에 있다(14단계 검색 색인). TypeORM `logging` 은 개발에서 오류 · 경고 · 마이그레이션만, 운영 · 시험은 끈다 (`shared/config/database.config.ts` 의 `databaseLogging`)
|
||||
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · 조건 검색의 `from` · `to` · `subject` · `body`(14단계) · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
|
||||
앞단 nginx 도 접근 로그에 쿼리를 남기지 않는다 — `/api` 와 화면 요청 모두(메일 검색은 화면 주소 `/mail/search?q=…` 에도 검색어가 들어간다, [[docker]]).
|
||||
**예외**: nginx 오류 로그(413 · 502 · 504 등)는 장애 원인 줄을 잃지 않게 요청 줄 · referrer 를 쿼리째 남긴다 — 오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다
|
||||
|
||||
@@ -47,6 +49,8 @@ this.logger.log(`로그인 시도: email=${maskEmail(dto.email)}`);
|
||||
|
||||
- 마스킹 유틸은 순수 함수로 `shared/utils/` 에 둔다 ([[coding-common]]).
|
||||
|
||||
- 메일 프로그램 자동 설정(`autoconfig.` · `autodiscover.` — 13단계)의 요청 주소에는 직원 주소가 쿼리로 실린다(`?emailaddress=`) — 앞단 nginx 는 쿼리 없이 남기고(위 규칙 그대로), 외부 프록시의 그 Proxy Host 는 접근 로그를 끈다 ([[docker]]).
|
||||
|
||||
## 클라이언트 로깅
|
||||
|
||||
- 프론트/앱에서 사용자 정보를 콘솔에 출력하지 않는다. 운영 빌드에는 디버그 로그를 남기지 않는다.
|
||||
@@ -56,5 +60,8 @@ this.logger.log(`로그인 시도: email=${maskEmail(dto.email)}`);
|
||||
|
||||
- 운영 환경은 JSON 포맷 + 외부 수집기(Loki/ELK/CloudWatch) 연동을 권장한다.
|
||||
- 파일로 남길 경우 로테이션(`winston-daily-rotate-file`)을 적용하고 보존 기간을 정한다.
|
||||
- **컨테이너 로그 보존**(13단계): compose 의 모든 서비스에 json-file 회전을 명시한다(x- 앵커 — 기본 10m×5, mail · frontend · backend 는 20m×10, 압축 — 운영에서 mail 2주 · frontend 1주쯤).
|
||||
새 서비스에도 앵커를 붙인다(CI 가 서비스 수와 logging 수를 견준다). 더 오래 남기려면 위 수집기로.
|
||||
- **운영 도구 실행 기록**(`ops_audit_log` — 13단계): 언제 · 누가(운영자 이름) · 명령 · 대상 · 결과. 대상 주소는 감사용이라 원문으로 둔다(DB 백업에 든다). 비밀번호 · 임시 비밀번호는 남기지 않는다.
|
||||
|
||||
> 보안 규칙은 [[security]], 테스트 규칙은 [[testing]] 참조.
|
||||
|
||||
+70
-13
@@ -11,8 +11,8 @@
|
||||
|
||||
## 인증 · 토큰
|
||||
|
||||
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
|
||||
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
|
||||
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 리프레시 토큰만 `flutter_secure_storage`(키: `refresh_token`), 액세스 토큰은 메모리 — 앱 전용 토큰 경로(계약 `docs/mail/api-contract.md` 1절 "앱 로그인"), Android 백업 끔
|
||||
- Access Token 만료는 짧게(15분~1시간). Refresh Token 은 웹은 HttpOnly 쿠키로만, 앱은 1절 "앱 로그인"대로(본문으로 받아 보안 저장소에만 — 로그 · 일반 저장소에 두지 않는다)
|
||||
- 리프레시 토큰은 서버 세션에 **해시만** 저장하고 **1회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다.
|
||||
동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게)
|
||||
- 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다.
|
||||
@@ -20,6 +20,26 @@
|
||||
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
|
||||
- **메일 게이트웨이(대리 접근) 계정은 관리자 계정까지 대리할 수 있다** (Stalwart 0.16 확인) — 게이트웨이 비밀번호는 관리자급 비밀로 다룬다.
|
||||
백엔드는 엔진 관리자 계정을 로그인·대리 접근 대상에서 막는다: 정적 목록(엔진에 묻기 전) + 로그인·리프레시 때 엔진 권한 확인 ([[nestjs]])
|
||||
- **2단계 인증(12단계 — 엔진 TOTP)**: 엔진은 등록 때 코드를 확인하지 않고(잘못 등록하면 직원이 끄지도 못한다) 같은 코드를 다시 받으므로 서버가 막는다 —
|
||||
등록 때 첫 코드 확인, 성공에 쓴 코드를 2분 동안 다시 받지 않기(확인하는 동안에도 — 같은 코드의 동시 요청이 둘 다 지나가지 않게), 코드 실패를 따로 세기(같은 주소 15분 5회 · 24시간 20회).
|
||||
코드 단계에서도 비밀번호를 먼저 엔진 Basic 으로 본다 — 엔진 `/api/auth` 의 failure 는 비밀번호 · 코드를 가르지 않아, 그대로면 비밀번호 추측이 코드 실패로 세진다. 비밀(TOTP 키)은 등록 10분 동안만 서버 메모리에 두고 켠 뒤에는 엔진에만 있다
|
||||
- **앱 비밀번호**(메일 프로그램용 — 엔진이 만든 40자): 웹메일 로그인 · 비밀번호 바꾸기는 그 모양(`app` + 1자 + 36자)을 엔진에 묻지 않고 거절한다(엔진에는 웹메일에서 빼는 설정이 없다).
|
||||
만들 때 권한을 메일 프로그램만(`Replace` — authenticate · imapAuthenticate · emailSend)으로 좁힌다 — 기본(`Inherit`)이면 앱 비밀번호로 JMAP 을 쓰고 앱 비밀번호를 또 만든다.
|
||||
비밀번호를 바꾸면 앱 비밀번호를 모두 지운다(비밀번호가 샌 동안 만든 앱 비밀번호가 남지 않게). 값은 만든 응답에만 싣는다(no-store — 로그 · DB 에 두지 않는다)
|
||||
- **끊은 세션은 바로 막는다**(12단계) — 액세스 토큰의 `sid` 와 서버가 1시간 기억한 끊은 세션(뜰 때 DB 에서 채운다)으로 가드가 거절한다. 액세스 토큰 만료까지 기다리지 않는다
|
||||
- **엔진이 부르는 길**(웹훅 — 로그인 기록)은 세 겹으로 받는다: 앞단 nginx 가 `/api/internal/` 을 닫고, 서버가 접속 주소(소켓 — `X-Forwarded-For` 가 아니다)가 앱 네트워크인지 본문을 받기 전에 보고,
|
||||
본문 원문의 HMAC-SHA256(`MAIL_WEBHOOK_SECRET`)을 상수 시간으로 견준다. 키가 비면 모두 거절한다
|
||||
- **제한된 세션**(13단계 — 새 비밀번호 정하기 · 2단계 인증 켜기 전): 로그인은 되지만 허용한 경로(`@AllowRestrictedSession` — `/api/auth/*` · 2단계 켜기)만 지나고 그 밖은 403 `AUTH_010` 이다.
|
||||
제한을 푸는 요청은 **그 요청의 세션만** 풀고, 다음 제한이 있으면 그 제한으로 바꾼다. 2단계를 켜면 그 직원의 다른 세션 가운데 2단계를 거친 적 없는(`twoFactorSetup`) 세션은 끊는다 —
|
||||
비밀번호만 아는 다른 사람의 세션이 함께 열리지 않게(2026-11-02 리뷰). 엔진은 2단계를 강제하지 못하고, 비밀번호가 만료되면 웹메일(대리 접근)은 메일 오류만 보이므로 서버가 알아채 그 단계로 보낸다
|
||||
- **운영 도구**(서버 안 CLI — 13단계)의 관리자 비밀번호는 실행할 때 터미널에서만 받는다 — 화면에 보이지 않게 · 저장하지 않음 · 실행 기록 · 로그 · 예외 문장 · 프로세스 인자에 없음 · 입력 기록(↑) 0(`historySize: 0` — 2026-11-02 리뷰).
|
||||
표준 입력이 TTY 가 아니면 묻기 전에 멈춘다(자동 시험만 `OPS_ALLOW_NO_TTY=1`). 임시 비밀번호는 crypto 무작위 16자 · 한 번만 보여 주고 · 엔진 만료(첫 로그인 때 바꾸기)와 함께 건다.
|
||||
이전용 비밀번호(메일 이전 — 2026-11-02)도 같은 무작위 · 한 번만이고 엔진 만료를 30일 뒤로 건다(운영 도구 한 번 실행 안에서는 같은 값 — 프로세스 메모리에만) — imapsync 의 비밀번호 파일(`--passfile` — 0600, 끝나면 지움)에만 넣고 명령줄에 싣거나 직원에게 주지 않는다. 마지막 imapsync 뒤 「이전 마치기」로 직원용 임시 비밀번호로 바꾼다
|
||||
- **메일 엔진 관리자 계정**(13단계 setup): 대리 접근 권한(`impersonate`)이 없고 허용 IP 는 앱 네트워크다 — 관리자 비밀번호가 새도 바깥 993 · JMAP 에서 직원 메일함에 들어가지 못한다.
|
||||
되돌리는 길은 복구 관리자뿐이라 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 한다(없으면 걸지 않고 멈춘다). 게이트웨이 비밀번호는 여전히 관리자급이다(위 줄)
|
||||
- **받은편지함 알림 메일**(서버가 넣는 시스템 메일)에는 링크를 넣지 않는다 — 할 일은 메뉴 경로 글로. "회사 보안 알림에는 링크가 없다"는 습관이 링크 달린 피싱을 드러낸다
|
||||
- **퇴사 마무리**(주소를 다시 쓸 수 있게): 엔진 계정을 지운 뒤 우리 DB 에서 그 주소로 키링한 행을 모두 지운다 — 남기면 같은 주소의 새 사람에게 옛 로그인 기록 · 2단계 설정 · 서명이 보인다.
|
||||
끊은 세션 행은 끊은 기록을 서버가 동기화로 읽어야 하므로 바로 지우지 않는다(정리 cron)
|
||||
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004)
|
||||
- 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다.
|
||||
로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다.
|
||||
@@ -27,6 +47,9 @@
|
||||
## 입력 · 출력
|
||||
|
||||
- 입력값은 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`). 평범한 해시는 받은 사람이 후보 값(숨은참조 주소 등)을 대입해 맞춰 볼 수 있다.
|
||||
@@ -41,10 +64,13 @@
|
||||
`img onerror` 같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 ([[vue3]] 체크리스트 "외부 HTML")
|
||||
### 파일 업로드
|
||||
|
||||
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
`modules/upload` 에 로컬 디스크 기준 골격이 있다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
|
||||
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
|
||||
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
|
||||
- **예외 — 대용량 첨부(11단계 `modules/file-storage`)는 확장자 허용 목록만 본다.** 브라우저가 주는 형식은 dwg · psd · 7z 같은 파일에 믿을 근거가 없어(비거나 제각각)
|
||||
MIME 으로 거르면 정상 파일만 막힌다. 대신 세 겹으로 막는다: 내려줄 때 늘 `application/octet-stream` + `attachment` + `nosniff` + CSP `sandbox`(브라우저가 열지 않는다),
|
||||
허용 목록에 브라우저가 실행하는 형식이 없다, 내용은 ClamAV 가 본다(검사 전 · 중에는 받을 수 없다) — 계약 4절 "정한 것" 10.
|
||||
- **허용 목록(allowlist) 방식**으로만 받는다. 차단 목록은 언제나 빠뜨린다.
|
||||
`.html`·`.svg`·`.js` 등 **브라우저가 실행할 수 있는 형식은 넣지 않는다.**
|
||||
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
|
||||
@@ -60,6 +86,31 @@
|
||||
> 이름이 `.png` 이고 MIME 이 `image/png` 이면 내용이 HTML 이어도 통과한다.
|
||||
> 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면
|
||||
> 매직 바이트를 확인하는 라이브러리(`file-type` 등)를 파이프라인에 추가한다.
|
||||
> (서명 그림은 매직 바이트로 판별하고 — `modules/mail-signature/signature-image-format.ts`, 대용량 첨부는 악성 검사(ClamAV)가 내용을 본다.)
|
||||
|
||||
### 공개 링크 (로그인 없이 받는 길 — 11단계 대용량 첨부)
|
||||
|
||||
- 토큰은 서버가 만든 256비트(`randomBytes(32)` base64url)이고 **DB 에는 SHA-256 만** 둔다. 모양이 아니면 DB 에 묻지 않는다.
|
||||
원문 토큰은 로그 · 오류 context · 목록 응답 어디에도 두지 않는다 — 요청 주소는 `maskUrlForLog()`(경로 어디서든 `public-links` 뒤 조각을 가린다), 앞단 nginx 접근 로그도 가린다.
|
||||
- 공개 경로는 **따로 둔 공개 호스트**(`SHARE_PUBLIC_URL` — `files.`)에서만 열고 포털에서는 닫는다(nginx 의 호스트별 server + 백엔드 `public-host.ts` 두 겹).
|
||||
인증 쿠키는 host-only 라 공개 호스트로 가지 않는다. 운영에서 공개 호스트를 `CORS_ORIGIN` 에 넣으면 기동하지 않는다(분리가 꺼지므로).
|
||||
- 받은 수 상한은 한 문장의 조건부 UPDATE(`… WHERE count < limit RETURNING`)로 센다 — 읽고 나서 쓰면 동시 요청이 상한을 넘는다.
|
||||
- 버퍼링을 끈 내려받기는 연결 하나가 앞단 연결 둘과 백엔드 fd 를 잡는다 — **IP 마다 · 전체 동시 연결 상한**(nginx `limit_conn`)을 함께 둔다.
|
||||
전체 상한은 경로(공개 · 앱 안)마다 따로 — 함께 세면 바깥 주소 몇십 개로 직원의 받기까지 막힌다.
|
||||
**앞단과 백엔드가 같은 경로를 같게 읽어야 한다** — 백엔드가 대소문자를 가리지 않던 때 `/FILES/` 처럼 글자 하나로 nginx 의 상한 location 을 비켜 갔다(2026-10-30 리뷰).
|
||||
그래서 nginx 의 받기 location 은 대소문자를 가리지 않는 정규식이고, 백엔드 라우팅은 대소문자를 가린다(두 겹 — [[nestjs]] "전역 HTTP 설정").
|
||||
같은 까닭으로 요청 대상의 날 `\`(경로) · `#` · 0x80 이상 바이트는 nginx · 백엔드 모두 400 이다 — Express 는 이런 대상을 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꿔,
|
||||
nginx 가 닫은 `/api/internal/` · `/api/health` 가 백엔드에서 열렸다(2026-10-31 리뷰).
|
||||
|
||||
### 앱 푸시 (휴대폰 새 메일 알림 — 앱 1단계 · FCM)
|
||||
|
||||
- **글은 "새 메일 N통"뿐이다** — 주소 · 이름 · 보낸 사람 · 제목 · 메일 id · 세션 id 는 Google 로 보내지 않는다. 메시지는 한 곳(`modules/push/fcm/fcm-message.ts`)에서 종류 · 개수만 받아 만든다(받는 칸이 없다),
|
||||
새 메일을 알아채는 쪽도 엔진 연결에서 신호만 받는다. Google 은 기기마다 언제 몇 통이 왔는지는 안다(알려진 한계).
|
||||
- **휴대폰 주소(FID)는 한 세션에만** — 같은 FID 를 다른 세션이 등록하면 앞 줄을 지운다(같은 휴대폰에서 다른 직원이 로그인해도 앞 직원의 알림이 가지 않게). 직원마다 10개까지.
|
||||
- **보낼 때 살아 있는 · 제한 없는 앱 세션만 고른다** — 끊은 세션(로그아웃 · 비밀번호 바꾸기 · 운영 도구)은 DB 의 `revoked_at` 으로 바로 빠진다. 등록 경로는 앱 토큰만(웹 쿠키 세션 403 `AUTH_003`).
|
||||
- **서비스 계정은 알림 전용 하나 + 역할 "Firebase Cloud Messaging API Admin" 하나** — Firebase 의 기본 Admin SDK 계정(Firestore · Auth 까지 쓴다)을 쓰지 않는다. 키는 개발 · 운영 따로,
|
||||
`backend/.env.{env}` 의 `FCM_SERVICE_ACCOUNT_KEY` 에만 둔다(로그 · 오류 · 응답에 싣지 않는다). 새면 그 키를 Google Cloud 콘솔에서 지우고 새 키로 — 그 키로는 우리 앱 사용자에게 꾸민 알림을 보낼 수 있다(메일은 읽지 못한다).
|
||||
- 바깥 주소는 상수 두 곳(`oauth2.googleapis.com` · `fcm.googleapis.com`)뿐이다 — 키 파일의 `token_uri` 를 따라가지 않고(다르면 기동하지 않는다) 다른 곳으로 보내는 응답(3xx)도 따라가지 않는다.
|
||||
|
||||
## 네트워크 · 인프라
|
||||
|
||||
@@ -80,10 +131,14 @@
|
||||
- 라우트 단위 조정: `@Throttle({ default: { ttl, limit } })` / 제외: `@SkipThrottle()`
|
||||
- 로그인·비밀번호 재설정처럼 브루트포스 표적이 되는 엔드포인트는 **더 엄격한 한도**를 따로 건다
|
||||
- ⚠️ rate limiter 를 이중으로 걸지 말 것 — 어느 쪽이 실제로 동작하는지 알 수 없게 되고 응답 헤더가 실제 한도와 어긋난다
|
||||
- **백업 비밀**(13단계): restic 비밀번호 · Object Storage 키는 서버의 `/etc/arasystem/`(root 0600)에 두고 **서버 밖(비밀번호 관리자)에도** 둔다 — 잃으면 백업을 풀 수 없다.
|
||||
백업에는 env 파일이 들어간다(암호화). 운영 스크립트는 비밀을 `docker exec -e 이름` · 표준 입력 · 읽기 전용 마운트로만 넘기고 명령줄(`ps` 로 보인다)에 싣지 않는다 — Healthchecks 주소도(`curl -K -`)
|
||||
- **컨테이너 root 실행 금지 — 개발 환경 포함.** 운영뿐 아니라 dev 오버라이드가 사용하는 빌드 스테이지도 비-root 여야 한다.
|
||||
- 프론트엔드 운영: `nginxinc/nginx-unprivileged` + `listen 8080` (비-root 는 1024 미만 포트 바인드 불가)
|
||||
- Node 스테이지: `RUN chown node:node /app` → `USER node` → `COPY --chown=node:node`
|
||||
(소유권을 먼저 넘겨야 익명 볼륨 `node_modules` 가 node 소유로 생성되어 캐시 쓰기가 가능하다)
|
||||
- 예외(13단계 — 호스트 systemd 의 백업 · 복원 보조 컨테이너): restic 은 다른 소유자(메일 엔진 UID 2000 등)의 볼륨을 읽으려고 root 로 돌되 `--cap-drop ALL` + `DAC_READ_SEARCH` ·
|
||||
`no-new-privileges` 만(복원은 `DAC_OVERRIDE` · `CHOWN` · `FOWNER` 를 더함). 앱 컨테이너가 아니라 운영 스크립트가 잠깐 띄우는 것이다 ([[docker]] "백업 · 감시")
|
||||
- DB·Redis 는 `expose` 만 사용해 내부 네트워크에만 노출한다.
|
||||
개발 편의로 호스트에 열어야 한다면 **반드시 `127.0.0.1` 로 한정**한다 — `"${DB_PORT}:5432"` 는 `0.0.0.0` 바인딩이라
|
||||
같은 공유기(사무실·카페)에 있는 누구나 개발 DB 에 접속할 수 있다.
|
||||
@@ -114,32 +169,33 @@
|
||||
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
|
||||
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
|
||||
|
||||
> **현재 유예 중인 항목** (2026-10-27 점검):
|
||||
> **현재 유예 중인 항목** (frontend 2026-10-27 · backend 2026-11-05 점검):
|
||||
> - 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종):
|
||||
> - backend (2026-11-05 기준 23건 — moderate 21 · low 2 · high 0, 운영 의존성만 보면 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 |
|
||||
> | `js-yaml` 5.3.0 (moderate, `@nestjs/swagger` 하위) | 없음 — swagger 는 `dump` 만 쓰고 우리 코드는 YAML 을 파싱하지 않는다 | swagger 11.4.8(2026-11-04)이 js-yaml 5.5.0 을 선언하지만 배포 2주 미만 | 2026-11-18 이후 `npm update @nestjs/swagger`(`^11` 범위 — lock 만, 컨테이너 안에서) |
|
||||
> | `glob` 10.5.0 · `minimatch` 9.0.9 (moderate, `typeorm` 0.3.31 하위 — 11-05 새 권고) | 없음 — typeorm 은 기동 때 `database.config.ts` 의 고정 패턴(entities · migrations)만 푼다. 외부 입력이 닿지 않고 우리 코드는 둘을 직접 쓰지 않는다 | 수정판이 메이저(glob 14 · minimatch 11, 2026-10-27)라 override 로 건너뛰지 않는다(아래 규칙 — 같은 메이저만) | typeorm 0.3.x 가 고친 glob 을 선언한 판 + 2주, 또는 typeorm 1.x 로 옮길 때 |
|
||||
> | 개발 전용 18건 — jest · `@nestjs/cli` · `@swc/cli` · typescript-eslint 아래의 glob · minimatch, `deepmerge`(low) | 없음 — 운영 이미지에 없다 | 상위 스택의 갱신 판이 2주 미만이거나 메이저 | 그 스택을 올릴 때. ⚠️ `npm audit fix` 를 통째로 돌리지 않는다 — 2026-11-05 dry-run 이 `@unrs/resolver-binding-*` 를 1.13 으로 바꾸고 2주 미만 판을 섞었다 |
|
||||
>
|
||||
> 2026-11-05 에 올린 것(모두 배포 2주 지난 판 — 컨테이너 안 `npm install … --before=<2주 전 날짜>` 로 골랐다): body-parser 2.3.0 → 2.4.0(lock 만 — express 5.2.1 의 범위 안, qs 6.16.1 · iconv-lite 0.7.3 이 따라옴),
|
||||
> multer override `^2.4.0` → `^2.5.0`, sanitize-html 2.18.0 → 2.18.1, fast-uri 3.1.8 → 3.1.9. 29건 → 23건(운영 11 → 5, high 3 → 0).
|
||||
>
|
||||
> 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 를 지울 수 있다.
|
||||
> 참고: `@nestjs/platform-express` 11.x 최신(11.2.8)도 multer 2.4.0 을 정확히 고정하고, 2.5.0 은 12.3.0 부터 선언한다 — multer override 는 platform-express 를 v12.3 이상으로 올릴 때 지운다.
|
||||
>
|
||||
> 같은 점검에서 정리한 것: 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).
|
||||
> 도달 경로가 없다고 확인한 취약점이면 평소처럼 2주를 기다린다 (예: 2026-11-05 에 올린 sanitize-html 2.18.1 · multer 2.5.0 · body-parser 2.4.0).
|
||||
|
||||
### 전이 의존성 취약점은 overrides 로 끊는다
|
||||
|
||||
@@ -149,13 +205,14 @@
|
||||
```jsonc
|
||||
// backend/package.json
|
||||
"overrides": {
|
||||
"multer": "^2.4.0"
|
||||
"multer": "^2.5.0"
|
||||
}
|
||||
```
|
||||
|
||||
> 실제 사례: `@nestjs/platform-express@11` 은 `multer` 를 `2.2.0` 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다.
|
||||
> `@nestjs/platform-express@12` 는 `multer@2.4.0` 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다.
|
||||
> **v12 가 하는 일이 결국 multer 를 올리는 것**이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다.
|
||||
> 2026-11-05 multer 2.5.0(다음 DoS 수정)도 같은 길이다 — platform-express 12.3.0 이 2.5.0 을 선언하는 것이 근거, 2.4.0 과 의존성(busboy · type-is · append-field)도 같다.
|
||||
|
||||
규칙:
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
|
||||
|
||||
# 테스트 규칙
|
||||
|
||||
> 템플릿은 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
|
||||
> 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
|
||||
> 새 로직을 추가하면 최소한의 테스트를 함께 작성한다.
|
||||
|
||||
## 공통 원칙
|
||||
@@ -13,9 +13,17 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
|
||||
- **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다.
|
||||
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
|
||||
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
|
||||
- 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 프로세스 CPU 시간이나 비용 수치.
|
||||
- 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 지금 스레드의 user CPU 시간(`backend/test/cpu-time.ts`)이나 비용 수치.
|
||||
프로세스 전체 · system 시간은 쓰지 않는다 — V8 의 GC · JIT 도우미 스레드와 페이지 폴트가 기계 사정을 따라 흔들려, 2026-10-31 문서만 바꾼 PR 의 CI 에서 할당이 많은 단언 셋이 넘쳤다
|
||||
(같은 컨테이너 16회: 프로세스 user + system 으로 4회 넘침 · 스레드 user 로 0회).
|
||||
CPU 시간 한도는 **지금 값의 10배쯤** 둔다 — CPU 시간도 부하에서 는다(2026-10-28: CI 와 같은 Linux 컨테이너에서 메모리가 모자라고 테스트가 함께 돌 때
|
||||
평소의 4~7배 — 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다). 막으려는 옛 경로(제곱 등)는 그 한도를 확실히 넘어야 한다 — 둘 사이가 좁으면 입력을 키운다.
|
||||
벽시계는 다른 작업이 CPU 를 나눠 쓰면 몇 배로 늘어 CI 에서 흔들린다 (2026-10-27: 혼자 0.8초인 정화가 부하에서 9초).
|
||||
비동기 결과를 기다릴 때도 고정 대기(`setTimeout(20)`) 대신 조건이 될 때까지 짧게 다시 보고 넉넉한 기한을 둔다.
|
||||
- **시간대도 입력이다** — CI 는 UTC, 개발 PC 는 KST 다. 화면에 보이는 시각 · 날짜 글("오늘 오전 9:12")을 글자 그대로 보는 테스트는
|
||||
시각을 지역 시각으로 만들거나(`new Date(2026, 9, 31, 9, 12)`) 포매터에 시간대를 넘긴다(frontend `format.spec.ts` 의 `SEOUL`).
|
||||
UTC 로 적은 고정값(`'2026-10-31T00:12:00Z'`)은 KST 에서만 그 글이 된다(2026-11-02 — 12단계 테스트 셋이 CI 에서만 실패).
|
||||
시각을 보이는 화면을 고쳤으면 UTC 로도 돌려 본다(Git Bash — `TZ=UTC npm run test:unit -- --run`).
|
||||
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
|
||||
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
|
||||
- 테스트 설명(it/test 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
|
||||
|
||||
+255
-17
@@ -18,7 +18,8 @@ description: Docker Compose 기반 로컬·운영 오케스트레이션 실행/
|
||||
|
||||
```bash
|
||||
# 코드만 수정했을 때 (의존성 변경 없음) — backend + db 가 뜬다
|
||||
# (예시 env 의 기본값 COMPOSE_PROFILES=mail 이면 메일 엔진 mail + 개발용 mailpit 도 함께 — 아래 "메일" 절)
|
||||
# (예시 env 의 기본값 COMPOSE_PROFILES=mail 이면 메일 엔진 mail + 개발용 mailpit 도 함께 — 아래 "메일" 절.
|
||||
# 대용량 첨부의 악성 검사기까지 보려면 COMPOSE_PROFILES=mail,clamav 와 CLAMAV_HOST=clamav — 아래 "대용량 첨부" 절)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
|
||||
# 의존성 변경 시에만 --build
|
||||
@@ -32,6 +33,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
|
||||
|
||||
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다 (예시: `https://portal.arasystem.it.kr`).
|
||||
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]). `CORS_ORIGIN`·`TRUST_PROXY` 는 운영 필수다.
|
||||
> 대용량 첨부(11단계)의 `SHARE_PUBLIC_URL`(공개 내려받기 호스트 — 예 `https://files.arasystem.it.kr`, **`CORS_ORIGIN` 에 넣지 않는다**)·`CLAMAV_HOST`(`clamav`)도 운영 필수다.
|
||||
> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다.
|
||||
|
||||
```bash
|
||||
@@ -45,11 +47,16 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
### 같은 주소(origin) 배포 — 입구는 프론트 nginx 하나
|
||||
|
||||
```
|
||||
클라이언트 ─TLS─▶ 외부 리버스 프록시 ─(proxy)─▶ <PROJECT_NAME>-frontend :8080 ─┬─ / 화면(SPA)
|
||||
클라이언트 ─TLS─▶ 외부 리버스 프록시 ─(proxy)─▶ <PROJECT_NAME>-frontend :8080 ─┬─ 포털 호스트 / 화면(SPA) · /api/
|
||||
(포털 · 공개 내려받기 두 호스트) ├─ 공개 호스트 /s/ 화면 · /api/public-links/ 만 (11단계 — SHARE_PUBLIC_URL)
|
||||
└─ /api/ ─(edge)─▶ <PROJECT_NAME>-backend :3000
|
||||
│ (app-network = 메일 엔진의 신뢰 대역)
|
||||
├─▶ db · redis
|
||||
└─▶ mail :8080 (JMAP·관리 API)
|
||||
├─▶ mail :8080 (JMAP·관리 API)
|
||||
│ └─(mail-dns)─▶ mail-resolver ─(mail-dns-egress)─▶ 바깥 DNS
|
||||
└─(clamav)─▶ clamav :3310 (악성 검사 — 대용량 첨부)
|
||||
▲ 서명 볼륨(읽기 전용)
|
||||
clamav-updater ─(clamav-egress)─▶ 서명 서버
|
||||
```
|
||||
|
||||
| 네트워크 | 붙는 컨테이너 | 비고 |
|
||||
@@ -57,6 +64,10 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
| `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 는 붙이지 않는다 |
|
||||
| `<PROJECT_NAME>_mail-dns` | mail · mail-resolver | `MAIL_RESOLVER_SUBNET`(기본 `172.16.73.0/24`) 고정 · `internal`(바깥 길 없음). 리졸버 주소 `MAIL_RESOLVER_IP`(기본 `172.16.73.254`)를 mail 의 `dns` 와 엔진 DNS 가 쓴다 |
|
||||
| `<PROJECT_NAME>_mail-dns-egress` | mail-resolver | 리졸버가 바깥 DNS 서버에 묻는 길. 서브넷은 고정하지 않는다(도커 기본 풀) — 서버의 사설망(VPC)과 겹치지 않는지 본다 |
|
||||
| `<PROJECT_NAME>_clamav` | backend · clamav | `internal`(바깥 길 없음) — 검사기는 바깥에서 온 파일을 여니 바깥 · 다른 컨테이너에 닿지 못하게. clamd 는 평문 TCP 이고 접속을 가리지 않는다 — 다른 컨테이너를 붙이지 않는다. 주소로 믿는 곳이 없어 서브넷은 고정하지 않는다 |
|
||||
| `<PROJECT_NAME>_clamav-egress` | clamav-updater | 서명 갱신(freshclam)이 서명 서버에 묻는 길 — updater 혼자 붙는다(백엔드 · 검사기와 잇지 않는다). 서브넷은 도커 기본 풀 — VPC 와 겹치지 않는지 본다 |
|
||||
|
||||
- 브라우저는 화면과 같은 주소의 `/api` 를 부른다 (docs/mail/api-contract.md 머리말). 프론트 빌드는 `VITE_API_BASE_URL=/api` 로 고정이다.
|
||||
**backend 는 proxy 네트워크에 붙지 않는다** — 바깥에서는 nginx 의 `/api` 로만 들어온다.
|
||||
@@ -70,9 +81,49 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- 본문 한도: `/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` 뿐이다),
|
||||
**대용량 첨부의 조각 `PUT /api/file-uploads/{id}/chunks/{n}` = 17m 고정**(조각 16MiB 는 계약 상수 — 바꾸려면 계약 · 백엔드 · 화면을 함께, 11단계 —
|
||||
이 경로만 오류 로그를 `error` 부터 남긴다: 본문을 임시 파일에 받는다는 warn 이 조각마다 남았다),
|
||||
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
|
||||
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
|
||||
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions` 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
|
||||
- 시간 제한: `/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 은 이 때문이고 오류가 아니다(화면은 다시 잇는다).
|
||||
- **대용량 첨부 (11단계 — 계약 4절 "공개 호스트" · "운영 확인")**: server 가 둘이다.
|
||||
- 포털(`default_server` · `server_name _`)과 공개 내려받기(`server_name` = `SHARE_PUBLIC_URL` 의 호스트 이름). `nginx-env.envsh` 가 그 호스트 이름을
|
||||
`CORS_ORIGIN` 의 호스트 이름들과 견준다(백엔드 `file-sharing.settings.ts` 와 같은 판정 — 포트는 보지 않는다).
|
||||
다르면 공개 server 가 화면 `/s/` · 화면 자산 · `/api/public-links/` **만** 열고(그 밖의 화면 404 · API 404 `RES_001` — 백엔드에 넘기지 않는다) 포털은 그 둘을 404 로 닫는다.
|
||||
비었거나 같으면(개발) 포털이 함께 열고 공개 server 이름은 어느 요청에도 맞지 않는 `share.invalid` 다.
|
||||
- **메일 프로그램 자동 설정 (13단계)**: server 가 하나 더 있다 — `autoconfig.<MAIL_DOMAIN>` · `autodiscover.<MAIL_DOMAIN>` 에 Thunderbird XML(두 경로) · Outlook POX 고정 응답(IMAP 993 · SMTP 465, `text/xml; charset=utf-8`).
|
||||
요청 값(주소 · 쿼리)을 되돌려 쓰지 않고 그 밖의 경로 · `/api` 는 404. 값은 `MAIL_DOMAIN` · `MAIL_HOSTNAME`(`nginx-env.envsh` — 비면 꺼짐, 모양이 틀리거나 포털 · 공개 호스트와 겹치면 기동 중단).
|
||||
엔진의 자동 설정은 공개하지 않은 POP3 를 알리고 입력을 XML 에 되돌려 써서 쓰지 않는다. 전 직원 전환 때 DNS(두 이름 A → 외부 프록시, SRV `_imaps._tcp` · `_submissions._tcp` · 쓰지 않는 것은 "."),
|
||||
외부 프록시 Proxy Host(같은 frontend:8080 · **접근 로그 끔** — 쿼리에 직원 주소)를 만든다. 엔진 인증서의 Additional Hostnames 에는 이 두 이름을 넣지 않는다(mail/README "메일 운영 설정").
|
||||
- 공개 경로 · 오류 location 은 템플릿이 아닌 스니펫이다(이미지의 `/etc/nginx/snippets/` — `${NGINX_*}` 를 쓰지 않는다):
|
||||
`nginx-share-open.conf` · `nginx-share-closed.conf`(server 단계 — 화면 `/s/` 만), `nginx-share-api-open.conf` · `nginx-share-api-closed.conf`(`/api/` 블록 안 — 공개 API, `~*`),
|
||||
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
|
||||
- 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다
|
||||
(기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다.
|
||||
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 65MB 안팎 — 기기 종류 셋(ABI)이 한 파일에 든다)도 같은 줄들이다 —
|
||||
머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고,
|
||||
공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다.
|
||||
머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다.
|
||||
버퍼링을 끈 받기에서 `proxy_read_timeout` 은 돌지 않고, 받는 쪽이 60초 동안 하나도 받지 않으면 `send_timeout`(기본 60s)으로 끊는다 — 느려도 조금씩 받으면 이어진다(2026-10-30 확인).
|
||||
- **동시 연결 상한**(`limit_conn` — 받기가 연결 둘과 백엔드의 파일을 잡으므로): 클라이언트 주소마다 공개 10 · 앱 안 30(사무실 NAT), 서버 전체는 경로마다 따로 공개 400 · 앱 안 200
|
||||
(함께 세면 바깥 IP 몇십 개로 직원의 앱 안 받기까지 막힌다 — 2026-10-30 리뷰).
|
||||
휴대폰 앱 받기는 앱 안 받기의 그릇(`download_app_ip` 30 · `download_app_total` 200)을 함께 쓴다 — 둘 다 로그인한 직원의 받기라 막으려는 것이 같고, 그릇(받기 600 개 — `worker_connections` 셈)을 늘리지 않으려고.
|
||||
판이 나온 날 사무실 휴대폰이 한꺼번에 받다 대용량 받기와 합쳐 주소마다 30 에 닿으면 넘은 받기만 429 다(잠시 뒤 다시 — 처리 중인 요청만 센다).
|
||||
넘으면 429 `REQ_001`(JSON). 클라이언트 주소는 `X-Forwarded-For` 의 **마지막 칸**(외부 프록시가 이어 붙인 값 — 없으면 접속 주소)이다 — 앞 칸을 꾸며도 바뀌지 않는다.
|
||||
8080 에 프록시를 건너뛰고 바로 붙는 길이 있으면 주소마다 한도는 피한다(경로마다 서버 전체 한도는 그대로) — 그 길을 막는 것이 ACG · proxy 네트워크다. IPv6 는 /64 로 묶지 않는다.
|
||||
⚠️ **받기 location 은 `/api/` 블록 바로 안의 대소문자를 가리지 않는 정규식 하나씩이다**(공개 `~* ^/api/public-links/.+/files/`, 앱 안 `~* ^/api/messages/.+/large-files/`,
|
||||
휴대폰 앱 `~* ^/api/app-releases/.+/file` — 끝을 묶지 않는다: 끝의 `/` 같은 꼴도 상한을 타게) —
|
||||
백엔드 라우팅이 대소문자를 가리지 않던 때 `/FILES/` · `/api/Messages/…` 처럼 글자 하나로 상한과 `X-Accel-Buffering` 넘김을 비켜 갔다(2026-10-30 리뷰).
|
||||
같은 블록의 prefix 에 `^~` 가 있으면 그 아래 경로는 블록의 정규식을 건너뛰므로 `/api/messages` 는 `^~` 없이 둔다. id · 토큰 칸은 `.+`(nginx 는 `%2F` 를 풀어 location 을 고른다).
|
||||
포털이 닫는 공개 API 도 `~* ^/api/public-links(?:/|$)` 다. 백엔드도 라우팅이 대소문자를 가린다(두 겹 — [[nestjs]] "전역 HTTP 설정").
|
||||
거절은 접근 로그(429)로만 본다(`limit_conn_log_level info` — 오류 로그에 토큰째 요청 줄이 쏟아지지 않게).
|
||||
- 그릇: 워커마다 `worker_connections 4096`(이미지 기본 1024 — `frontend/Dockerfile` 이 바꾸고 바뀌었는지 grep 으로 확인해 못 바꾸면 빌드 실패) · compose 의 `ulimits nofile 8192`.
|
||||
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
|
||||
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
|
||||
해시가 없는 공개 파일(`frontend/public` → 루트, 예: `favicon.ico`)은 같은 이름으로 바뀌므로 `no-cache`(매번 재검증, 안 바뀌면 304).
|
||||
@@ -86,12 +137,25 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
Referer 를 읽는 코드는 없다 — 출처 검사(`OriginGuard`)는 `Sec-Fetch-Site`·`Origin` 을 본다.
|
||||
새 메일 확인 `GET /api/mail-state`(탭마다 1분에 한 번)는 성공(2xx·304)하면 남기지 않는다 — 로깅 규칙 "반복되는 정상 흐름".
|
||||
실패(401·502 등)는 남긴다 (server 단계 `access_log … if=$log_access` 하나 — `/api/` 블록을 나누지 않는다).
|
||||
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다.
|
||||
실시간 알림 `/api/mail-events` 는 성공도 남긴다 — 연결이 끝날 때 한 줄(브라우저마다 대략 1시간에 한 번)이고 `rt=` 가 이어진 시간이다.
|
||||
시작된 스트림은 어떻게 끝나도 200 이라, 끊김은 짧은 `rt=` 와 오류 로그의 `upstream prematurely closed connection` 으로 본다.
|
||||
**대용량 첨부의 내려받기 토큰도 가린다**(11단계) — 요청 줄 · Referer 의 `/s/<토큰>` · `/api/public-links/<토큰>…` 의 토큰 칸을 `***` 로(대소문자 무관, absolute-form 포함),
|
||||
그리고 경로 모양과 상관없이 토큰 모양(43자 base64url)으로도 — `//s/` · `/%73/` · `/api//public-links/` 같은 변형을 경로 모양만으로는 놓쳤다(2026-10-30 리뷰).
|
||||
토큰을 가진 누구나 파일을 받으므로 로그를 읽는 사람이 받지 못하게 — 백엔드도 `maskUrlForLog()` 로 가린다.
|
||||
오류 로그(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=` 와 같다).
|
||||
- **nginx 가 직접 끝내는 `/api` 오류도 표준 실패 형식(JSON)이다** — 본문 한도 초과 413 `FILE_002`(공개 server 의 1m 넘는 본문 포함), 받기 동시 연결 초과 429 `REQ_001`(11단계),
|
||||
nginx 가 닫은 경로 404 `RES_001`(공개 server 의 그 밖 `/api` · 포털이 닫은 공개 API — 대소문자 변형도), 백엔드 응답 없음 502·시간 초과 504
|
||||
(그리고 500·503) `SYS_001`. 이름 있는 location 은 두 server 가 함께 넣는 `nginx-api-errors.conf` 에 있다 — `error_page` 를 하나라도 적은 location 은 위의 것을 물려받지 않아 셋을 다 적는다. `message` 는 비우고(클라이언트가 코드로 사전 문구를 쓴다) `traceId` 는 nginx 요청 ID 다 (응답 헤더 `X-Request-Id`·로그 `rid=` 와 같다).
|
||||
상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 손대지 않는다 (`proxy_intercept_errors` 꺼짐).
|
||||
- **`/api/internal/` 도 바깥에서 404 다**(12단계 — 엔진만 부르는 웹훅 받는 경로 `POST /api/internal/mail-engine-events`). 스니펫 `nginx-internal-api-closed.conf`(템플릿 아님 — `/etc/nginx/snippets/`)를
|
||||
두 server 의 `/api/` 블록 안 **다른 정규식보다 먼저** 넣는다 — `~* ^/api/internal(?:[/\x5c]|$)` 라 대소문자 · `%XX`(`%5C` 포함) · 겹 `/` · `..` 변형도 걸린다. 엔진은 app-network 로 backend 에 바로 붙어 이 nginx 를 거치지 않는다
|
||||
(백엔드도 접속 주소가 `APP_NETWORK_SUBNET` 인지 · 본문 서명을 본다 — 세 겹). 운영 배포 때 frontend 이미지를 다시 만들어야 스니펫이 들어간다(`up -d --build`).
|
||||
- **요청 대상에 날 `\`(경로 — `?` 앞) · `#` · 0x80 이상 바이트가 있으면 두 server 모두 location 을 고르기 전에 400 `VAL_001` JSON 이다**(12단계 — server 단계 `if ($request_target_rejected)`).
|
||||
백엔드의 Express(parseurl)는 `#` · 0xA0 가 있으면 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꿔, nginx 정규식이 닫은 `/api/internal/` · `/api/health` · 공개 API 가 백엔드에서 열렸다
|
||||
(2026-10-31 리뷰 — 백엔드도 같은 글자를 400 으로 막는다, 두 겹). 쿼리의 `\` 는 받는다(브라우저가 주소창 검색어의 `\` 를 그대로 보낼 수 있고, `url.parse` 는 `?` 앞의 `\` 만 바꾼다).
|
||||
닫는 정규식 셋(`/api/internal` · `/api/health` · 포털의 공개 API)도 `[/\x5c]` 로 넓혔다.
|
||||
- **`/api/health` 는 바깥에서 404 다** (`RES_001` JSON — 없는 경로와 같다). 실패하면 DB 오류 문자열 등 원인이 담기기 때문이다
|
||||
(대소문자·끝 `/` 도 막는다). 내부에서는 백엔드에 직접 묻는다:
|
||||
```bash
|
||||
@@ -103,30 +167,71 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
(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`) 없이 띄우면 일부러 기동을 멈춘다.
|
||||
공개 호스트를 가를 때와 아닐 때 설정이 다르다 — 두 꼴을 다 본다: `-e SHARE_PUBLIC_URL=https://files.example.kr -e CORS_ORIGIN=https://portal.example.kr` 와 둘 다 없이.
|
||||
- **템플릿 값이 잘못되면 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` 요청이 그 경로 하나로 가므로 거부한다
|
||||
- `SHARE_PUBLIC_URL`(11단계): 빈 값(공개 호스트를 가르지 않음) 또는 출처 `http(s)://호스트[:포트]` 만 — 경로 · 끝의 `/` · 끝 점(nginx 의 `$host` 는 끝 점을 떼어 백엔드 판정과 어긋난다) ·
|
||||
점 · 하이픈으로 시작하거나 끝나는 이름 · `;` · 공백 · `@` · 대괄호를 거부한다(`server_name` 에 지시어를 끼워 넣지 못하게). 백엔드 env 검사도 같은 값을 거절한다 — 한쪽만 뜨는 반쪽 배포가 되지 않게
|
||||
- **외부 리버스 프록시 요구사항** (클라이언트 → 외부 프록시 → nginx → 백엔드 — 이 compose 의 운영 경로는 이것뿐이다.
|
||||
frontend 는 호스트 포트를 열지 않고 TLS 도 하지 않는다):
|
||||
frontend 는 TLS 를 하지 않고 호스트 포트도 열지 않는다 — **예외: 프록시가 다른 서버에 있을 때**(NCP 등 — 같은 VPC 의 Nginx Proxy Manager)는
|
||||
선택 파일 `docker-compose.remote-proxy.yml` 이 frontend 를 proxy 네트워크에서 빼고 8080 을 이 서버의 **사설 IP 에만**(`FRONTEND_PRIVATE_BIND`) 게시한다.
|
||||
`.env.production` 의 `COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml` 로 켜서 운영 명령은 그대로 쓴다. 공인 IP 를 사설 IP 로 NAT 하는 곳에서는
|
||||
게시 주소로 바깥을 막지 못하므로 8080 은 방화벽(NCP ACG)이 프록시 서버의 사설 IP 에서만 받게 한다 — 절차 · NPM 설정은 mail/README.md "리버스 프록시가 다른 서버에 있을 때"):
|
||||
- **`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` 등).
|
||||
**프록시가 다른 서버에 있으면** 주소 목록 `<EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP>` 다(같은 VPC 의 다른 서버가 8080 에 바로 붙어 꾸미는 것을 막는다 — 프록시 서버 위의 다른 컨테이너는 같은 IP 로 나가 가를 수 없다).
|
||||
- **요청 수 한도 `THROTTLE_LIMIT`** (운영 확인 항목 — 기본 1분 100회, `THROTTLE_TTL_MS`): 백엔드는 **경로마다 · 클라이언트 IP 마다** 센다 — 사무실이 NAT 하나로 나가면 모든 직원이 경로마다 한 칸을 나눠 쓴다.
|
||||
"직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`, 메일을 열 때마다 부르는 같은 대화 `GET /api/messages/{id}/conversation` — 14단계)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다.
|
||||
외부 이미지 중계 `GET /api/mail-images/{token}`(9단계)만은 라우트에서 IP 마다 1분 600회로 따로 정했다(뉴스레터 한 통에 이미지가 수십 개).
|
||||
- **백엔드의 바깥 연결**(운영 확인 항목, 9단계): 외부 이미지 중계는 backend 컨테이너가 바깥 80 · 443 으로 나가 이미지를 받아 온다 — 호스트 방화벽 · 사내 프록시가 막으면
|
||||
이미지 자리가 비어 보인다(다른 기능은 영향 없음). 사설 · 내부 주소로는 백엔드가 스스로 나가지 않는다(SSRF 막기 — 계약 2절 "외부 이미지 보기").
|
||||
그 막기는 사설 대역에 기댄다 — `APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET` 은 사설 대역으로 둔다(mail setup 은 앱 네트워크만 검사한다).
|
||||
서버 자신의 공인 주소로 풀리는 이름은 막지 않는다(공인 주소라서) — 그 요청은 호스트의 80 · 443 에 닿으므로, 같은 외부 프록시 뒤에 "내부 주소면 허용"하는 다른 사이트를 두지 않는다
|
||||
(메일 엔진의 443 은 setup 이 403 으로 막고, 중계는 응답이 그림일 때만 돌려준다 — 2026-10-29 리뷰).
|
||||
**앱 새 메일 알림(앱 1단계 — FCM)** 도 같은 길로 나간다: backend → `oauth2.googleapis.com:443`(서비스 계정 JWT → access token) · `fcm.googleapis.com:443`(보내기).
|
||||
막히면 휴대폰 알림만 오지 않는다(로그 warn · error — 다른 기능은 영향 없음). **서버 시계가 맞아야 한다**(NTP — `timedatectl` 의 "System clock synchronized: yes") — JWT 의 `iat` 가 틀리면 Google 이 토큰 교환을 거절한다.
|
||||
키(`FCM_SERVICE_ACCOUNT_KEY` — 서비스 계정 JSON 을 통째로 base64 한 줄)는 `backend/.env.{APP_ENV}` 에 둔다(compose 는 바꿀 것이 없다 — `env_file`). 비면 알림만 끄고 뜬다 — 넣었으면 기동 로그 "앱 새 메일 알림: 켬 (Firebase 프로젝트 …)"를 본다.
|
||||
- **본문 한도는 `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 로는 새지 않지만, 새로고침 · 직접 들어온 요청 줄은 외부 프록시가 그대로 받는다.
|
||||
- **대용량 첨부의 공개 내려받기 호스트를 잇는다** (운영 확인 항목, 11단계 — 절차 · NPM 칸은 mail/README.md "대용량 첨부 (11단계 — 인프라)"):
|
||||
- DNS(`files.` A → 외부 프록시 서버) · TLS 인증서 · Proxy Host 를 포털과 **같은 frontend:8080** 으로 — `Host` 를 바꾸지 않고 넘긴다(nginx 와 백엔드가 Host 로 가른다).
|
||||
`X-Forwarded-For` 는 포털과 같게 이어 붙인다(받기 동시 연결 상한이 마지막 칸을 센다).
|
||||
- **이 호스트는 접근 로그를 끈다** — 경로에 내려받기 토큰이 있다(우리 nginx 는 가리지만 외부 프록시는 원문을 남긴다). 오류 로그에도 토큰째 남으니 볼 수 있는 사람을 한정한다.
|
||||
- 받기 응답은 흘려보낸다 — nginx 계열은 우리가 넘기는 `X-Accel-Buffering: no` 를 따른다(SSE 와 같다). 2GB 까지 이어지므로 응답 **전체** 시간 상한이 있으면 없애거나 넉넉히.
|
||||
- 포털 호스트의 본문 한도는 조각 올리기 때문에 **17m 이상**이어야 한다(위 첨부 한도 21m 이면 된다).
|
||||
- **두 호스트 모두 머리글 버퍼를 키운다** — `proxy_buffer_size 16k;` 와 `proxy_busy_buffers_size 16k;` 를 함께(NPM 2.16.0 은 기본 4k · 한 줄만 넣으면 `nginx -t` 가 실패해 그 Proxy Host 가 Offline).
|
||||
긴 한글 이름 파일의 받기 응답 머리글(3.5KB)이 4k 를 넘으면 그 받기만 502 다.
|
||||
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
|
||||
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다,
|
||||
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 65MB 안팎 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./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`.
|
||||
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
|
||||
- **대용량 첨부 파일은 named volume `<PROJECT_NAME>_files`**(`/app/files` — 경로는 compose 가 `FILE_STORAGE_DIR` 로 고정, 루트 env 로 바꾸지 않는다)에 쌓인다. **개발도 named volume** 이다 —
|
||||
바인드 마운트면 2GB 를 Windows 너머로 쓰고 Defender 가 EICAR 시험 파일을 지운다. 그래서 `backend/Dockerfile` 의 builder 단계가 `/app/files` 를 node 소유로 만들어
|
||||
개발 볼륨도 소유자를 물려받는다(2026-10-30 확인). **백업하지 않는다** — 30일짜리 전송물이고 기록은 DB 에 있다(서버를 되살리면 그 전의 링크는 파일이 없어 받지 못한다).
|
||||
디스크는 백엔드가 올리기를 만들 때 여유(`FILE_STORAGE_MIN_FREE_GB`)를 본다 — 같은 디스크를 악성 검사의 임시 파일(아래 "대용량 첨부" 절)이 함께 쓴다.
|
||||
악성 검사 서명 볼륨 `<PROJECT_NAME>_clamav-db`(110MB 남짓)도 백업하지 않는다 — 지워도 clamav-updater 가 다시 받는다.
|
||||
|
||||
---
|
||||
|
||||
@@ -225,11 +330,25 @@ 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 를 쓴다.
|
||||
- `ci.yml` 의 **infra 잡**(compose `config -q` · `docker build --check` · nginx 단계 `nginx -t` · 메일 리졸버 `unbound-checkconf` — compose 의 이미지 · 사용자 101:102)은 잡 컨테이너 안에서 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: "-"` 로 소켓을 막았다면 이 잡만 실패한다.
|
||||
`docker-compose.remote-proxy.yml` 도 이 잡이 본다 — 게시 주소를 준 `config -q` · proxy 네트워크가 빠졌는지 · 값이 없으면 멈추는지, 그리고 `frontend-bind-check` 를
|
||||
`compose run`(프로젝트 이름 `ci-bind-<run_id>`, DB 이미지)으로 받을 값 · 거절할 값을 하나씩 돌린다.
|
||||
11단계부터는 nginx 를 두 꼴(공개 호스트를 가를 때 · 아닐 때)로 `nginx -t` 하고 렌더링 결과(server 이름 · 포털이 닫는지 · 받기 영역 400 · 200)와 틀린 `SHARE_PUBLIC_URL` 의 기동 거부를 본다.
|
||||
"대용량 받기 경로 시험"은 같은 이미지에 가짜 백엔드를 얹어 받기 경로의 대소문자 · `%2F` · 겹 `/` 변형이 모두 상한(429 JSON) · `X-Accel-Buffering` 을 타는지 실제 요청으로 본다
|
||||
(휴대폰 앱 받기 `…/file` 도 — 앱 안 받기와 그릇을 함께 쓰는지 · 공개 호스트는 404 인지 · 정보 경로 `…/latest` 는 타지 않는지. "nginx 설정 검사"는 `limit_conn download_app_*` 가 두 꼴 모두 둘씩인지 센다).
|
||||
"닫는 경로 시험"(12단계)은 같은 가짜 백엔드로 두 server × 닫는 경로 셋(`/api/internal` · `/api/health` · 공개 API)의 변형이 nginx 가 직접 낸 JSON 인지 본다 —
|
||||
대소문자 · 겹 `/` · `%2F` · `%69` · `%5C` · `..` · absolute-form · 끝 `/` 는 404, 날 `\` · `#` · 0xA0 는 400, 쿼리의 `\` 는 통과, `/api/internals` 같은 이웃 경로는 백엔드로 넘어간다
|
||||
(경계 확인은 wget — busybox `nc` 는 표준 입력이 끝나면 닫아 프록시를 거친 응답을 받지 못한다). 정적 검사는 include 와 `if ($request_target_rejected)` 를 지시어로만 센다(주석 처리한 줄은 세지 않는다).
|
||||
13단계부터: 세 조합(운영 · 개발 · remote-proxy)의 서비스 수와 `logging` 수 견주기, `DB_IMAGE` 판 · digest 정규식, 자동 설정 server 의 렌더링 · 값 거절(겹침 포함) · 실제 요청 시험,
|
||||
운영 스크립트(`ops/`) shellcheck(scratch 이미지 — 빌드 컨텍스트로 넘긴다).
|
||||
ClamAV 설정은 compose 의 이미지로 `clamconf` 를 돌려 한도 · 경보 값을 본다 — `clamconf` 는 틀린 항목에도 종료 코드 0 이라 출력의 `ERROR` 를 찾고,
|
||||
`-n`(기본값과 같은 항목을 숨김) 없이 한 번 더 돌려 기본값에 기대는 `SelfCheck = "600"` · `HeuristicScanPrecedence disabled` 를 줄 그대로 확인한다. 이미지 줄은 끝까지 읽는 awk 로 고른다(위 pipefail 규칙).
|
||||
|
||||
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
|
||||
|
||||
@@ -258,7 +377,7 @@ dc --profile redis up -d
|
||||
|
||||
---
|
||||
|
||||
## 메일 (Stalwart · Mailpit) — profile `mail`
|
||||
## 메일 (Stalwart · unbound · Mailpit) — profile `mail`
|
||||
|
||||
직원 로그인 = 메일 계정이라 **예시 env 는 개발·운영 모두 `COMPOSE_PROFILES=mail`** 이다.
|
||||
구조·운영 배포·도메인 전환 절차는 [`mail/README.md`](../../../mail/README.md) 가 SSOT 다.
|
||||
@@ -275,19 +394,112 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
- 백엔드는 `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 이 잠그는 것: 공개 443 의 JMAP·관리 API·웹 포털(→ 403, MTA-STS 정책 파일만 연다), 직원 역할의 계정 목록(Principal) 조회,
|
||||
메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일(→ RCPT 에서 550 — 받아 놓고 위조된 보낸 사람에게 반송하지 않게, mail/README.md "보안").
|
||||
- setup 은 엔진의 받는 쪽 한도 "같은 보낸 도메인 → 한 받는 사람"(기본 시간당 25)을 **인증 없는 세션에만** 건다 — 사내 메일은 보낸 도메인이 같아 동료 모두 합쳐
|
||||
한 사람에게 25통을 넘으면 웹메일 발송이 거절됐다(2026-10-28). 보낸 IP 마다 초당 연결 5개 한도는 그대로. 한도 값 · 직원마다 내보내는 양의 한도는 운영 전에 정한다 (mail/README.md "엔진 한도").
|
||||
신뢰 대역은 고정한 app-network 서브넷(`APP_NETWORK_SUBNET`) 안으로만 받는다 — 더 넓으면 실패한다.
|
||||
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
|
||||
- setup 은 엔진의 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 계정마다 1시간 동안 올린 양, 기본 50MB)를 첨부 합계 한도(`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 백엔드와 같은 계산)의
|
||||
20배(기본 524,288,000바이트) 이상으로 올린다 — 기본값이면 20MB 첨부 세 개로 그 직원의 첨부 올리기 · 필터 · 부재중 저장이 1시간까지 429 `REQ_001` 로 막혔다(2026-10-29).
|
||||
더 크게 둔 값은 내리지 않고, 개수(1,000) · 기간 · 한 번 크기는 그대로다. 첨부 한도 env 를 바꾸면 setup 도 다시 돌린다 (mail/README.md "엔진 한도").
|
||||
- setup 은 SMTP 로 받는 메일 한 통의 크기(`x:MtaStageData` 의 `maxMessageSize`, 기본 100MiB)를 50MiB 로 낮춘다 — 큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 들어
|
||||
메일 컨테이너 1g 로는 바깥 100MB 한 통이 한도에 닿았다(2026-11-03 개발 984/1024MiB · 운영은 106MB IMAP APPEND 로 OOM). IMAP 요청 · 메일 크기 한도(`x:Imap` · `x:Email`)는
|
||||
이전(imapsync) 때 잠시 올릴 수 있어 바꾸지 않고, 기본보다 크면 `[주의]` 만 낸다 (mail/README.md "엔진 한도" · "큰 메일").
|
||||
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소와 리졸버 네트워크(서브넷 · 구성원 · 리졸버 주소 · mail 의 `dns`)를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
|
||||
**운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다.
|
||||
- **메일 전용 DNS 리졸버** `mail-resolver`(unbound — 설정 `mail/resolver/unbound.conf`): 엔진의 DNS 조회(스팸 차단 목록 · SPF · DKIM · DMARC · MX · PTR · TLSA)를
|
||||
공용 · ISP DNS 대신 이 서버에서 직접 푼다 — Spamhaus 는 공용 · ISP DNS 를 거친 조회를 거부한다. DNSSEC 을 검증해 엔진의 DANE 이 켜지고, 조회 이름은 로그에 남기지 않는다.
|
||||
사설 · 내부 주소의 DNS 서버에는 묻지 않는다(`do-not-query-address` — 바깥 도메인이 이름 서버 주소를 사설 IP 로 두어 VPC 안을 두드리게 하지 못하게).
|
||||
setup 이 엔진의 `x:DnsResolver` 를 맞춘다 — 운영은 리졸버에 바로(Custom · UDP+TCP), 개발은 도커 DNS 를 거쳐(System — `mailpit` 같은 도커 이름 때문).
|
||||
사전 확인은 UDP 는 루트 SOA, TCP 는 루트 DNSKEY 를 DNSSEC 과 함께 묻는다 — UDP 로 DNSKEY 를 물으면 루트 키 교체 기간에 답이 잘려(TC) 멀쩡한 리졸버를 실패로 본다(2026-10-30 리뷰).
|
||||
mail 은 리졸버가 healthy 일 때 뜨지만(`depends_on … required: false`) 리졸버가 unhealthy 거나 뜨지 못하면 경고만 내고 mail 을 띄운다 — 리졸버 설정 오류 하나로
|
||||
`up -d mail` 이 옛 mail 을 지운 채 끝나 메일 전체가 서는 것을 막는다(2026-10-30 개발에서 확인). **서버 재부팅 때는 도커가 이 차례를 지키지 않는다** — 재부팅 뒤 `restart mail` 하고 setup 으로 `[확인]` 을 본다(먼저 뜨면 DANE 만 꺼진다).
|
||||
리졸버가 바깥에 닿지 못하는 동안은 **엔진이 설정 리로드를 통째로 거절한다**(스팸 필터의 Pyzor 주소 `public.pyzor.org` 를 같은 리졸버로 풀다 실패) — setup · 관리 화면의 리로드가 모두 막힌다.
|
||||
비상 길은 관리 화면에서 엔진 DNS 를 공용 DNS 로 저장한 뒤 리로드가 아니라 `restart mail`(엔진 DNS 를 System 으로 돌려도 mail 의 `dns:` 가 같은 리졸버라 소용없다) — mail/README.md "리졸버 장애 때".
|
||||
Spamhaus 두 항목(ZEN · DBL)은 `MAIL_SPAMHAUS_DQS_KEY` 가 있으면 시험 항목이 나오는지 본 뒤 DQS(`<키>.zen.dq.spamhaus.net` 등)로 켜고, 없으면 끈다(공개 미러는 소규모 · 비상업 용도라 쓰지 않는다).
|
||||
키는 출력하지 않지만(setup 의 모든 출력이 키를 가린다) 엔진의 스팸 필터 설정에 그대로 저장된다(관리 화면 · 관리 API · 메일 볼륨 백업). 체험 키는 기간이 끝나면 답이 멈추고
|
||||
엔진은 표시 없이 지나간다(fail-open) — 다음 setup 이 끄고 종료 1 로 알리므로 운영은 setup 을 주기적으로 돌려 본다 — mail/README.md "DNS 리졸버와 스팸 차단 목록".
|
||||
- setup 은 엔진 웹훅(`x:WebHook` 하나 — 12단계 메일 프로그램 로그인 기록)을 `MAIL_WEBHOOK_SECRET` 으로 맞춘다: url `http://backend:<BACKEND_PORT>/api/internal/mail-engine-events`(setup 이 `BACKEND_PORT` 도 읽는다),
|
||||
이벤트 `auth.success` · `auth.failed` · `auth.mfa-required` 만(`include` — 기본은 모든 이벤트), `level: trace`, throttle 1초 · discardAfter 5분.
|
||||
0.16.27 의 웹훅에는 이름 칸이 없어 머리글 `X-Arasystem-Setup: [setup.mjs] key=<소금 24hex>.<지문 24hex>` 로 찾는다 — 엔진이 서명 키를 `****` 로만 돌려주므로 키가 바뀌었는지도 이 지문으로 안다.
|
||||
소금은 쓸 때마다 새로 뽑는다(옛 모양은 한 번 맞춘다). 백엔드는 이 머리글을 쓰지 않는다. 키는 사람이 짓지 않는다(`openssl rand -hex 24`).
|
||||
리로드 뒤 메일 컨테이너에서 서명한 빈 묶음으로 받는 곳을 확인한다 — 백엔드 쪽(받는 곳 · 접속 주소 · 키)만 본다(관리 화면에서 서명 키만 바꾼 것은 알아채지 못한다).
|
||||
401 · 404 는 운영에서 실패(개발은 `[주의]`), 닿지 못함 · 5xx 는 `[주의]` 다. 이번 실행에서 키를 바꿨으면 401 을 `[주의]` 로 낮추고 `up -d backend` 뒤 setup 한 번 더를 안내한다
|
||||
(404 안내는 운영 `up -d --build backend frontend` · 개발 `restart backend`). 키를 바꿀 때는 backend 를 먼저 띄우고 setup 을 돌리는 차례를 권한다.
|
||||
운영은 키가 비거나 예시 값이면 아무것도 바꾸기 전에 멈춘다. 개발은 키가 비면 `[주의]` 와 함께 있던 웹훅을 끄고, 예시 값이면 `[주의]` 만 낸다.
|
||||
키는 엔진 설정에 그대로 저장된다(메일 볼륨 백업에 있다 — Spamhaus 키와 같은 성격). 402 를 "비밀번호 맞음"으로 보는 것은 테스트 직원(웹메일에서 2단계를 켰을 수 있다)뿐이다 —
|
||||
게이트웨이가 402 면(OTP 를 켜면 백엔드의 대리 접근이 모두 막혀 웹메일 전체가 선다) 아무것도 바꾸기 전에 멈추고, 관리자도 그렇다.
|
||||
엔진 DNS 가 Custom(운영 — 리졸버)이어도 웹훅은 `backend` 를 푼다 — 메일 컨테이너의 resolv.conf 가 도커 DNS(127.0.0.11)다(2026-10-31 개발에서 Custom 으로 바꿔 확인) (mail/README.md "계정 보안 (12단계 — 인프라)").
|
||||
- setup 은 엔진 관리자를 단단히 한다(13단계): 관리자 역할(System Administrator)의 빼는 권한에 `impersonate` — 관리자 비밀번호로 `직원%관리자` 대리 로그인(바깥 993 · JMAP)이 막힌다(관리 API 는 그대로).
|
||||
영구 관리자 비밀번호의 허용 IP 를 `APP_NETWORK_SUBNET` 으로 — 관리 API 주소가 앱 네트워크 안일 때만 걸고, 건 뒤 다시 로그인해 보고, 막히면 복구 관리자로 되돌린다. **운영에서 처음 걸 때는 복구 관리자가 있어야 한다**(없으면 걸지 않고 종료 1).
|
||||
운영자가 대역 안으로 좁혀 둔 값은 넓히지 않는다. 관리 화면 SSH 터널은 mail 컨테이너의 **앱 네트워크 주소**로 연다(다른 망 주소로 열면 403 — 엔진이 보는 출발 주소가 그 망의 게이트웨이).
|
||||
휴지통 · 스팸메일함 30일(`x:DataRetention.expungeTrashAfter`)과 정리 시각 KST 03:00 · 04:00 · 04:30(컨테이너 UTC 차이를 읽어)도 맞춘다 — 그 메일함에 들어간 때부터 세므로 imapsync 로 옮긴 메일도 옮긴 때부터다.
|
||||
자동 비우기는 특수 메일함("Deleted Items" · "Junk Mail")에만 걸린다 — imapsync 의 `--automap` 이 mailcow 의 Trash · Junk 를 이 둘로 맞춘다(2026-11-02 개발 확인 — 따로 생긴 폴더가 있을 때만 `--f1f2`, mail/README imapsync 절). 이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버".
|
||||
- 운영의 복구 관리자(`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` 에 넣는다.
|
||||
- 사내 목록 계정 `<MAIL_DIRECTORY_LOCAL_PART>@도메인`(기본 `mail-directory`, compose 가 backend 에 넘긴다) — 백엔드가 엔진의 계정 목록에서 이름·주소만 읽는 비밀번호 없는 계정(게이트웨이 대리 접근으로만). 엔진 관리자는 그 권한을 내줄 수 없어 setup 이 **복구 관리자로 돌 때만** 만든다(개발은 늘, 운영은 `--recovery` — 이미 운영 중이면 한 번 더). 없으면 `[주의]` 만 내고 백엔드는 로그인한 직원 기록으로 대신한다 (mail/README.md "사내 목록 계정").
|
||||
- **파일럿 공존**(10단계 — mail/README.md "파일럿 공존"): 루트 env `MAIL_PILOT_LEGACY_HOST`(옛 서버 mailcow 주소)·`MAIL_PILOT_LEGACY_IPS`(그 서버가 보내는 IP)를 넣으면
|
||||
setup 이 새 서버에 없는 사내 주소를 옛 서버로 중계하게 한다. 켜는 차례는 막는 것부터 — 시범 표시(옛 서버 IP 의 자동 차단 예외 — 아래 식들의 주인 표시) →
|
||||
받는 쪽 가드(인증 없는 모르는 사내 주소 550)·위조 막기·옛 서버 IP 의 검사 · 한도 예외 → 리로드 → **켜기 전 가드 확인** → 경로(리로드) →
|
||||
도메인의 `allowRelaying`(저장 즉시 먹어 가드보다 먼저 켜면 열린 중계가 된다) → 켠 뒤 확인. 끌 때는 거꾸로(`allowRelaying` 먼저) — 운영은 값을 비우고 `--end-pilot` 일 때만.
|
||||
**가드 없는 relay 를 setup 이 만들지 않는다** — 가드 확인은 "2초 안에 550"이다(가드 거절은 곧바로 · 엔진 자체 거절은 `waitOnFail` 5초 뒤라 응답 시간으로 가른다 — 그래서 `waitOnFail` 은 4초 이상).
|
||||
켜기 전에 아니면 켜지 않고, 켠 뒤에 아니면 relay 를 바로 다시 끈다(둘 다 종료 코드 1). 끄지 못하면 "끄지 못했습니다"로 멈춘다 — 이미 켜진 relay 를 처음 확인할 때(0단계)는
|
||||
멈추기 전에 RCPT 가드만 다시 걸고 그 결과를 싣는다. "대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다"면 열린 relay 는 아니고(relay 는 켜진 채),
|
||||
"열려 있습니다 · 열려 있을 수 있습니다"면 관리 화면에서 그 도메인의 relay 허용을 바로 끈다.
|
||||
가드는 `allowRelaying` 이 꺼진 것을 엔진에서 다시 읽어 확인한 뒤에만 뺀다 — 켜져 있는데 setup 의 `legacy` 경로가 없으면
|
||||
끄지 않고 가드도 남긴 채 종료 코드 1(요약 `끝내지 못함`, 시범 설정이 하나도 없으면 `시범 설정 없음 · allowRelaying 켜짐`).
|
||||
개발은 셸로 한 번 시험한다(`MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs` — 다음 평소 실행이 끈다.
|
||||
엔진 로그에서 개발 외부 경로 `mailpit` 과 가르려면 컨테이너 이름 `<PROJECT_NAME>-mailpit:1025` 를 준다).
|
||||
운영 확인 항목: 새 호스트 → mailcow **25** 가 열려 있을 것(운영은 25 만 받는다), 파일럿 동안 mx1 에 AAAA 를 두지 않을 것(SPF 는 ip4 만), mailcow 가 보내는 IP 가 목록과 같을 것(빗나가면 동료 메일이 `550 5.7.1`),
|
||||
그 IP 로 사람이 만든 자동 차단 예외(`x:AllowedIp`) · 검사 예외(SPF · DMARC 등) · IP 한도 예외가 없을 것(setup 이 시범 표시를 붙여 만든다 — 있으면 멈춘다),
|
||||
새 DKIM TXT 를 **켜기 전에** DNS 에 넣을 것(운영은 처음 켤 때 TXT 가 없거나 다르면 켜지 않는다),
|
||||
끄기 · 되돌리기 전에 새 서버 대기열에 동료 앞 메일이 남지 않았을 것(끈 뒤에는 로컬 실패 → 반송).
|
||||
개발의 인증 발송 확인(465)은 백엔드 컨테이너의 node 로 돈다(메일 이미지에는 node 가 없다 — 비밀번호는 표준 입력으로 넘기고 출력하지 않는다).
|
||||
- ⚠️ **게이트웨이 비밀번호 = 관리자급 비밀.** Stalwart 0.16 은 대리 접근 대상을 제한하지 못해, 신뢰 대역 안에서는 게이트웨이로 관리자 계정도
|
||||
대리 접근된다 (setup 이 매번 `[주의]` 로 보여 준다). 막는 일은 백엔드가 한다 — mail/README.md "보안".
|
||||
|
||||
---
|
||||
|
||||
## 대용량 첨부 — 악성 검사기 (ClamAV) · 운영은 늘, 개발은 profile `clamav`
|
||||
|
||||
백엔드가 다 올린 파일을 clamd 의 INSTREAM(TCP 3310)으로 흘려보내 판정을 받는다 (계약 4절 "악성 검사", `backend/src/modules/file-storage/clamav-client.ts`).
|
||||
설정은 `mail/clamav/`, 판정 실측표 · 메모리 예산 · 문제 해결은 [`mail/README.md`](../../../mail/README.md) "대용량 첨부 (11단계 — 인프라)".
|
||||
|
||||
- **컨테이너 둘**: `clamav`(clamd — 백엔드와만 닿는 internal 네트워크 하나, 서명 볼륨은 읽기 전용)와 `clamav-updater`(freshclam — 자기 바깥 길에만).
|
||||
바깥에서 온 파일을 여는 쪽과 바깥에 나가는 쪽을 가른다 — 파일 해석기에 구멍이 나도(2026년에만 heap overflow CVE 여럿) 바깥 · 다른 컨테이너에 닿지 못하게.
|
||||
새 서명은 clamd 가 10분마다 스스로 본다(`SelfCheck 600` — updater 가 알려 주지 않는다). clamd 는 서명이 생긴 뒤 뜬다(처음 기동은 받는 1분 남짓을 기다린다).
|
||||
- **개발**: 기본은 꺼져 있고 `CLAMAV_HOST` 가 비면 백엔드는 검사하지 않고 다 올리자마자 "검사하지 못함"이다. 켜려면 루트 env 에
|
||||
`COMPOSE_PROFILES=mail,clamav` 와 `CLAMAV_HOST=clamav` **둘 다**(clamd 1GB 남짓 · 처음에 서명 110MB). 운영은 `CLAMAV_HOST` 가 비면 백엔드가 뜨지 않는다.
|
||||
- **설정 `mail/clamav/clamd.conf`** — 이미지 기본 설정을 통째로 바꾼다(바꾼 뒤 `up -d --force-recreate clamav`):
|
||||
- `StreamMaxLength` = `MaxFileSize` = 2047M(한 파일 한도의 최대값 — 넘는 스트림은 `INSTREAM size limit exceeded. ERROR` → 백엔드는 설정 오류로 error), `MaxScanSize` 4094M,
|
||||
`MaxScanTime` 5분(1.4 에서는 ZIP 에만 걸린다 — 7z · tar.gz 는 MaxScanSize 까지 시간 상한 없이 푼다).
|
||||
- **`AlertExceedsMax yes` · `AlertEncrypted yes` 는 끄지 않는다** — 끄면 한도를 넘거나 암호가 걸린 파일이 `stream: OK`(깨끗함)로 온다(확인함). 백엔드는 이 두 휴리스틱을 "검사하지 못함"으로 가른다.
|
||||
- `ConcurrentDatabaseReload no` — 켜면 새 서명을 다 읽을 때까지 두 벌(2GB 남짓)을 들어 한도 2g 를 넘는다. 대신 다시 읽는 30초 안팎 동안 검사 · PING 이 기다린다(백엔드는 다시 한다).
|
||||
- 받은 스트림 · 푼 내용은 컨테이너 쓰기 계층의 `/tmp` 에 잠시 쓴다 — 동시 검사 2 × (2GB + 푼 내용) — tmpfs 로 바꾸지 않는다(메모리 한도에 잡힌다).
|
||||
검사 중에 죽으면(OOM 등) 그 임시 파일이 쓰기 계층에 남아 재시작마다 쌓였다(2026-10-30 확인) — 그래서 entrypoint 가 `sh -c 'rm -rf /tmp/clamav-*; exec clamd …'` 다.
|
||||
- **자원**: clamd 메모리 1GB 안팎(서명 363만 개 — 서명이 늘면 는다) · 한도 2g, CPU 는 검사 하나가 코어 하나(2,047MB 에 5분 안팎 — 개발 PC) · `cpus 2.0` · `cpu_shares 512`
|
||||
(서버가 2 vCPU 보다 적으면 낮춘다). updater 는 평소 수십 MB, 처음 받을 때 0.7GB 남짓 · 한도 1g.
|
||||
- **상태**: clamav 의 healthcheck 는 PING(다시 읽는 동안 세 번까지 기다린다), updater 는 daily 서명이 이틀 안에 바뀌었는지 — 갱신이 이틀 넘게 멈추면 unhealthy(검사는 옛 서명으로 이어 간다).
|
||||
- 이미지는 Alpine 판이라 **linux/amd64 만** 있다 — ARM 서버면 `_base-debian`(같은 판)을 쓴다. 1.4 LTS 가 끝나면(2027-08 무렵) 서명을 받지 못한다 — 그 전에 다음 LTS 로.
|
||||
|
||||
---
|
||||
|
||||
## 백업 · 감시 (호스트 systemd — compose 밖, 13단계)
|
||||
|
||||
서버가 스스로 지키고 되살리는 일은 compose 가 아니라 호스트의 systemd 가 돌린다. 설치 · 복원 차례 · 알림 대응은 [`mail/README.md`](../../../mail/README.md) "백업" · "감시"가 SSOT 다.
|
||||
|
||||
- **자리**: 저장소의 `ops/` 를 서버의 `/opt/arasystem-ops` 로 `install -m 0644` 해 root 소유 사본으로 돌린다(저장소 폴더를 그대로 쓰지 않는다). 설정 · 비밀은 `/etc/arasystem/ops.env` · `restic-password`(root 0600) — [[env-structure]] 의 예외.
|
||||
- **유닛 셋**: `arasystem-backup.timer`(매일 03:30 KST · 부팅 뒤 따라잡기), `arasystem-check.timer`(15분 점검), `arasystem-mail-recover.service`(부팅 때 — 백업 표시가 남았으면 엔진을 띄운다, `Before=arasystem-backup.service`). 모두 `Wants=` + `After=docker.service`.
|
||||
- **백업**(restic → Object Storage, 보존 일 7 · 주 4 · 월 6, 일요일 prune · check 5%): 메일 볼륨은 켜 둔 채 미리 올리고(warm — restic 종료 3 은 받아들임) → 엔진을 잠깐 멈춰(9초 안팎) 바뀐 것만(final — 상한 `BACKUP_FINAL_TIMEOUT` 15분) → 다시 띄운다.
|
||||
DB `pg_dump -Fc`(+ `pg_restore -l` 확인) · 업로드 볼륨 · env 둘. 어떻게 끝나도 엔진을 다시 띄운다(EXIT trap + `ExecStopPost`, 멈추는 중 신호면 멈춤을 끝낸 뒤). 비밀 사본(env · 덤프)은 끝날 때 지운다.
|
||||
**매일 03:30 에 웹메일이 10초 안팎 `MAIL_001`** 이다(엔진 멈춤). 백업과 복원 연습은 같은 잠금 — 잠긴 동안 손으로 부른 엔진 띄우기는 건너뛴다(final 도중 띄우면 일관되지 않은 스냅숏이 final 이 된다).
|
||||
- **복원**(`arasystem-restore.sh` — snapshots · check · drill · files · volumes · db): final 태그 스냅숏만, 쓰는 컨테이너가 있거나 볼륨이 비지 않았으면 멈춘다(운영 볼륨을 덮지 않게), 새 서버는 `.env.production` 없이 ops.env 의 `PROJECT_NAME` 으로 files 부터. 한 달에 한 번 `drill`(mail/README "백업 › 복원" 이 기준).
|
||||
- **15분 점검**: 디스크 두 곳 · 컨테이너 health · 백엔드 health · 메일 대기열 · 인증서 셋 · 백업 나이(26시간 이상) · ClamAV 서명 · Spamhaus 키 → Healthchecks.io(정상 · 실패 + 까닭 한 줄). 바깥은 UptimeRobot(포털 HTTPS · mx1:993 · mx1:25) — 알림은 우리 메일 서버가 아닌 주소로.
|
||||
- **비-root 예외**: restic 보조 컨테이너는 root 로 돌되 `--cap-drop ALL` + 필요한 권한만(읽기 `DAC_READ_SEARCH`, 복원은 `DAC_OVERRIDE` · `CHOWN` · `FOWNER`) · `no-new-privileges` ([[security]]). 연습 엔진(Stalwart)을 `no-new-privileges` 로 띄우려면 `NET_BIND_SERVICE` 가 든다.
|
||||
- 비밀은 명령줄에 싣지 않는다 — `docker exec -e 이름` · 표준 입력 · 읽기 전용 마운트, Healthchecks 주소도 `curl -K -`.
|
||||
|
||||
## 규칙
|
||||
|
||||
- **이미지 버전 고정 — 떠 있는 태그(`stable-alpine`·`v0.16`·`24-alpine`)를 쓰지 않는다.** 그날의 최신이 들어오면 호스트마다 버전이 달라지고,
|
||||
@@ -297,8 +509,17 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
| --- | --- | --- |
|
||||
| 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 처음 설치·재실행과 웹메일을 다시 확인한다 |
|
||||
| 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 처음 설치·재실행과 웹메일을 다시 확인한다. 앱 알림의 N 은 `Email/query` 의 `after` 가 그 초를 넣지 않는 것에 맞춰 한 초 앞을 넘긴다(`jmap-mail.provider.ts` 의 `inboxUnreadSinceFilter` — 2026-11-08 확인) — 올리면 이것도 다시 본다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 |
|
||||
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
|
||||
| unbound (메일 리졸버) | `klutchell/unbound:1.26.2@sha256:50cbeb32…` | `docker-compose.yml` 의 `mail-resolver`, `mail/README.md` 「DNS 리졸버와 스팸 차단 목록」의 이미지 칸(판 · digest 앞부분), 이 문서. 커뮤니티 이미지라 **digest 까지** 묶는다 — 올릴 때는 `imagetools inspect` 의 manifest list digest 로 함께 바꾼다. CI 의 unbound 설정 검사는 compose 의 이미지를 읽어 쓴다 |
|
||||
| ClamAV (악성 검사 — 11단계) | `clamav/clamav:1.4.6_base@sha256:e7399106…` | `docker-compose.yml` 의 `clamav` · `clamav-updater`(같은 값 둘), `mail/README.md` "악성 검사 (ClamAV)"의 이미지 칸, 이 문서. LTS(1.4 — 2024-08 부터 3년)만 쓴다 — 같은 날 나온 1.5.4 대신 고른 까닭: 지원 기간, 1.5 의 새 ZIP 코드에서 나온 CVE-2026-20337. 공식 이미지지만 digest 까지 묶는다(CI 의 `clamconf` 검사는 compose 의 이미지를 읽어 쓴다) |
|
||||
| PostgreSQL (db — 13단계부터 고정) | `postgres:15.19-alpine3.24@sha256:54e0dd8e…` | 루트 예시 env 둘의 `DB_IMAGE`(각자의 로컬 env 도), CI 의 `DB_IMAGE` 정규식, 이 문서. `docker-compose.remote-proxy.yml` 의 `frontend-bind-check` 가 같은 이미지를 빌려 쓴다. 15.19 는 2026-08-13 보안 릴리스, 이 digest 는 2026-10-21 alpine 재빌드라 운영 반영은 11-04 뒤. **메이저(15)를 올리려면 덤프 · 복원** — 데이터 형식이 다르다. db · redis 의 watchtower 표시는 뺐다(판은 사람이 올린다) |
|
||||
| restic (백업 — 13단계) | `restic/restic:0.19.1@sha256:136600b6…` | `ops/backup/arasystem-backup.sh` · `arasystem-restore.sh` 의 `RESTIC_IMAGE` 두 곳, 이 문서 |
|
||||
| shellcheck (CI — 13단계) | `koalaman/shellcheck:v0.11.0@sha256:61862eba…` | `.github/workflows/ci.yml` |
|
||||
| imapsync (전환 때 서버 B 호스트에서 — 2026-11-02) | `gilleslamiral/imapsync:2.326@sha256:ece980cb…` | `mail/README.md` 「imapsync」 절의 명령. 저자 공식 이미지 · amd64 만 · 비-root(`nobody`). 인증서를 기본으로 확인하지 않는다(`SSL_verify_mode=0`) — 운영 명령은 `--sslargs1/2 SSL_verify_mode=1` 꼴(README) |
|
||||
| Redis (선택 — 기본 꺼짐) | `redis:7-alpine`(아직 떠 있는 태그 — 기존 드리프트) | 켤 때 판 · digest 로 고정하고 이 표에 줄을 둔다 |
|
||||
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
|
||||
| Flutter SDK (CI · 개발 PC — 이미지가 아니다) | `3.41.9` stable (Dart 3.11.5 · 2026-04-30) | `.github/workflows/ci-flutter.yml` 의 `flutter-version`, `flutter/pubspec.yaml` 의 `environment.sdk`(Dart 하한) · `intl`(새 SDK 의 flutter_localizations 가 고정한 판), `flutter/README.md`, [[flutter]] "Flutter 판", 이 문서. 개발 PC 의 Flutter(`C:\src\flutter`)는 다른 프로젝트도 함께 쓴다 — 같은 마이너의 패치 3.41.9 는 2026-11-06 사용자 확인 뒤 올렸고, 마이너(3.47 등)를 올리는 것은 사용자가 정한다(올리면 개발 PC 와 CI 를 함께) |
|
||||
|
||||
- **갱신 규칙**(공통): 일반 패치는 **배포 후 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).
|
||||
@@ -319,6 +540,8 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
24.22.1 은 2026-10-20 보안 릴리스라 보안 릴리스 규칙으로 바로 고정했다.
|
||||
- 올린 뒤에는 운영 이미지 두 개 빌드, CI 와 같은 명령(아래 "의존성은 컨테이너 안에서"처럼 임시 폴더 복사본에서 lint·type-check·test·build),
|
||||
dev 재빌드(`dc up -d --build -V`)로 확인한다.
|
||||
- **로그 보존 — 모든 서비스에 `logging` 앵커**(json-file · max-size · max-file · compress — 13단계). 기본 10m×5, mail · frontend · backend 는 20m×10. 앵커는 파일을 넘지 못해 dev · remote-proxy 는 자기 앵커를 둔다.
|
||||
새 서비스에도 붙인다 — CI 가 세 조합의 서비스 수와 logging 수를 견준다. 로그 한도는 컨테이너를 다시 만들어야 걸린다.
|
||||
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
|
||||
- **의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.**
|
||||
dev 는 `/app/node_modules` 를 익명 볼륨으로 마운트하는데, `--build` 만으로는 **예전 볼륨이 그대로 재사용된다.**
|
||||
@@ -357,6 +580,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
`nest-cli.json` 의 `deleteOutDir` 는 마운트 지점을 지우려다 EBUSY 로 죽으므로, dev `command` 는 기동 때 볼륨 내용만 비우고
|
||||
`deleteOutDir` 만 끈 설정 사본으로 `start:dev` 를 돌린다 (`-V` 로 재생성하면 이미지의 산출물로 다시 채워진다).
|
||||
- **dev backend 메모리 한도는 2g** — watch 컴파일과 타입 검사가 함께 돌아 base 의 1g 에서 OOM 으로 죽었다 (운영은 1g 그대로).
|
||||
- **db 메모리 한도는 1g · 공유 버퍼(`shared_buffers`) 256MB** (14단계 — 메일 검색 색인의 찾기는 표 · 색인이 메모리에 있어야 1초 안이다, 캐시가 비면 3~8배).
|
||||
공유 버퍼는 compose 의 db `command`(`postgres -c …` — 명령줄 값이 데이터 폴더의 설정보다 이긴다)이고 개발도 같은 값이다(dev 오버라이드가 덮어쓰지 않는다).
|
||||
8GB 서버의 한도 합 · 운영에 올리는 차례는 mail/README.md "메모리 예산".
|
||||
- **dev backend 는 컨테이너를 기동할 때 한 번 `migration:run` 을 한다** (`dc down -v` 등으로 DB 를 새로 만든 뒤에도 바로 뜨게).
|
||||
watch 재시작은 이 단계를 다시 돌지 않는다 — 그래서 생성만 하고 검토 전인 마이그레이션이 파일을 저장할 때마다 적용되지는 않는다.
|
||||
⚠️ 다만 **컨테이너를 다시 띄우면** 그때 있는 마이그레이션이 적용된다. 마이그레이션을 고치는 중(revert → 수정 → run)에는
|
||||
@@ -369,12 +595,15 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
그 접속까지 믿게 될 위험이 커지고, `/28` 보다 좁으면 컨테이너 주소가 모자란다 (근거: `setup.mjs` 의 `APP_NETWORK_PREFIX_RANGE`).
|
||||
- 기본값은 도커 기본 주소 풀 밖이라 자동 배정 네트워크와 겹치지 않지만, 같은 호스트의 다른 프로젝트가 같은 값을 쓰면
|
||||
`up` 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 mail setup 을 다시 실행한다 (`EDGE_NETWORK_SUBNET` 도 같다).
|
||||
- 메일 리졸버 네트워크 `mail-dns` 도 서브넷(`MAIL_RESOLVER_SUBNET`, 기본 `172.16.73.0/24`, 사설 대역 `/24`~`/29`)과 리졸버 주소(`MAIL_RESOLVER_IP`, 끝 쪽 — 도커는 mail 에 앞쪽부터 준다)를 고정한다.
|
||||
mail 의 `dns` 와 엔진 설정이 IP 로만 받기 때문이다 — 바꾸면 둘을 함께 바꾸고 setup 을 다시 실행한다. 리졸버만 붙는 `mail-dns-egress` 는 고정하지 않아 도커 기본 풀에서 받는다 —
|
||||
운영 서버의 사설망(VPC)이 그 대역(172.17~31 · 192.168)이면 겹치지 않는지 본다.
|
||||
⚠️ 서브넷을 처음 고정하거나 바꿀 때, 네트워크 구성을 바꿀 때는 **`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` 대상이라 **비어 있어도 존재해야 한다**
|
||||
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다** (백엔드 고유 시크릿 — `JWT_SECRET`, 앱 알림을 쓰면 `FCM_SERVICE_ACCOUNT_KEY` — 매일 백업에 함께 든다)
|
||||
복사 후 `change-me-*` placeholder 값을 반드시 교체한다 ([[security]]).
|
||||
- **Graceful shutdown**: backend 는 `init: true`(tini)로 PID 1 을 두어 SIGTERM 이 애플리케이션까지 전달되게 하고,
|
||||
`stop_grace_period: 30s` 동안 처리 중인 요청을 마치도록 한다.
|
||||
@@ -389,9 +618,18 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
- **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` 로만).
|
||||
운영 frontend 도 열지 않는다 — 프록시가 다른 서버에 있을 때만 8080 을 사설 IP 에 연다(`docker-compose.remote-proxy.yml` 의 `frontend-bind-check` 가 사설 IPv4:포트가 아니면 frontend 를 띄우지 않는다).
|
||||
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`.
|
||||
- **메일 리졸버**: 이미지가 root 로 떠서 권한을 내리는 꼴이라 처음부터 `user: "101:102"`(이미지의 unbound 사용자) · `cap_drop: ALL` · `no-new-privileges` 로 띄운다.
|
||||
53 에 붙는 것은 도커가 컨테이너마다 `net.ipv4.ip_unprivileged_port_start=0` 으로 두기 때문이다. 호스트 포트는 어떤 환경에서도 열지 않는다(열린 리졸버가 되지 않게).
|
||||
- **악성 검사기(clamav · clamav-updater)**: 이미지의 `/init` 은 root 로 소유자를 바꾸고 둘을 함께 띄운다 — 쓰지 않고 `entrypoint` 로 clamd · freshclam 을 바로,
|
||||
`user: "100:101"`(이미지의 clamav 사용자) · `cap_drop: ALL` · `no-new-privileges` · `init: true`. 설정 파일은 그 사용자가 읽을 수 있어야 한다(644). 호스트 포트는 어떤 환경에서도 열지 않는다.
|
||||
- **Git Bash 에서 컨테이너 안 경로를 인자로 주지 않는다** — MSYS 가 `/dev/null` 같은 인자를 Windows 경로로 바꿔 `docker exec … wget -O /dev/null` 이
|
||||
저장소에 `nul` 파일을 만든다(2026-10-30). `MSYS_NO_PATHCONV=1` 을 앞에 붙이거나 PowerShell 로 돌린다.
|
||||
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`
|
||||
(프록시가 다른 서버에 있으면 `.env.production` 의 `COMPOSE_FILE` 로 `docker-compose.remote-proxy.yml` 을 더한다 — compose 가 `--env-file` 의 `COMPOSE_FILE` · `COMPOSE_PROFILES` 를 읽는다.
|
||||
빈 `COMPOSE_FILE=` 은 compose 가 실패하니 쓰지 않을 때는 주석으로 둔다).
|
||||
|
||||
+165
-100
@@ -3,34 +3,63 @@ name: flutter
|
||||
description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null Safety, feature-first 디렉터리 구조, 작은 위젯 분리, 반응형 UI, Riverpod 상태관리, Dio 인터셉터, flutter_secure_storage 토큰 저장. flutter/ 에서 작업하거나 위젯·프로바이더·모델·API 클라이언트를 만들 때 사용한다.
|
||||
---
|
||||
|
||||
> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다.
|
||||
> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다. 앱 로그인의 근거는 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 (앱 전용 토큰 경로)", 화면 기준은 `docs/design/app/README.md`, 개발 실행 · 배포 준비 메모는 `flutter/README.md` 다.
|
||||
> 사용법: `/flutter [요청 내용]` 또는 flutter/ 작업 시 자동 적용.
|
||||
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]]
|
||||
> Flutter 는 docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립 관리한다 ([[env-structure]] 참조).
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 기본 제공 골격
|
||||
## 기본 골격
|
||||
|
||||
> 이 템플릿은 네트워크 계층을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
> 네트워크 · 세션 · 라우터 · 모듈 틀이 이미 있다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 직원 휴대폰에 설치한 뒤에는 못 바꾼다: 식별자가 다르면 다른 앱이라 그 위에 설치되지 않는다), 패키지 `arasystem_app`.
|
||||
|
||||
| 제공 항목 | 위치 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| Dio 클라이언트 | `lib/core/api/dio_client.dart` | 싱글톤, baseUrl=dotenv `API_URL`(fallback 로컬), 타임아웃 |
|
||||
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(코드 매핑) — 인라인 |
|
||||
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}` 정규화 예외. 인터셉터가 `DioException.error` 에 실어준다 |
|
||||
| 앱 뿌리 | `lib/main.dart` · `lib/app.dart` | main: dotenv → 글꼴 라이선스 → 토큰 보관 준비(첫 실행 지우기) → 기기 정보(판 · User-Agent) → `ProviderScope(overrides)`. app: `MaterialApp.router` · 한국어만(`locale: ko` · `GlobalMaterialLocalizations.delegates`) · 전역 `scaffoldMessengerKey` |
|
||||
| **모듈 목록** | `lib/core/modules/app_modules.dart` | **짝: 웹 `frontend/src/shared/constants/app-modules.ts`** — 같은 id · 이름 · 차례(한쪽을 바꾸면 둘 다). 항목마다 id · 이름 · 아이콘 · 첫 화면 경로(`/<id>`) · 켜짐. 웹은 준비 중 모듈을 꺼진 채 보이지만 앱은 **켜진 모듈만** 탭이 된다(첫 판: 메일 · 설정) |
|
||||
| 모듈 라우트 묶음 | `lib/features/<모듈>/<모듈>_routes.dart` → `lib/core/router/module_registry.dart` | 모듈마다 `ModuleRoutes`(`core/modules/module_routes.dart` — 탭 안 라우트 · 알림 id → 경로 · 설정 절) 하나. registry 의 exhaustive switch 가 id → 묶음. 켰는데 묶음이 없으면 라우터를 만들 때 `StateError` |
|
||||
| 라우터 · 탭 막대 | `lib/core/router/` — `app_router.dart` · `app_shell.dart` · `session_redirect.dart` · `app_routes.dart` | go_router. 로그인 전 · 제한 단계(`/start` · `/login/...`)는 셸 밖, 로그인 뒤는 `StatefulShellRoute.indexedStack`(켜진 모듈마다 탭 하나 — 탭마다 이동 기록을 지킨다). 아래 탭 막대(`NavigationBar`)가 웹의 전역 앱 레일이다. 읽기 · 쓰기 같은 전체 화면은 `parentNavigatorKey: rootNavigatorKey`. `redirect` 하나(`resolveSessionRedirect` — 순수 함수)가 세션 상태로 화면을 고르고, 모르는 · 꺼진 모듈의 주소는 메일 첫 화면(`onException` 도) |
|
||||
| **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고, 그 뒤 서버 logout 과 이 기기의 알림 멈추기(알림창 지우기 · FCM 등록 풀기)를 함께 보낸다(서로 기다리지 않는다). 제한이 풀리면 쥔 비밀번호를 지운다 |
|
||||
| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 |
|
||||
| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<queries>` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 |
|
||||
| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 |
|
||||
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). **보이는 것이 없는 본문이면 본문 칸(WebView)을 두지 않는다**(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부가 위 절반에서 잘렸다): 판정은 정화 길이 함께 낸다(`SanitizedMailBody.hasVisibleContent` — 남긴 글 가운데 `core/utils/visible_text.dart` 의 `hasVisibleText`(공백 · 제어 문자 · Default_Ignorable(제로폭 · 방향 표시 · 한글 채움 등) · 점자 빈칸은 보이지 않음)인 글, 주소가 남은 그림(`_attributesOf` 가 src 를 맨 앞에 쓰는 모양에 기댄다 — 외부 그림은 받기 전에도), 글 없이 그려지는 `hr` · `li` · `details` · `q`. 꾸밈(style · bgcolor · border)만은 치지 않는다. 고르는 본문(`displayedHtmlBody`)은 그대로) → 상태 `bodyEmpty` → 머리의 최대 높이가 화면 전체(`kReaderHeadMaxFraction` 대신 — 그림 받는 진행 막대 자리만 뺀다 · 0 아래로 내려가지 않게), 아래 막대가 없는 메일(예약)은 머리 스크롤에 시스템 아래 여백을 더한다. 문서를 만들기 전(`document == null`)은 지금처럼 본문 자리의 진행 줄. 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
|
||||
| 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) |
|
||||
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies`. **칸 줄**은 `ComposeFieldRow`(`widgets/compose_recipient_field.dart`) 하나 — 칸 이름 72 · 글자 15 · 간격 6 · 여백 4 4 4 16, 칸 이름 · 오른쪽 단추는 첫 줄(`ComposeFieldRow.lineHeight` 48)의 가운데(받는 사람은 `firstLineHeight` 로 칩 줄 40 을 넘겨 칩이 여러 줄이어도 첫 칩 줄에 맞춘다), 아래 선은 `foregroundDecoration` 이라 초점으로 굵어져도 칸이 움직이지 않는다. 입력칸은 `borderlessInputDecoration`(2026-11-12 — 아래 함정). 본문 칸 최소 높이는 `composeBodyMinHeightOf`(원본 메일이 있으면 150 · 없으면 240 — 서명과 상관없다, 보드 App1Compose) |
|
||||
| **메일 검색 (`App1Search` — 1차 · `App1SearchConditions` — 2차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `search_conditions`(2차 — **짝: 웹 `mail-search.ts` · `mail-vacation.ts`** — 조건 다듬기 · 합친 낱말 수 · 기간 · after/before(기기 시간대 0시 → UTC, 끝 날짜는 다음 날 0시) · 강조 쿼리 · "나") · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar`(+ 공용 `LineBreaksToSpacesFormatter`) · `mail_search_states` · `highlighted_text` · `mail_search_conditions_sheet`(2차)) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 조건(2차)은 화면 상태 `MailSearchState.conditions` 로 이어 찾기 · 새로고침 · 다시 시도 · 찾을 곳 바꾸기에 이어지고, "나"는 `mailSelfProvider`. 시트 가림막은 테마의 바닥 시트 가림막(`AppColors.backdrop` — 옮기기 시트도), 날짜 창은 `showDatePicker(barrierColor:)` · 머리 22(`datePickerTheme`). 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` |
|
||||
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기 · 로그인 없는 앱 설정용(언래핑만 — `publicDioProvider`. 인증 인터셉터는 요청을 한 줄로 세워 앞 요청의 미리 갱신을 기다리게 하므로, 앱을 띄우자마자 묻는 앱 설정은 줄 밖으로 보낸다). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
|
||||
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
|
||||
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 |
|
||||
| ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) |
|
||||
| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 보유 상태에서 401 → `AUTH_002` 승격 + 토큰 삭제 |
|
||||
| 토큰 저장 | `flutter_secure_storage`, key 상수 `kJwtTokenKey` | `dio_client` 에서 사용 |
|
||||
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 |
|
||||
| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 |
|
||||
| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 |
|
||||
| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 |
|
||||
| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 |
|
||||
| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값. 테두리 없는 입력칸(칸 줄이 아래 선 · 초점을 그리는 쓰기 화면 · 검색 막대)은 `borderlessInputDecoration` — 테마의 입력칸 테두리를 물려받지 않는다 |
|
||||
| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
|
||||
| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 |
|
||||
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `<API_URL 의 출처>/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 |
|
||||
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · `/settings/release-notes`(+ `/:noteId`) · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · 업데이트 내역(목록 → 글, 아래 행) · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
|
||||
| **업데이트 내역 (`ReleaseNotes` B — 2026-11-10)** | `lib/features/settings/` — data(`models/release_note.dart` · `repositories/release_notes_repository.dart`) · presentation(`pages/release_notes_page.dart` · `release_note_page.dart` · providers `release_notes_providers.dart` · `release_notes_list_controller.dart` · `release_note_controller.dart` · widgets `release_notes_parts.dart` · 글 `release_notes_texts.dart`), 달력 날짜 `core/utils/calendar_date.dart`(`CalendarDate`) + `korean_format.dart` 의 `koreanCalendarDate`, 시험 도구 `test/support/release_notes_fakes.dart`(`FakeReleaseNotesBackend` · 보드의 네 글 `kBoardReleaseNotes`) | 계약 7절(`GET /api/release-notes?cursor=&limit=` · `GET /api/release-notes/{id}` — 올리는 API 없음, 글은 서버의 글 파일). 설정 › 앱 정보의 앱 버전 다음 줄 → 목록 `/settings/release-notes` → 글 `/settings/release-notes/:noteId`(둘 다 `parentNavigatorKey: rootNavigatorKey`, 글은 목록 라우트의 자식이라 주소로 바로 열어도 아래에 목록). 목록은 `releaseNotesListProvider`(autoDispose — 화면을 열 때마다 새로 받는다 · 첫 쪽 · [다시 시도]는 `silent`, [더 보기]는 알림 + 받은 줄 그대로 · 겹친 id 는 건너뜀), 글은 `releaseNoteProvider(id)`(autoDispose family · `silent` — 없는 글 404 `RES_001` 은 [목록으로], 그 밖은 [다시 시도]). 둘 다 `releaseNotesAccountProvider`(세션의 주소 — 메일의 `mailAccountProvider` 와 같은 규칙, 모듈끼리 import 하지 않아 따로)를 듣고 세션이 바뀌면 처음부터 · 세대(`_seq`)와 시작할 때의 Ref 로 늦은 응답을 버린다. 응답 모양이 틀리면 `ReleaseNotesMalformedResponse`(`SYS_001` — 인터셉터를 다 지난 뒤라 [더 보기]만 컨트롤러가 `errorNotifierProvider` 로 알린다, 메일 `DownloadInterrupted` 와 같은 결). **날짜는 `CalendarDate` 로만**(`YYYY-MM-DD` 를 글로 나눠 쥔다 — `DateTime` 으로 바꾸지 않는다, 없는 날은 모양 틀림). 다른 글 줄은 `context.pushReplacement`(쌓지 않는다 — 뒤로는 늘 목록, go_router 17 은 void 를 돌려준다). 글은 서버 글 그대로(마크다운 해석 · 링크 없음) · 한글 줄바꿈 `KeepAllText`(시험은 `findCopy`). [더 보기]는 받는 동안 누름만 무시한다(초점 지키기 — 메일 검색 [더 찾기]와 같다) — 도는 표시가 있어 시험은 `pumpAndSettle` 대신 `pump(100ms)` |
|
||||
| 불러오기 상태 틀 | `lib/shared/widgets/load_states.dart` · `load_state_texts.dart` | 가운데 상태 `StateLayout`(아이콘 원 72 · 제목 · 글 · 단추) · 받지 못함 `LoadErrorView`(사전 문구 · 문의 코드 · [다시 시도] — `RES_001` + `onLeave` 면 `leaveLabel` 단추, 둘은 함께 준다) · 뼈대 `LoadingSkeleton`(화면 낭독기 한 줄 + 막대는 읽지 않음) · `SkeletonBar`(메일은 모서리 4, 업데이트 내역은 `rounded`) · `traceCodeOf`. 2026-11-10 메일 목록(`mail_list_states.dart` 의 `MailStateLayout` · `MailListError` · `_SkeletonBar`)에서 모양 · 동작 그대로 옮겼다 — 메일 목록 · 검색 · 읽기 · 쓰기와 업데이트 내역이 쓴다. 메일 줄 꼴의 뼈대(`MailListSkeleton`) · 빈 메일함 · 목록 끝 · 30일 줄은 메일에 남았다. `MailListTexts.retry` · `traceCode` 는 `LoadStateTexts` 를 가리킨다 |
|
||||
| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 |
|
||||
| 예제 feature(참조용) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남겼다. 앱 API 에 `/users` 는 없다 |
|
||||
| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) |
|
||||
|
||||
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
|
||||
> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))`
|
||||
> - **앱 구조**: 라우터 도입 시 `MyApp` 을 `app.dart` 로 분리, `core/{constants,theme,storage}` 채움, 공통 위젯은 `shared/widgets/`
|
||||
> - **토큰 저장 추상화(선택)**: 필요 시 `core/storage/` 래퍼로 분리
|
||||
> - **권장 패키지(선택)**: `go_router`·`freezed`·`json_serializable`·`cached_network_image`·`fpdart` 도입 시 `pubspec.yaml` 추가
|
||||
> - **새 모듈 붙이는 법**(웹 [[vue3]] "새 사내 모듈"에 해당): `core/modules/app_modules.dart` 의 항목을 켜고(`isEnabled`) → `features/<모듈>/<모듈>_routes.dart` 에 `ModuleRoutes`(첫 라우트 경로 = `/<모듈>`)를 만들고 → `core/router/module_registry.dart` 의 switch 에 그 묶음을 적는다. 탭 · 주소 · 알림 · 설정 절은 저절로 붙는다. 웹 목록도 함께 본다(짝)
|
||||
> - **모듈끼리 서로 import 하지 않는다.** 함께 쓰는 것(사람 고르기 · 파일 첨부 · 날짜 표기 등)은 `core/` · `shared/` 에 둔다. 세션(`features/auth`)은 모듈이 아니라 모두가 쓴다. 다른 모듈의 설정 화면에 절을 더할 때는 `ModuleRoutes.settingsSections` 로 내놓기만 한다
|
||||
> - **주소는 모듈 이름부터**(`/mail/...` · `/settings/...` · 나중 `/calendar/...`). 깊은 링크 · 푸시도 모듈 이름으로 고른다
|
||||
> - **상태관리 · 복제할 패턴**: `features/auth` 의 `data/models`(손으로 쓴 모델) → `data/repositories`(`dioProvider` 의 Dio) → `presentation/providers`(타입을 적은 Riverpod 프로바이더 · 노티파이어)를 복제한다. 목록은 `features/user` 의 커서 페이지 예제. import 는 `package:arasystem_app/...`
|
||||
> - **화면 글**: 디자인 README 「화면 문구 — 사용자에게 말하듯」. 모듈 화면의 글은 그 모듈 안(`features/<모듈>/presentation/<모듈>_texts.dart`), 로그인 단계는 `core/constants/screen_titles.dart`
|
||||
> - **모델 생성기**: `freezed` · `json_serializable` · `riverpod_generator` 는 아직 넣지 않았다 — build_runner 가 같은 폴더의 `error_lexicon.g.dart`(자동 생성 사전)를 자기 출력으로 보고 지울 수 있다. 넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고 한 번 돌려 사전이 남는지 확인한다
|
||||
> - **권장 패키지(선택)**: `cached_network_image`·`fpdart`·`webview_flutter`(받은 메일) 도입 시 `pubspec.yaml` 추가. 새 패키지는 배포 2주가 지난 판만 ([[security]] "의존성")
|
||||
|
||||
### Flutter 판
|
||||
|
||||
**개발 PC 와 CI 를 같은 판으로 고정한다 — 지금 `3.41.9` stable (Dart 3.11.5).** 함께 고치는 곳: `.github/workflows/ci-flutter.yml` 의 `flutter-version` · `flutter/pubspec.yaml` 의 `environment.sdk` 와 `intl`(`flutter_localizations` 가 SDK 마다 정확한 판으로 고정한다 — 지금 `0.20.2`, 새 SDK 의 `packages/flutter_localizations/pubspec.yaml` 값으로) · `flutter/README.md` · [[docker]] 이미지 버전 표의 "Flutter SDK" 줄. CI 는 `flutter pub get --enforce-lockfile` 로 lock 그대로 받는다.
|
||||
개발 PC 의 Flutter 는 다른 프로젝트도 함께 써서 그 판에 맞췄다 — 3.47 로 올리는 것은 사용자가 정한다(올리면 개발 PC · CI 를 함께, 2주 규칙).
|
||||
|
||||
---
|
||||
|
||||
@@ -47,26 +76,36 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
```
|
||||
lib/
|
||||
├── main.dart
|
||||
├── app.dart ← MaterialApp / 라우터 설정
|
||||
├── core/
|
||||
│ ├── constants/ ← 상수 (색상, 크기, 텍스트 등)
|
||||
│ ├── theme/ ← 앱 테마 정의
|
||||
│ ├── api/ ← Dio 인스턴스(dio_client), 에러 사전(error_lexicon), 인터셉터
|
||||
│ ├── storage/ ← flutter_secure_storage 래퍼 (선택)
|
||||
│ └── utils/ ← 순수 유틸 함수
|
||||
├── app.dart ← MaterialApp.router · 한국어 · 테마 · 전역 알림 자리
|
||||
├── core/ ← 모듈 공통 (모듈은 core · shared 만 함께 쓴다)
|
||||
│ ├── api/ ← Dio(dio_client) · 인터셉터 셋 · 토큰 갱신 · 에러 사전 · User-Agent
|
||||
│ ├── constants/ ← 앱 이름 · 로그인 단계 화면 이름 · 앱 판
|
||||
│ ├── modules/ ← 모듈 목록(app_modules — 웹과 짝) · 라우트 묶음 꼴(module_routes)
|
||||
│ ├── app_config/ ← 앱 설정(최소 판 · 알림을 보낼 수 있는가)
|
||||
│ ├── app_download/ ← [새 버전 받기] — 포털의 휴대폰 앱 받기 화면 주소 · 런처 · 글 (업데이트 화면 · 설정이 함께 쓴다)
|
||||
│ ├── push/ ← 새 메일 알림(FCM · 채널 · 서버 등록 맞추기) · 알림 → 경로
|
||||
│ ├── router/ ← 라우터 · 탭 막대 · redirect · 모듈 묶음 모으기(module_registry)
|
||||
│ ├── storage/ ← 토큰 보관 · 보안 저장소 · 첫 실행 표시
|
||||
│ ├── theme/ ← 색 · 간격 · 글자(임시) · 글꼴
|
||||
│ └── utils/ ← 순수 유틸 함수 (시계 등)
|
||||
├── features/
|
||||
│ └── user/
|
||||
│ ├── data/
|
||||
│ │ ├── models/ ← JSON 직렬화 모델
|
||||
│ │ └── repositories/ ← API 호출 구현체
|
||||
│ ├── domain/
|
||||
│ │ └── entities/ ← 순수 비즈니스 엔티티
|
||||
│ └── presentation/
|
||||
│ ├── pages/ ← 화면 단위 위젯
|
||||
│ ├── widgets/ ← 재사용 위젯
|
||||
│ └── providers/ ← 상태 관리
|
||||
│ ├── app_update/ ← 업데이트 화면 (모듈이 아니다 — 서버의 최소 판보다 낮을 때)
|
||||
│ ├── auth/ ← 세션 · 앱 로그인 (모듈이 아니다 — 모든 모듈이 쓴다)
|
||||
│ ├── mail/ ← 모듈 — 주소 /mail/...
|
||||
│ │ ├── mail_routes.dart ← 이 모듈의 라우트 묶음 (core/router 가 모은다)
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── models/ ← JSON 모델 (손으로 — 위 "모델 생성기")
|
||||
│ │ │ └── repositories/ ← API 호출 구현체
|
||||
│ │ ├── domain/
|
||||
│ │ │ └── entities/ ← 순수 비즈니스 엔티티
|
||||
│ │ └── presentation/
|
||||
│ │ ├── pages/ ← 화면 단위 위젯
|
||||
│ │ ├── widgets/ ← 재사용 위젯
|
||||
│ │ └── providers/ ← 상태 관리
|
||||
│ ├── settings/ ← 모듈 — 주소 /settings/... (다른 모듈의 설정 절을 받아 붙인다)
|
||||
│ └── user/ ← 예제(참조용)
|
||||
└── shared/
|
||||
└── widgets/ ← 앱 공통 위젯 (생성 완료)
|
||||
└── widgets/ ← 앱 공통 위젯 (자리 화면 · 확인 창 · 불러오기 상태 틀 등)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -133,103 +172,109 @@ Expanded(flex: 1, child: RightPanel()),
|
||||
|
||||
## 상태 관리
|
||||
|
||||
```dart
|
||||
// Riverpod 예시 (권장)
|
||||
@riverpod
|
||||
Future<User> fetchUser(FetchUserRef ref, int id) async {
|
||||
final repo = ref.watch(userRepositoryProvider);
|
||||
return repo.getUser(id);
|
||||
}
|
||||
생성기(`riverpod_generator` · `@riverpod`)는 쓰지 않는다 — 프로바이더는 타입을 적어 손으로 만든다(위 "모델 생성기").
|
||||
|
||||
```dart
|
||||
// 프로바이더 — 타입을 적는다 (features/user 예제 · features/auth 와 같은 꼴)
|
||||
final Provider<UserRepository> userRepositoryProvider = Provider<UserRepository>(
|
||||
(Ref ref) => UserRepository(ref.watch(dioProvider)),
|
||||
);
|
||||
|
||||
final FutureProviderFamily<UserModel, int> userProvider =
|
||||
FutureProvider.family<UserModel, int>(
|
||||
(Ref ref, int id) => ref.watch(userRepositoryProvider).getUser(id),
|
||||
);
|
||||
|
||||
// 위젯에서 사용 — 실패는 [다시 시도](ref.invalidate)로 다시 한다(저절로 다시 하지 않는다)
|
||||
class UserTile extends ConsumerWidget {
|
||||
const UserTile({required this.userId, super.key});
|
||||
|
||||
final int userId;
|
||||
|
||||
// 위젯에서 사용
|
||||
class UserWidget extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userAsync = ref.watch(fetchUserProvider(userId));
|
||||
return userAsync.when(
|
||||
data: (user) => Text(user.name),
|
||||
final AsyncValue<UserModel> user = ref.watch(userProvider(userId));
|
||||
return user.when(
|
||||
data: (UserModel value) => Text(value.name),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, _) => const ErrorWidget(),
|
||||
error: (Object error, StackTrace _) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Riverpod** 권장 (Provider는 레거시로 간주)
|
||||
- **Riverpod 3** — 세션처럼 바꾸는 일이 있는 상태는 `AsyncNotifier`/`Notifier`(`sessionProvider` 가 본보기)
|
||||
- **저절로 다시 하기는 끈다**: Riverpod 3 은 프로바이더의 `retry` 를 주지 않으면 실패한 프로바이더를 기본으로 10번까지 다시 만든다(요청을 다시 보낸다).
|
||||
앱은 ProviderScope(main.dart) · 시험의 ProviderContainer 에 `noAutomaticRetry`(`core/utils/provider_retry.dart`)를 건다 — 다시 하기는 화면의 [다시 시도]가 한다
|
||||
- `StatefulWidget`은 애니메이션, 폼 등 로컬 UI 상태에만 제한적 사용
|
||||
- 실패를 상태(`AsyncValue`)에 두어도 된다 — 비밀이 든 요청(`ApiOptions.secret` — 로그인 · 코드 · 비밀번호 단계 · logout)의 실패 예외는 본문을 비워 던지고, 인증 인터셉터가 내보내는 모든 실패 예외는 Bearer 를 지운 채 온다
|
||||
|
||||
---
|
||||
|
||||
|
||||
## API 통신 (Dio + 인터셉터)
|
||||
|
||||
실제 구현(`lib/core/api/dio_client.dart`)은 싱글톤 + `InterceptorsWrapper` 인라인 방식이다. 인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
|
||||
앱 로그인은 앱 전용 토큰 경로다(계약 `docs/mail/api-contract.md` 1절 "앱 로그인" — `POST /api/auth/app/login` · `/app/refresh` · `/app/logout`, 보호 API 는 `Authorization: Bearer`).
|
||||
세션 규칙(1회용 회전 · 30초 유예 · 끊은 세션 바로 막기 · 제한된 세션)은 웹과 같고 토큰을 나르는 길만 다르다.
|
||||
|
||||
```
|
||||
화면 · 저장소 ── ref.watch(dioProvider)
|
||||
│
|
||||
▼ 인터셉터 줄 (core/api/dio_client.dart)
|
||||
ResponseInterceptor {success,data}→data · 실패는 DioException.error = ApiException
|
||||
AuthInterceptor QueuedInterceptor — Bearer · 미리 갱신 · 401 → TokenRefresher(한 번에 하나) → 한 번 다시 · AUTH_010 → 다시 맞추기(renewed 면 한 번 다시)
|
||||
ErrorNotifyInterceptor 맨 끝 — silent 가 아니면 ErrorNotifier(스낵바 · 같은 코드 3초)
|
||||
│ 갱신 · 다시 보내기는 언래핑만 있는 안쪽 Dio 로 (같은 줄을 다시 타지 않게)
|
||||
▼
|
||||
SessionEventBus ──▶ 세션 노티파이어 (세션 끝 → 로그인 화면 · AUTH_010 → 세션 확인(안쪽 Dio) · 갱신 → RestrictionCheck 로 답 · 갱신 응답의 제한)
|
||||
```
|
||||
|
||||
```dart
|
||||
// core/api/dio_client.dart (실제 구조 요약)
|
||||
class DioClient {
|
||||
DioClient._internal() {
|
||||
// flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값
|
||||
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
// jwt_token 읽어 Authorization 헤더 첨부
|
||||
final token = await _storage.read(key: kJwtTokenKey);
|
||||
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||
handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
// success==true → data 언래핑, false → ApiException 으로 reject
|
||||
handler.next(response);
|
||||
},
|
||||
onError: (err, handler) async {
|
||||
// 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
|
||||
// 토큰 보유 상태의 401 은 AUTH_002(세션 만료)로 승격하고 토큰을 삭제한다.
|
||||
handler.next(err.copyWith(error: await _toApiException(err)));
|
||||
},
|
||||
));
|
||||
}
|
||||
// 저장소 — 파싱만 한다. 문구를 만들지 않는다
|
||||
final Response<Map<String, Object?>> response = await _dio.get<Map<String, Object?>>(
|
||||
'/mail-state',
|
||||
options: ApiOptions.silent(), // 요청마다 알림 끄기 — Options(extra: {kSilentExtraKey: true}) 와 같다
|
||||
);
|
||||
|
||||
// 화면 — code 로 가른다
|
||||
try {
|
||||
await ref.read(sessionProvider.notifier).signIn(email: email, password: password);
|
||||
} on DioException catch (e) {
|
||||
if (apiErrorCodeOf(e) == 'AUTH_004') showTopError();
|
||||
}
|
||||
```
|
||||
|
||||
> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다.
|
||||
- 인터셉터를 더할 때는 위 차례를 지킨다 — 알림은 늘 맨 끝(갱신 · 다시 보내기가 끝난 결과만 알린다).
|
||||
- 세션 노티파이어를 인터셉터가 직접 부르지 않는다(노티파이어가 Dio 를 쓰므로 서로 물린다) — `SessionEventBus` 로 알린다.
|
||||
- 갱신을 새로 부르는 곳(제한을 푼 직후 등)도 `TokenRefresher`(`AuthRepository.refresh`)를 지난다 — 두 갱신이 겹치면 늦은 쪽이 "이미 쓴 토큰"이다.
|
||||
|
||||
---
|
||||
|
||||
## 보안 (토큰 저장)
|
||||
|
||||
```dart
|
||||
// flutter_secure_storage 사용
|
||||
const storage = FlutterSecureStorage();
|
||||
|
||||
// 저장 (키 이름은 dio_client 의 상수로 통일: kJwtTokenKey == 'jwt_token')
|
||||
await storage.write(key: kJwtTokenKey, value: token);
|
||||
|
||||
// 읽기
|
||||
final token = await storage.read(key: kJwtTokenKey);
|
||||
|
||||
// 삭제 (로그아웃)
|
||||
await storage.deleteAll();
|
||||
// 토큰은 TokenStore 로만 다룬다 (core/storage/token_store.dart)
|
||||
await tokenStore.save(tokens); // 리프레시 토큰 → 보안 저장소(kRefreshTokenKey), 액세스 토큰 → 메모리
|
||||
final String? refresh = await tokenStore.readRefreshToken();
|
||||
await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser 없이 — 늦은 401 알림을 가른다)
|
||||
```
|
||||
|
||||
- JWT, 민감 정보는 반드시 `flutter_secure_storage` 사용
|
||||
- 키 문자열을 여기저기 적지 말고 `kJwtTokenKey` 상수를 쓴다 ([[coding-common]])
|
||||
- `SharedPreferences`에 토큰 저장 **금지**
|
||||
- 리프레시 토큰 · 민감 정보는 `flutter_secure_storage`(`SecureStore`)에만, 액세스 토큰은 메모리에만 둔다
|
||||
- 키 문자열을 여기저기 적지 말고 `kRefreshTokenKey` 상수를 쓴다 ([[coding-common]])
|
||||
- `SharedPreferences` 에 토큰 저장 **금지** — 첫 실행 표시(참 · 거짓 하나)만 둔다
|
||||
- 로그인 때 넣은 비밀번호는 Riverpod 상태에 두지 않는다(`LoginSecret` — 메모리 · 10분)
|
||||
- 비밀번호 · 토큰은 로그 · 예외 문장에 남기지 않는다 — `LogInterceptor` 를 넣지 않고, `AuthTokens.toString()` 은 값을 가린다
|
||||
- 추가 보안 규칙은 [[security]] 참조.
|
||||
|
||||
---
|
||||
|
||||
## 성능 & 최신 트렌드 추가 규칙
|
||||
|
||||
- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델)
|
||||
- **모델 직렬화**: 지금은 손으로 쓴 불변 모델(`final` 칸 + `fromJson`/`tryParse` — `features/auth/data/models`). `json_serializable` · `freezed` 는 위 "모델 생성기" 주의를 지킨 뒤에 넣는다
|
||||
- **이미지**: `cached_network_image`로 네트워크 이미지 캐싱
|
||||
- **목록**: 긴 목록은 `ListView.builder` 사용 (전체 렌더링 금지)
|
||||
- **에러 핸들링**: `Either<Failure, Success>` 패턴 고려 (`fpdart` 패키지)
|
||||
- **라우팅**: `go_router` 사용 권장 (딥링크, 중첩 라우팅 지원)
|
||||
- **라우팅**: `go_router` (도입 완료 — `core/router/`). 새 화면은 그 모듈의 라우트 묶음에 더한다
|
||||
- **환경 분리**: `--dart-define` 또는 `flutter_dotenv`로 dev/prod 분리
|
||||
|
||||
---
|
||||
@@ -241,17 +286,37 @@ await storage.deleteAll();
|
||||
- [ ] **Null Safety**: `dynamic` 금지, `?`/`!`/`late` 정확히 사용.
|
||||
- [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지.
|
||||
- [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`.
|
||||
- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 리포지토리에서 try/catch 로 메시지를 만들지 않는다.
|
||||
- [ ] **에러 분기**: `e.error as ApiException` 의 **`code` 로 분기**한다. 문구는 사전에서만 온다.
|
||||
- [ ] **API**: `dioProvider` 의 Dio 를 쓴다(새 클라이언트 · 인터셉터 차례를 만들지 않는다). 리포지토리에서 try/catch 로 메시지를 만들지 않는다. 화면이 칸에 보이는 실패는 `silent` 로 끈다.
|
||||
- [ ] **에러 분기**: `apiErrorCodeOf(e)`(또는 `e.error as ApiException`)의 **`code` 로 분기**한다. 문구는 사전에서만 온다. `AUTH_010` 은 화면이 다루지 않는다(세션이 그 단계로 옮긴다).
|
||||
- [ ] **에러 사전**: `error_lexicon.g.dart` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
|
||||
- [ ] **목록**: 응답은 `{items, pageInfo}` — `UserRepository.getUsers()` 패턴 재사용 ([[response-format]]).
|
||||
- [ ] **토큰**: `flutter_secure_storage` + 키 상수 `kJwtTokenKey` 통일. `SharedPreferences` 저장 금지.
|
||||
- [ ] **토큰**: `TokenStore` 로만(리프레시 = `flutter_secure_storage` 의 `kRefreshTokenKey`, 액세스 = 메모리). `SharedPreferences` 저장 금지.
|
||||
- [ ] **상태관리**: 새 기능은 Riverpod 프로바이더/노티파이어로 작성 (`ProviderScope` 는 기본 제공).
|
||||
- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성. 공통 위젯은 `lib/shared/widgets/` 에 둔다(이동 완료).
|
||||
- [ ] **권장 패키지**: 모델(`freezed`/`json_serializable`)·라우팅(`go_router`) 도입 시 `pubspec.yaml` 추가 후 `flutter pub get`.
|
||||
- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과.
|
||||
CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
|
||||
- [ ] **모듈**: 새 기능은 그 모듈 폴더(`features/<모듈>/`)와 라우트 묶음 안에. 모듈끼리 import 하지 않는다 — 함께 쓰는 것은 `core/` · `shared/`. 모듈 목록을 바꾸면 웹 `app-modules.ts` 도(짝).
|
||||
- [ ] **화면**: 캔버스로 확인받은 뒤 만든다 — 그때까지 자리 화면(`PlaceholderPage`). 글자 크기는 `textTheme` 역할 이름으로만, 누르는 곳은 48dp 이상.
|
||||
- [ ] **권장 패키지**: 새 패키지는 배포 2주가 지난 판만, `pubspec.yaml` 추가 후 `flutter pub get`. 모델 생성기를 넣을 때는 위 "모델 생성기" 주의(build.yaml `generate_for`).
|
||||
- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. 배포 판(release)은 우리 서명 키(`android/key.properties` — 저장소 밖 keystore)와 `google-services.json` 이 있고 `flutter/.env` 의 `API_URL` 이 운영 포털의 https 주소일 때만 만든다 — 하나라도 아니면 Gradle 이 까닭을 알리고 멈춘다(debug 키로 조용히 서명하지 않고, 개발 주소가 든 판이 나가지 않게 — 알리는 글에 주소 값은 싣지 않는다, `flutter/README.md` "사내 배포"). 만들면 release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다는 것도 본다.
|
||||
Kotlin(`MainActivity.kt`)을 고쳤으면 커밋 전에 `flutter build apk --debug` 로 컴파일을 본다 — CI 는 안드로이드를 빌드하지 않고,
|
||||
Kotlin 쪽 짝(고른 파일의 미리 거르기 · 이름 규칙)에는 시험이 없다(Dart 가 판정을 다시 하고, 어긋남은 앱 README 의 실기기 목록으로 본다).
|
||||
Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다.
|
||||
CI(`ci-flutter.yml`)는 **flutter/ 와 앱 짝 시험이 읽는 백엔드 파일 둘(`attachment.policy.ts` · `test/search-text-samples.ts`)이 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
|
||||
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
|
||||
- [ ] **위젯 · 시험 함정**(쓰기 · 검색 화면에서 찾음 — 2026-11-07):
|
||||
`TextEditingController` 는 초점 · 선택만 바뀌어도 알린다 — 바꿈은 글을 견줘 가른다.
|
||||
go_router 의 `context.pop()` 은 `NavigatorState.pop` 이라 PopScope 의 `canPop` 을 보지 않는다 — 화면이 스스로 닫을 때는 자기 표시로 가린다.
|
||||
`ref` 는 `State.dispose` 에서 쓰지 못한다 — 노티파이어를 필드에 쥔다(`initState` 에서 `listenManual` + `read`).
|
||||
되풀이 타이머(남은 시간 막대 250ms)는 `pumpAndSettle` 간격(100ms)보다 길게 둔다. isolate 를 쓰는 일(원문 인용)은 위젯 시험에서 프로바이더로 덮어쓴다(`composeQuoteBuilderProvider`).
|
||||
소스에 보이지 않는 글자를 두지 않는다 — nbsp 는 `'\u00A0'` 이스케이프로 적는다.
|
||||
기기 파일 입출력(고르기 · 복사본)은 시험 시계 안에서 끝나지 않는다 — `PickedFileStore` 를 덮어쓴다(`FakePickedFiles`).
|
||||
무한 진행 막대(고르는 동안)가 떠 있으면 `pumpAndSettle` 이 끝나지 않는다 — 그동안은 `pump()` 로 넘긴다.
|
||||
가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다.
|
||||
InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄).
|
||||
두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`).
|
||||
go_router 17 의 `context.pushReplacement` · `replace` 는 void 다(`push` 만 Future) — 바꿔 끼울 화면은 `pushReplacement`(새 page key — 앞 화면의 PageStorage 스크롤 자리를 물려받지 않는다, 업데이트 내역의 다른 글 줄).
|
||||
짜임을 둘로 가르는 화면(읽기 화면의 본문 칸 있음 · 없음)은 두 짜임에 같은 자리의 같은 위젯을 두고 값(`ConstrainedBox` 의 maxHeight)만 바꾼다 — `Flexible` · `Expanded` 로 바꿔 끼우면 그 아래 State(펼친 받는 사람 등)가 새로 만들어진다. 시험은 위치로 본다(머리 스크롤 칸의 아래 끝 = 아래 막대의 위 끝).
|
||||
`InputDecoration.collapsed` 는 테두리 없는 칸이 아니다 — `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두면 TextField 가 그 칸을 테마의 `inputDecorationTheme`(둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는 `border` 보다 그 칸들을 먼저 쓴다(2026-11-12 — 쓰기 화면의 칸마다 글자를 감싼 테두리). 칸 줄이 선을 따로 그리는 칸은 `borderlessInputDecoration`(`core/theme/app_theme.dart` — 여섯 테두리 모두 없음 · 여백 0)을 쓴다. `lib/` 에 `InputDecoration.collapsed(` 가 들어오면 시험(`test/core/theme/borderless_input_decoration_test.dart`)이 실패한다. 칸의 테두리는 `test/support/input_borders.dart` 의 `expectBorderlessInput`(평소 · 초점 · 꺼짐마다 InputDecorator 가 받은 꾸밈)으로 본다.
|
||||
화면을 그림으로 견줄 때(임시 시험 — 저장소에 남기지 않는다): `setUpAll` 에서 `FontLoader('Pretendard')` 에 `assets/fonts/Pretendard-*.otf` 넷을, `FontLoader('MaterialIcons')` 에 SDK 의 `bin/cache/artifacts/material_fonts/materialicons-regular.otf` 를 올리고(그러지 않으면 글자가 네모), 맨 위 repaint boundary(RenderView)의 `OffsetLayer.toImage(paintBounds)` 를 `tester.runAsync` 안에서 PNG 로 쓴다. 화면 크기는 `MailAppHarness.pump(size:)`.
|
||||
화면 아래 끝까지 닿는 스크롤(읽기 화면의 빈 본문 + 아래 막대 없는 예약 메일 등)은 `MediaQuery.paddingOf(context).bottom` 을 스크롤 끝 여백으로 더한다 — Scaffold 는 bottomNavigationBar 가 있을 때만 몸통의 아래 padding 을 0 으로 만든다. 시험은 `tester.view.padding = FakeViewPadding(bottom: 48 * 3)`(물리 픽셀 — 시험대는 3배) + `addTearDown(tester.view.resetPadding)`.
|
||||
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
|
||||
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
|
||||
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
|
||||
|
||||
+109
-26
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -26,16 +26,25 @@ REDIS_PORT=6379
|
||||
# 서브넷을 고정한다 — 메일 엔진은 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 에 붙지 않는다)
|
||||
# MAIL_RESOLVER_SUBNET : mail ↔ 메일 전용 DNS 리졸버(mail-resolver) 전용 네트워크 mail-dns — 바깥 길이 없는 internal (사설 대역, /24 ~ /29)
|
||||
# MAIL_RESOLVER_IP : 그 안의 리졸버 주소(고정) — 엔진의 DNS 와 mail 컨테이너의 dns 가 이 주소다. 서브넷의 끝 쪽에 둔다
|
||||
# (도커는 mail 에 앞쪽부터 차례로 준다). 둘은 함께 바꾸고 setup 을 다시 실행한다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
# 도커 기본 주소 풀(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
|
||||
MAIL_RESOLVER_SUBNET=172.16.73.0/24
|
||||
MAIL_RESOLVER_IP=172.16.73.254
|
||||
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
# ==========================================
|
||||
DB_IMAGE=postgres:15-alpine
|
||||
# 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정" — 떠 있는 태그 15-alpine 을 쓰지 않는다). 메이저(15)는 데이터 형식이라 그대로 —
|
||||
# 바꾸면 다음 up 에서 db 가 다시 만들어진다(데이터는 DB_DATA_DIR 그대로, 같은 메이저의 패치라 그대로 연다)
|
||||
# 15.19 는 2026-08-13 배포(보안 릴리스). 이 digest 는 그 판의 2026-10-21 재빌드(alpine 패키지) — 이미지로는 2주가 2026-11-04 에 찬다
|
||||
# (개발은 지금 써도 된다 — 운영에 올리는 것은 11-04 뒤, .env.production.example 의 같은 줄)
|
||||
DB_IMAGE=postgres:15.19-alpine3.24@sha256:54e0dd8e8cddcdbb9156c2486eed5a43eccb7214d7c6828097d41ce049fb3984
|
||||
DB_USER=app_user
|
||||
DB_PASSWORD=change-me-in-local-env
|
||||
DB_NAME=project_db
|
||||
@@ -86,6 +95,8 @@ DB_SYNCHRONIZE=false
|
||||
# mail 메일 엔진 + Mailpit (기본) → COMPOSE_PROFILES=mail
|
||||
# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=mail,redis
|
||||
# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=mail,frontend
|
||||
# clamav 대용량 첨부의 악성 검사기(ClamAV — 메모리 1GB 남짓 · 처음에 서명 110MB 를 받는다)
|
||||
# → COMPOSE_PROFILES=mail,clamav 와 함께 아래 "대용량 첨부" 의 CLAMAV_HOST=clamav (운영은 profile 없이 늘 뜬다)
|
||||
#
|
||||
# 일회성으로 켜려면: docker compose --profile redis ... up -d
|
||||
#
|
||||
@@ -103,6 +114,36 @@ COMPOSE_PROFILES=mail
|
||||
UPLOAD_DATA_DIR=./data/uploads
|
||||
UPLOAD_MAX_SIZE_MB=5
|
||||
|
||||
# ==========================================
|
||||
# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절, mail/README.md "악성 검사 (ClamAV)")
|
||||
#
|
||||
# 일반 첨부 한도(아래 MAIL_ATTACHMENT_*)를 넘는 파일은 메일 엔진이 아니라 백엔드의 볼륨(<PROJECT_NAME>_files — 개발도 named volume)에 올리고,
|
||||
# 메일에는 받는 사람이 로그인 없이 여는 내려받기 링크를 넣는다. 아래 값은 백엔드 기본값과 같다 — 비워 두어도 그 값이다.
|
||||
# SHARE_PUBLIC_URL : 공개 내려받기 화면의 출처(스킴 · 호스트[· 포트] — 경로 · 끝의 / 없이). 개발은 비운다 = CORS_ORIGIN 의 첫 주소(포털과 같은 호스트).
|
||||
# 운영은 포털과 다른 호스트다(.env.production.example)
|
||||
# SHARE_REPORT_ADDRESS : 공개 화면의 신고 · 문의 주소 (비우면 postmaster@<MAIL_DOMAIN>)
|
||||
# SHARE_DOWNLOAD_LIMIT : 파일마다 내려받기 상한(받기 시작한 수, 1~100000)
|
||||
# MAIL_LARGE_FILE_MAX_SIZE_MB : 한 파일(MB, 1~2047 — 악성 검사기가 끝까지 검사할 수 있는 2GiB 아래, MAIL_ATTACHMENT_MAX_SIZE_MB 이상)
|
||||
# MAIL_LARGE_FILE_MAX_COUNT : 메일 한 통에 새로 싣는 대용량 파일 수(1~20)
|
||||
# MAIL_LARGE_FILE_USER_QUOTA_GB : 직원마다 동시에 보관하는 양(GB, 1~1000 — 올리는 중 · 임시 저장 포함)
|
||||
# MAIL_LARGE_FILE_RETENTION_DAYS : 보낸 날(한국)부터 받을 수 있는 날 수(1~90)
|
||||
# FILE_STORAGE_MAX_TOTAL_GB : 서버 전체 보관량(GB). 비우면 한도 없음 — 디스크 여유만 본다
|
||||
# FILE_STORAGE_MIN_FREE_GB : 올리기를 만든 뒤에도 남길 디스크 여유(GB) — 개발은 Docker Desktop 가상 디스크의 여유를 본다
|
||||
# CLAMAV_HOST · CLAMAV_PORT : 악성 검사기(clamd). 개발은 비워 둔다 = 검사하지 않고 다 올리자마자 "검사하지 못함".
|
||||
# 켜려면 COMPOSE_PROFILES=mail,clamav 와 함께 CLAMAV_HOST=clamav (compose 의 clamav 서비스, 포트 3310)
|
||||
# ==========================================
|
||||
SHARE_PUBLIC_URL=
|
||||
SHARE_REPORT_ADDRESS=
|
||||
SHARE_DOWNLOAD_LIMIT=500
|
||||
MAIL_LARGE_FILE_MAX_SIZE_MB=2047
|
||||
MAIL_LARGE_FILE_MAX_COUNT=10
|
||||
MAIL_LARGE_FILE_USER_QUOTA_GB=10
|
||||
MAIL_LARGE_FILE_RETENTION_DAYS=30
|
||||
FILE_STORAGE_MAX_TOTAL_GB=
|
||||
FILE_STORAGE_MIN_FREE_GB=10
|
||||
CLAMAV_HOST=
|
||||
CLAMAV_PORT=3310
|
||||
|
||||
# ==========================================
|
||||
# 메일 엔진 (Stalwart) — profile `mail`
|
||||
#
|
||||
@@ -140,12 +181,22 @@ MAIL_RECOVERY_ADMIN=admin:change-me-in-local-env
|
||||
# ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다
|
||||
MAIL_GATEWAY_USER=mail-gateway@arasystem.test
|
||||
MAIL_GATEWAY_PASSWORD=change-me-in-local-env
|
||||
# 사내 목록 계정 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 mail-directory) — 백엔드가 엔진의 계정 목록에서 직원 이름·주소만 읽을 때
|
||||
# 게이트웨이 대리 접근으로 쓰는 전용 계정이다. 비밀번호가 없고, setup 스크립트가 복구 관리자(위 MAIL_RECOVERY_ADMIN)로 만든다.
|
||||
# 관리자·게이트웨이 아이디와 달라야 한다. 바꾸면 setup 을 다시 실행하고 backend 를 다시 띄운다 (mail/README.md "사내 목록 계정").
|
||||
# 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (MAIL_ADMIN_LOCAL_PART 와 같은 규칙)
|
||||
MAIL_DIRECTORY_LOCAL_PART=mail-directory
|
||||
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망). 보통 바꿀 일이 없다
|
||||
MAIL_JMAP_URL=http://mail:8080
|
||||
# 엔진 웹훅의 서명 키 (12단계 — 메일 프로그램(IMAP · SMTP) 로그인 기록) — 엔진이 로그인 이벤트를 백엔드로 보낼 때 본문을 이 값으로 서명하고(HMAC-SHA256)
|
||||
# 백엔드가 같은 값으로 확인한다. setup 과 backend 가 이 값 하나를 읽는다. 영문 · 숫자로만 32자 이상 — 만들기: openssl rand -hex 24 (사람이 지은 값은 쓰지 않는다)
|
||||
# 비우면 setup 이 웹훅을 두지 않는다(개발만 — 그동안 메일 프로그램 로그인 기록이 남지 않는다). 바꾸면 setup 을 다시 실행하고 backend 를 다시 띄운다 (mail/README.md "엔진 웹훅")
|
||||
MAIL_WEBHOOK_SECRET=changemeinlocalenv00000000000000
|
||||
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다
|
||||
# (그 앞에 외부 리버스 프록시를 두면 그쪽 한도도 맞춘다 — docker 스킬 "운영 환경")
|
||||
MAIL_ATTACHMENT_MAX_SIZE_MB=20
|
||||
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다
|
||||
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다.
|
||||
# 바꾸면 mail setup 도 다시 실행한다 — 엔진의 임시 업로드 한도를 이 값의 20배로 맞춘다 (mail/README.md "엔진 한도")
|
||||
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25
|
||||
# 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분).
|
||||
# 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다
|
||||
@@ -153,6 +204,16 @@ MAIL_TRUSTED_NETWORKS=
|
||||
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
|
||||
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
|
||||
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.test
|
||||
# 시범 공존 — 한 사람만 새 서버를 먼저 쓰고 나머지 직원은 기존 서버(mailcow)에 남을 때 (mail/README.md "파일럿 공존"). 비우면 꺼짐.
|
||||
# 개발은 비워 두고, 시험할 때만 셸에서 일회성으로 준다 (기존 서버 역 = Mailpit — 개발에서는 셸 값을 일부러 받는다):
|
||||
# MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs
|
||||
# 값 없이 다시 실행하면 setup 이 시범 설정을 지운다. 기존 서버 주소 — host · host:port · [host]:port (포트 기본 25)
|
||||
MAIL_PILOT_LEGACY_HOST=
|
||||
# 기존 서버가 이 서버에 붙을 때의 IPv4 · CIDR (콤마). 개발은 비우면 백엔드 컨테이너 주소(개발의 시험 SMTP 가 나가는 곳)
|
||||
MAIL_PILOT_LEGACY_IPS=
|
||||
# Spamhaus 조회 키 (선택 — mail/README.md "DNS 리졸버와 스팸 차단 목록"). 키는 운영 서버의 .env.production 에만 둔다 — 개발은 비워 둔다.
|
||||
# 비어 있으면 setup 이 스팸 필터의 Spamhaus 두 항목(ZEN · DBL)을 꺼 둔다 — 개발도 운영과 같다(공개 미러는 소규모 · 비상업 용도다)
|
||||
MAIL_SPAMHAUS_DQS_KEY=
|
||||
# 개발용 테스트 직원 계정(ara.kim@ 등)의 공통 비밀번호 — 개발 환경에서만 만들어진다
|
||||
MAIL_DEV_USER_PASSWORD=change-me-in-local-env
|
||||
# 개발 전용 메일 수집기 — 사내 도메인 밖으로 보낸 메일이 여기에만 쌓인다
|
||||
|
||||
+96
-4
@@ -23,16 +23,26 @@ REDIS_PORT=6379
|
||||
# APP_NETWORK_SUBNET : backend·db·redis·mail — 사설 대역, /24 ~ /28 (mail setup 이 검사한다)
|
||||
# EDGE_NETWORK_SUBNET : frontend(nginx) ↔ backend 전용. 인터넷을 향한 frontend 는 app-network 에 붙지 않는다.
|
||||
# TRUST_PROXY 를 주소 목록으로 쓸 때 이 대역을 넣는다 (아래)
|
||||
# MAIL_RESOLVER_SUBNET : mail ↔ 메일 전용 DNS 리졸버(mail-resolver) 전용 네트워크 mail-dns — 바깥 길이 없는 internal (사설 대역, /24 ~ /29)
|
||||
# MAIL_RESOLVER_IP : 그 안의 리졸버 주소(고정) — 엔진의 DNS(setup 이 맞춘다)와 mail 컨테이너의 dns 가 이 주소다. 서브넷의 끝 쪽에 둔다
|
||||
# (도커는 mail 에 앞쪽부터 차례로 준다). 둘은 함께 바꾸고 setup 을 다시 실행한다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다.
|
||||
# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다.
|
||||
# 서버의 사설망(NCP VPC 등)과도 겹치지 않게 한다 — 겹치면 그 대역으로 가는 길이 도커 브리지로 간다 (mail/README.md "NCP 쪽 할 일")
|
||||
# ==========================================
|
||||
APP_NETWORK_SUBNET=172.16.71.0/24
|
||||
EDGE_NETWORK_SUBNET=172.16.72.0/24
|
||||
MAIL_RESOLVER_SUBNET=172.16.73.0/24
|
||||
MAIL_RESOLVER_IP=172.16.73.254
|
||||
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
# ==========================================
|
||||
DB_IMAGE=postgres:15-alpine
|
||||
# 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정" — 떠 있는 태그 15-alpine 을 쓰지 않는다). 메이저(15)는 데이터 형식이라 그대로 —
|
||||
# 바꾸면 다음 up 에서 db 가 다시 만들어진다(몇 초 끊김 · 데이터는 DB_DATA_DIR 그대로, 같은 메이저의 패치라 그대로 연다).
|
||||
# 15.19 는 2026-08-13 배포(보안 릴리스). 이 digest 는 그 판의 2026-10-21 재빌드(alpine 패키지) — 2주 규칙으로 운영에 올리는 것은 2026-11-04 뒤.
|
||||
# 올리기 전에 백업이 그날 돌았는지 본다 (mail/README.md "백업" · "13단계 전부터 운영 중인 서버")
|
||||
DB_IMAGE=postgres:15.19-alpine3.24@sha256:54e0dd8e8cddcdbb9156c2486eed5a43eccb7214d7c6828097d41ce049fb3984
|
||||
DB_USER=app_user
|
||||
DB_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
DB_NAME=project_db
|
||||
@@ -59,16 +69,20 @@ REDIS_DATA_DIR=./data/redis
|
||||
#
|
||||
# - TRUST_PROXY : 백엔드 앞 프록시 — 실제 클라이언트 IP(로그인 실패 한도의 기준)를 읽는다. 운영 필수
|
||||
# 이 compose 의 운영 경로는 클라이언트 → 외부 리버스 프록시(TLS) → 프론트 nginx → 백엔드 하나뿐이다.
|
||||
# (프론트 nginx 는 호스트 포트를 열지 않고 TLS 도 하지 않으므로 외부 프록시 없이는 들어올 수 없다 — 1 은 해당 없음)
|
||||
# (프론트 nginx 는 TLS 를 하지 않고 호스트 포트도 열지 않으므로(다른 서버의 프록시용 구성은 사설 IP 에만) 외부 프록시 없이는 쓸 수 없다 — 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 등).
|
||||
# 넓은 사설 대역을 통째로 넣지 않는다.
|
||||
# 외부 프록시가 다른 서버에 있으면(아래 "리버스 프록시가 다른 서버에 있을 때") 주소 목록으로 —
|
||||
# <EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP> (예: 172.16.72.0/24,10.0.1.11). 사설 IP 는 frontend 접근 로그의 첫 칸이다
|
||||
# true·0·false 는 운영에서 기동이 거부된다.
|
||||
# ==========================================
|
||||
BACKEND_API_URL=https://portal.arasystem.it.kr/api
|
||||
# ⚠️ 대용량 첨부의 공개 내려받기 호스트(아래 SHARE_PUBLIC_URL — files.)는 여기에 넣지 않는다 — 넣으면 공개 호스트 분리가 꺼져
|
||||
# 포털에서도 공개 링크가 열린다(백엔드가 운영에서는 기동을 막는다). 공개 화면은 자기 호스트의 /api/public-links/ 만 부른다
|
||||
CORS_ORIGIN=https://portal.arasystem.it.kr
|
||||
TRUST_PROXY=2
|
||||
|
||||
@@ -92,13 +106,30 @@ DB_SYNCHRONIZE=false
|
||||
# 선택적 서비스 (Compose profiles)
|
||||
#
|
||||
# 기본값 = mail — 메일 엔진(Stalwart)을 띄운다. 직원 로그인이 메일 엔진에 의존한다.
|
||||
# (redis 는 기동하지 않는다)
|
||||
# (redis 는 기동하지 않는다. 대용량 첨부의 악성 검사기 clamav · clamav-updater 는 profile 없이 늘 뜬다 — 아래 "대용량 첨부")
|
||||
# 캐시·세션 공유가 필요해지면 redis 를 추가하면 컨테이너가 함께 뜬다.
|
||||
# COMPOSE_PROFILES=mail,redis
|
||||
# 일회성으로 켜려면: docker compose --profile redis ... up -d
|
||||
# ==========================================
|
||||
COMPOSE_PROFILES=mail
|
||||
|
||||
# ==========================================
|
||||
# 리버스 프록시가 다른 서버에 있을 때 (선택 — mail/README.md "리버스 프록시가 다른 서버에 있을 때")
|
||||
#
|
||||
# 기본은 외부 리버스 프록시가 이 호스트에서 external proxy 네트워크로 <PROJECT_NAME>-frontend:8080 에 붙는 구성이다.
|
||||
# 다른 서버의 프록시(예: 같은 VPC 의 Nginx Proxy Manager)가 사설망으로 붙는다면 COMPOSE_FILE 의 주석을 풀고 FRONTEND_PRIVATE_BIND 를 채운다 —
|
||||
# docker-compose.remote-proxy.yml 이 frontend 를 proxy 네트워크에서 빼고 8080 을 이 서버의 사설 IP 에만 게시한다.
|
||||
# COMPOSE_FILE : compose 파일 목록 (리눅스는 : 로 잇는다). 이 파일에 두면 문서의 운영 명령(sudo docker compose --env-file .env.production …)이
|
||||
# -f 없이 그대로 두 파일을 읽는다 (compose v2.40 · 5.4 에서 확인). 쓰지 않을 때는 주석으로 둔다 —
|
||||
# 빈 값(COMPOSE_FILE=)은 compose 가 파일 목록으로 읽다 실패한다
|
||||
# FRONTEND_PRIVATE_BIND : <이 서버의 사설 IPv4>:<포트> (예: 10.0.1.12:8080 — ip -4 addr 로 본다).
|
||||
# 비어 있으면 compose 가 멈추고, 포트뿐인 값 · 0.0.0.0 · 공인 IP · IPv6 면 frontend 가 뜨지 않는다 (frontend-bind-check)
|
||||
# TRUST_PROXY 는 위 "화면·API 주소" 의 주소 목록으로 바꾼다 — <EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP>
|
||||
# ⚠️ 게시 주소만으로는 바깥을 막지 못한다 — NCP 는 공인 IP 를 사설 IP 로 NAT 하므로 8080 은 방화벽(ACG)이 프록시 서버의 사설 IP 에서만 받게 한다
|
||||
# ==========================================
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml
|
||||
FRONTEND_PRIVATE_BIND=
|
||||
|
||||
# ==========================================
|
||||
# 파일 업로드 (로컬 디스크)
|
||||
#
|
||||
@@ -108,6 +139,38 @@ COMPOSE_PROFILES=mail
|
||||
# ==========================================
|
||||
UPLOAD_MAX_SIZE_MB=5
|
||||
|
||||
# ==========================================
|
||||
# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절, mail/README.md "악성 검사 (ClamAV)" · "대용량 첨부 — 운영")
|
||||
#
|
||||
# 일반 첨부 한도(아래 MAIL_ATTACHMENT_*)를 넘는 파일은 백엔드의 볼륨(<PROJECT_NAME>_files — 30일짜리 전송물이라 백업하지 않는다)에 올리고,
|
||||
# 메일에는 받는 사람이 로그인 없이 여는 내려받기 링크를 넣는다. 아래 값은 백엔드 기본값과 같다(SHARE_PUBLIC_URL · CLAMAV_HOST 는 운영 필수).
|
||||
# SHARE_PUBLIC_URL : 공개 내려받기 화면의 출처 — **운영 필수**, https, 경로 · 끝의 / 없이. 포털과 다른 호스트로 둔다:
|
||||
# 털린 계정이 퍼뜨린 악성 파일로 포털 주소까지 차단 목록에 오르는 위험을 떼어 놓고, 포털 쿠키도 그 호스트로 가지 않는다.
|
||||
# DNS(가비아 files A → 외부 리버스 프록시 서버) · 외부 프록시의 Proxy Host(같은 사설 IP:8080, 접근 로그 끔)가 있어야 한다.
|
||||
# ⚠️ 이 호스트를 위 CORS_ORIGIN 에 넣지 않는다(넣으면 분리가 꺼진다 — 백엔드가 기동을 막는다).
|
||||
# ⚠️ 한 번 정하면 바꾸지 않는다 — 이미 보낸 메일의 링크가 이 주소다(옛 호스트가 남아 있어야 열린다)
|
||||
# SHARE_REPORT_ADDRESS : 공개 화면의 신고 · 문의 주소 (비우면 postmaster@<MAIL_DOMAIN>)
|
||||
# SHARE_DOWNLOAD_LIMIT : 파일마다 내려받기 상한(받기 시작한 수, 1~100000)
|
||||
# MAIL_LARGE_FILE_MAX_SIZE_MB : 한 파일(MB, 1~2047 — 악성 검사기가 끝까지 검사할 수 있는 2GiB 아래, MAIL_ATTACHMENT_MAX_SIZE_MB 이상)
|
||||
# MAIL_LARGE_FILE_MAX_COUNT : 메일 한 통에 새로 싣는 대용량 파일 수(1~20)
|
||||
# MAIL_LARGE_FILE_USER_QUOTA_GB : 직원마다 동시에 보관하는 양(GB, 1~1000 — 올리는 중 · 임시 저장 포함)
|
||||
# MAIL_LARGE_FILE_RETENTION_DAYS : 보낸 날(한국)부터 받을 수 있는 날 수(1~90)
|
||||
# FILE_STORAGE_MAX_TOTAL_GB : 서버 전체 보관량(GB). 비우면 한도 없음 — 디스크 여유만 본다. 디스크를 메일 · DB 와 함께 쓰면 정해 둔다
|
||||
# FILE_STORAGE_MIN_FREE_GB : 올리기를 만든 뒤에도 남길 디스크 여유(GB) — 도커 data-root 의 디스크(볼륨 · 악성 검사의 임시 파일이 함께 쓴다)
|
||||
# CLAMAV_HOST · CLAMAV_PORT : 악성 검사기(clamd) — **운영 필수**. compose 의 clamav 서비스(호스트 이름만 — 스킴 · 포트 없이)
|
||||
# ==========================================
|
||||
SHARE_PUBLIC_URL=https://files.arasystem.it.kr
|
||||
SHARE_REPORT_ADDRESS=
|
||||
SHARE_DOWNLOAD_LIMIT=500
|
||||
MAIL_LARGE_FILE_MAX_SIZE_MB=2047
|
||||
MAIL_LARGE_FILE_MAX_COUNT=10
|
||||
MAIL_LARGE_FILE_USER_QUOTA_GB=10
|
||||
MAIL_LARGE_FILE_RETENTION_DAYS=30
|
||||
FILE_STORAGE_MAX_TOTAL_GB=
|
||||
FILE_STORAGE_MIN_FREE_GB=10
|
||||
CLAMAV_HOST=clamav
|
||||
CLAMAV_PORT=3310
|
||||
|
||||
# ==========================================
|
||||
# 메일 엔진 (Stalwart) — profile `mail`
|
||||
#
|
||||
@@ -149,13 +212,26 @@ MAIL_ADMIN_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
# ⚠️ 이 비밀번호는 전 직원 메일함의 열쇠다
|
||||
MAIL_GATEWAY_USER=mail-gateway@arasystem.it.kr
|
||||
MAIL_GATEWAY_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
# 사내 목록 계정 아이디 (주소는 <이 값>@MAIL_DOMAIN, 미설정 시 mail-directory) — 백엔드가 엔진의 계정 목록에서 직원 이름·주소만 읽을 때
|
||||
# 게이트웨이 대리 접근으로 쓰는 전용 계정이다. 비밀번호가 없다. 관리자·게이트웨이 아이디와 달라야 하고, 옮겨 올 메일함과 겹치지 않게 정한다.
|
||||
# ⚠️ 엔진 관리자는 이 계정의 권한을 내줄 수 없어 setup 의 --recovery 실행(위 복구 관리자 절차)에서만 만들어진다 — 첫 배포 때 만들어지고,
|
||||
# 이미 운영 중인 서버라면 --recovery 를 한 번 더 한다. 없는 동안 백엔드는 로그인한 직원 기록으로 사내 목록을 대신한다 (mail/README.md "사내 목록 계정").
|
||||
# 바꾸면 --recovery 로 setup 을 다시 실행하고 backend 를 다시 띄운다. 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하 (MAIL_ADMIN_LOCAL_PART 와 같은 규칙)
|
||||
MAIL_DIRECTORY_LOCAL_PART=mail-directory
|
||||
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망)
|
||||
MAIL_JMAP_URL=http://mail:8080
|
||||
# 엔진 웹훅의 서명 키 (12단계 — 메일 프로그램(IMAP · SMTP) 로그인 기록) — **운영 필수**. 엔진이 로그인 이벤트를 백엔드로 보낼 때 본문을 이 값으로
|
||||
# 서명하고(HMAC-SHA256) 백엔드가 같은 값으로 확인한다. setup 과 backend 가 이 값 하나를 읽는다 — 비었거나 CHANGEME 가 남아 있으면 둘 다 멈춘다.
|
||||
# 영문 · 숫자로만 32자 이상 — 만들기: openssl rand -hex 24 (사람이 지은 값은 쓰지 않는다 — 엔진 관리 화면에 이 키의 지문이 보인다)
|
||||
# 새 이미지를 띄우기(up) 전에 넣는다 — 없으면 백엔드가 env 검사에서 멈춘다. 바꾸면 backend 를 다시 띄우고(up -d backend) setup 을 다시 실행한다 —
|
||||
# 그 사이에 엔진이 보낸 기록은 빠질 수 있다 (mail/README.md "엔진 웹훅")
|
||||
MAIL_WEBHOOK_SECRET=CHANGEMEBEFOREDEPLOY000000000000
|
||||
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도는 이 값 + 1MB 로 자동으로 따라간다.
|
||||
# ⚠️ 그 앞의 외부 리버스 프록시 본문 한도는 직접 이 값 + 1MB 이상으로 둔다 —
|
||||
# 작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 (docker 스킬 "운영 환경")
|
||||
MAIL_ATTACHMENT_MAX_SIZE_MB=20
|
||||
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다
|
||||
# 메일 한 통의 첨부 합계(MB, 미설정 시 25) — 파일당 한도 이상이어야 한다.
|
||||
# 바꾸면 mail setup 도 다시 실행한다 — 엔진의 임시 업로드 한도를 이 값의 20배로 맞춘다 (mail/README.md "엔진 한도")
|
||||
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=25
|
||||
# 게이트웨이 계정을 쓸 수 있고 자동 차단에서 빠지는 대역 (콤마 구분).
|
||||
# 비우면 APP_NETWORK_SUBNET 과 같다. 그 안의 더 좁은 대역만 쓸 수 있다 — 더 넓으면 setup 이 실패한다
|
||||
@@ -163,3 +239,19 @@ MAIL_TRUSTED_NETWORKS=
|
||||
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
|
||||
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
|
||||
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.it.kr
|
||||
# Spamhaus 조회 키 (선택 — mail/README.md "DNS 리졸버와 스팸 차단 목록"). 있으면 스팸 필터의 Spamhaus 두 항목(ZEN · DBL)이 공개 미러 대신
|
||||
# Spamhaus 의 "Blocklists via DNS Query"(옛 DQS — <키>.zen.dq.spamhaus.net)로 묻는다. 비어 있으면 setup 이 두 항목을 끄고 [주의] 를 낸다
|
||||
# (공개 미러는 소규모 · 비상업 용도라 쓰지 않는다 — 나머지 차단 목록은 그대로).
|
||||
# 무료 계정은 Spamhaus 의 Fair Use(소규모 · 비상업) 조건이다 — 회사가 대상인지는 Spamhaus 가 판단하고, 안 되면 비워 두거나(두 항목 꺼짐) 유료(30일 체험)로.
|
||||
# ⚠️ 체험(30일) 키는 기간이 끝나면 답이 멈춘다 — 구독하거나 키를 비우고 setup. 멈추면 엔진의 Spamhaus 조회가 실패해 표시가 붙지 않고(메일은 받는다)
|
||||
# 다음 setup 실행이 두 항목을 끄고 종료 1 로 알린다 — setup 을 주기적으로 돌려 종료 0 인지 보거나 Spamhaus 계정의 만료일을 본다.
|
||||
# 키(26자 영문 · 숫자)는 비밀이다 — 이 파일에만 둔다(파일에 없거나 파일과 다른 값이 셸에 있으면 setup 이 멈춘다). setup 은 출력하지 않지만 엔진의 스팸 필터 설정(조회 이름 식)에는
|
||||
# 그대로 저장된다 — 관리 화면 · 관리 API · 메일 볼륨 백업으로 보인다. 바꾸거나 비우면 setup 을 다시 실행한다
|
||||
MAIL_SPAMHAUS_DQS_KEY=
|
||||
# 시범 공존 — 한 사람만 새 서버를 먼저 쓰고 나머지 직원은 기존 서버(mailcow)에 남을 때 (mail/README.md "파일럿 공존"). 비우면 꺼짐.
|
||||
# MX 는 mailcow 그대로이고, mailcow 의 Transport Map 이 시범 사용자 주소만 이 서버로 넘긴다. 값은 셸이 아니라 이 파일에 둔다.
|
||||
# ⚠️ 켠 뒤에 값이 비면 setup 이 멈춘다 — 시범을 끝낼 때(전면 전환)는 둘 다 비우고 setup 에 --end-pilot 을 붙인다.
|
||||
# 기존 서버 주소 — host · host:port · [host]:port (운영은 포트 25 만 — 기본값, IPv4 주소면 인증서 이름 확인을 끈다). 예: mail.arasystem.it.kr
|
||||
MAIL_PILOT_LEGACY_HOST=
|
||||
# 기존 서버가 이 서버에 붙을 때의 공인 IPv4 · CIDR(/24 까지, 콤마) — HOST 가 있으면 필수. 예: 46.250.248.43
|
||||
MAIL_PILOT_LEGACY_IPS=
|
||||
|
||||
@@ -7,6 +7,8 @@ name: CI (Flutter)
|
||||
#
|
||||
# 언제 도는가:
|
||||
# - flutter/ 아래 파일이 바뀐 푸시·PR (= 실제로 Flutter 를 쓸 때 자동으로)
|
||||
# - 앱 짝 시험이 그대로 읽는 백엔드 파일이 바뀐 푸시·PR — 첨부 형식 표(attachment.policy.ts) · 검색 표본 표(search-text-samples.ts).
|
||||
# 그 파일만 고친 커밋에서도 앱 시험이 깨지는지 본다 (2026-11-07)
|
||||
# - Actions 탭의 "Run workflow" 버튼으로 수동 실행
|
||||
#
|
||||
# 모바일 앱을 아예 쓰지 않는 프로젝트라면 이 파일을 지우면 된다.
|
||||
@@ -16,10 +18,14 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'flutter/**'
|
||||
- 'backend/src/modules/mail/attachment.policy.ts'
|
||||
- 'backend/test/search-text-samples.ts'
|
||||
- '.github/workflows/ci-flutter.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'flutter/**'
|
||||
- 'backend/src/modules/mail/attachment.policy.ts'
|
||||
- 'backend/test/search-text-samples.ts'
|
||||
- '.github/workflows/ci-flutter.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -39,16 +45,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Flutter 판 고정 — 개발 PC 와 같은 판이다(.claude/skills/flutter/SKILL.md "Flutter 판").
|
||||
# 떠 있는 stable 을 받으면 그날의 최신이 들어와 개발 PC 와 어긋난다. 올리는 것은 사용자가 정한다.
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: 3.41.9
|
||||
cache: true
|
||||
|
||||
# .env 는 pubspec 의 asset 이라 없으면 analyze 가 경고를 낸다
|
||||
- name: Prepare .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- run: flutter pub get
|
||||
# lock 그대로 받는다 — pubspec 과 어긋나면 실패(조용히 다른 판을 받지 않게)
|
||||
- run: flutter pub get --enforce-lockfile
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
+359
-3
@@ -122,7 +122,7 @@ jobs:
|
||||
run: npm run build-only
|
||||
|
||||
infra:
|
||||
name: Infra (compose · Dockerfile · nginx)
|
||||
name: Infra (compose · Dockerfile · nginx · 운영 스크립트)
|
||||
runs-on: ubuntu-latest
|
||||
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
|
||||
timeout-minutes: 10
|
||||
@@ -139,6 +139,52 @@ jobs:
|
||||
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
|
||||
# 리버스 프록시가 다른 서버에 있을 때 (docker-compose.remote-proxy.yml — mail/README.md). 게시 주소는 이 단계에서만 준다
|
||||
FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q
|
||||
# proxy 네트워크가 빠져야 한다 — 남으면 그 서버에 없는 external 네트워크를 찾다 up 이 멈춘다
|
||||
# (grep -q 는 쓰지 않는다 — 먼저 끝나면 pipefail 셸에서 compose 의 SIGPIPE 가 결과를 뒤집는다)
|
||||
if FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config --networks | grep -x proxy >/dev/null; then
|
||||
echo "::error::docker-compose.remote-proxy.yml 이 proxy 네트워크를 빼지 못했습니다"; exit 1
|
||||
fi
|
||||
# 게시 주소가 없으면 멈춰야 한다 (예시 env 의 FRONTEND_PRIVATE_BIND 는 비어 있다 — 빈 값이면 모든 주소에 열린다)
|
||||
if docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q 2>/dev/null; then
|
||||
echo "::error::FRONTEND_PRIVATE_BIND 없이 docker-compose.remote-proxy.yml 을 받아들였습니다 (멈춰야 한다)"; exit 1
|
||||
fi
|
||||
# 로그 보존(13단계) — 모든 서비스에 logging 이 있어야 한다(없으면 도커 기본 — 회전 없이 디스크를 채운다).
|
||||
# 서비스는 두 칸, 그 키는 네 칸 들여 쓴다(compose config 의 출력 꼴)
|
||||
# 리버스 프록시가 다른 서버일 때의 조합(remote-proxy — frontend-bind-check 가 더해진다)도 센다. 게시 주소는 이 확인에서만 준다
|
||||
for args in "-f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile mail --profile redis --profile frontend --profile clamav" \
|
||||
"-f docker-compose.yml --env-file .env.production.example --profile mail --profile redis" \
|
||||
"-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example --profile mail --profile redis"; do
|
||||
# shellcheck disable=SC2086
|
||||
services=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config --services | wc -l)
|
||||
# shellcheck disable=SC2086
|
||||
logged=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config | awk '/^ logging:/ { c++ } END { print c + 0 }')
|
||||
[ "$services" -eq "$logged" ] || { echo "::error::logging 이 없는 서비스가 있습니다 ($args — 서비스 $services · logging $logged)"; exit 1; }
|
||||
done
|
||||
# DB 이미지는 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정")
|
||||
for envf in .env.development.example .env.production.example; do
|
||||
grep -Eq '^DB_IMAGE=postgres:[0-9]+\.[0-9]+-alpine[0-9.]+@sha256:[0-9a-f]{64}$' "$envf" || { echo "::error::$envf 의 DB_IMAGE 가 판 · digest 까지 고정되지 않았습니다"; exit 1; }
|
||||
done
|
||||
|
||||
# docker-compose.remote-proxy.yml 의 frontend-bind-check — 사설 IPv4:포트만 통과해야 한다
|
||||
# (포트뿐인 값 · 0.0.0.0 은 compose 가 그대로 받아 모든 주소에 연다). 프로젝트 이름을 따로 두어 다른 compose 프로젝트와 섞이지 않게 한다
|
||||
- name: frontend 게시 주소 확인 (리버스 프록시가 다른 서버에 있을 때)
|
||||
run: |
|
||||
touch backend/.env.production
|
||||
check() {
|
||||
FRONTEND_PRIVATE_BIND="$1" docker compose -p "ci-bind-${{ github.run_id }}" \
|
||||
-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example \
|
||||
run --rm --no-deps -T frontend-bind-check
|
||||
}
|
||||
for value in 10.0.1.12:8080 172.31.255.254:65535 192.168.0.10:18080; do
|
||||
check "$value" || { echo "::error::FRONTEND_PRIVATE_BIND=$value 를 거부했습니다 (받아야 한다)"; exit 1; }
|
||||
done
|
||||
for value in 8080 :8080 0.0.0.0:8080 127.0.0.1:8080 203.0.113.5:8080 172.32.0.1:8080 '[::]:8080' 10.0.1.12:0 10.0.1.12:08080; do
|
||||
if check "$value" 2>/dev/null; then
|
||||
echo "::error::FRONTEND_PRIVATE_BIND=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Dockerfile 검사
|
||||
run: |
|
||||
@@ -150,18 +196,328 @@ jobs:
|
||||
run: |
|
||||
tag="ci-nginx-config:${{ github.run_id }}"
|
||||
docker build --target nginx-config -t "$tag" frontend
|
||||
# 공개 내려받기 호스트(11단계)를 따로 두지 않을 때 — 포털이 /s/ · /api/public-links/ 를 연다(nginx-share-open.conf · nginx-share-api-open.conf)
|
||||
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t
|
||||
# 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소)
|
||||
# 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다.
|
||||
# 이 꼴은 메일 프로그램 자동 설정(13단계 — autoconfig. · autodiscover.<MAIL_DOMAIN>)도 켠다
|
||||
docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -t
|
||||
rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -T 2>/dev/null)
|
||||
for want in 'server_name files.ci.test;' 'include /etc/nginx/snippets/share-closed.conf;' 'include /etc/nginx/snippets/share-api-closed.conf;' \
|
||||
'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;' \
|
||||
'server_name autoconfig.ci.test autodiscover.ci.test;' '<hostname>mx1.ci.test</hostname>' '<Server>mx1.ci.test</Server><Port>993</Port>'; do
|
||||
printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; }
|
||||
done
|
||||
# 메일 값이 없으면 자동 설정은 꺼진다 — 어느 요청에도 맞지 않는 이름
|
||||
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null | grep -F 'server_name autoconfig.invalid;' >/dev/null \
|
||||
|| { echo "::error::MAIL_DOMAIN 없이 자동 설정 server 가 꺼지지 않았습니다"; exit 1; }
|
||||
# 자동 설정의 값은 server_name · 응답 XML 에 그대로 들어간다 — 호스트 이름 꼴이 아니면 · 포털 · 공개 호스트와 겹치면 기동을 멈춰야 한다
|
||||
for value in 'ci.test;return 200' 'ci..test' '-ci.test' 'ci.test.' 'citest' 'ci test' 'ci.test/x'; do
|
||||
if docker run --rm -e PROJECT_NAME=ci -e "MAIL_DOMAIN=$value" -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -t >/dev/null 2>&1; then
|
||||
echo "::error::MAIL_DOMAIN=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
for value in 'mx1.ci.test<x>' 'mx1.ci.test:993' 'mx1'; do
|
||||
if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e "MAIL_HOSTNAME=$value" "$tag" nginx -t >/dev/null 2>&1; then
|
||||
echo "::error::MAIL_HOSTNAME=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
# 끝의 / · 빈칸 · 여러 출처도 같게 본다 (공개 호스트 판정과 같은 읽기)
|
||||
for value in 'https://autoconfig.ci.test' 'https://autoconfig.ci.test/' 'https://portal.ci.test, https://AUTODISCOVER.ci.test/'; do
|
||||
if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test -e "CORS_ORIGIN=$value" "$tag" nginx -t >/dev/null 2>&1; then
|
||||
echo "::error::포털(CORS_ORIGIN=$value)과 겹치는 자동 설정 호스트를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
# 12단계 — 두 server 가 모두 엔진만 부르는 /api/internal/ 을 닫고(nginx-internal-api-closed.conf) 원문 요청 대상(날 \ · # · 0x80 이상)을 400 으로 끝낸다
|
||||
# (if $request_target_rejected). 두 꼴 모두 지시어가 둘씩이어야 한다 — 주석이 아니라 지시어만 센다(첫 칸이 include · if)
|
||||
plain=$(docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null)
|
||||
for form in plain rendered; do
|
||||
if [ "$form" = plain ]; then conf=$plain; else conf=$rendered; fi
|
||||
n=$(printf '%s\n' "$conf" | awk '$1 == "include" && $2 == "/etc/nginx/snippets/internal-api-closed.conf;" { c++ } END { print c + 0 }')
|
||||
[ "$n" -eq 2 ] || { echo "::error::internal-api-closed.conf 를 넣은 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
|
||||
n=$(printf '%s\n' "$conf" | awk '$1 == "if" && $2 == "($request_target_rejected)" { c++ } END { print c + 0 }')
|
||||
[ "$n" -eq 2 ] || { echo "::error::원문 요청 대상 검사(if \$request_target_rejected)가 든 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
|
||||
# 앱 안 받기 · 휴대폰 앱 받기(2026-11-08)가 같은 그릇(download_app_*)을 쓴다 — 포털의 두 location 에 하나씩, 두 꼴 모두 둘이다
|
||||
for want in 'download_app_ip 30;' 'download_app_total 200;'; do
|
||||
n=$(printf '%s\n' "$conf" | awk -v want="$want" '$1 == "limit_conn" && ($2 " " $3) == want { c++ } END { print c + 0 }')
|
||||
[ "$n" -eq 2 ] || { echo "::error::limit_conn $want 이 든 location 이 둘(앱 안 받기 · 휴대폰 앱 받기)이 아닙니다 ($form: $n)"; exit 1; }
|
||||
done
|
||||
done
|
||||
# 잘못된 값은 기동을 멈춰야 한다 (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
|
||||
for value in https://files.ci.test/ https://files.ci.test/s files.ci.test 'https://files.ci.test;return 200' https://files.ci.test.; do
|
||||
if docker run --rm -e PROJECT_NAME=ci -e "SHARE_PUBLIC_URL=$value" "$tag" nginx -t; then
|
||||
echo "::error::SHARE_PUBLIC_URL=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 대용량 받기 경로(11단계)를 실제 요청으로 본다 — 백엔드(Express)는 경로의 대소문자를 가리지 않아, 받기 location(동시 연결 수 · 버퍼링 끔 ·
|
||||
# X-Accel-Buffering 넘김 · 머리글 버퍼 16k)과 포털이 닫은 공개 API 는 대소문자 · %2F · 겹 / 변형도 같은 곳을 타야 한다 (frontend/nginx.conf 머리말).
|
||||
# 위 단계의 이미지에 가짜 백엔드(같은 컨테이너의 127.0.0.1:3000 — 늘 X-Accel-Buffering: no 를 붙인다. ?hold=1 이면 10MB 를 1KB/s 로,
|
||||
# ?pad=1 이면 머리글 6KB)를 더해 네트워크 없이 띄운다. 그 머리글은 받기 location 을 탄 요청만 클라이언트까지 온다 — 그것으로 location 을 가른다.
|
||||
# 휴대폰 앱(APK) 받기(/api/app-releases/…/file — 2026-11-08)도 본다: 같은 변형이 받기 location 을 타고 앱 안 받기와 그릇(주소마다 30)을 함께 쓰는지,
|
||||
# 공개 호스트는 404 로 닫는지, 정보 경로(…/latest — JSON)는 받기 location 밖인지
|
||||
- name: 대용량 받기 경로 시험
|
||||
run: |
|
||||
base="ci-nginx-config:${{ github.run_id }}"
|
||||
tag="ci-nginx-paths:${{ github.run_id }}"
|
||||
name="ci-nginx-paths-${{ github.run_id }}"
|
||||
ctx=$(mktemp -d)
|
||||
cat > "$ctx/paths.sh" <<'EOF'
|
||||
#!/bin/sh
|
||||
P=http://127.0.0.1:8080
|
||||
T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1
|
||||
F=11111111-2222-4333-8444-555555555555
|
||||
fails=0
|
||||
# host path → "상태 xab|-"
|
||||
probe() {
|
||||
timeout 10 wget -S -O /dev/null --header "Host: $1" "$P$2" 2>&1 | awk '
|
||||
/^ HTTP\// { s = $2 }
|
||||
/ error: HTTP\// { for (i = 1; i <= NF; i++) if ($i ~ /^HTTP\//) s = $(i + 1) }
|
||||
tolower($0) ~ /^ x-accel-buffering: no/ { x = "xab" }
|
||||
END { print (s ? s : "none"), (x ? x : "-") }'
|
||||
}
|
||||
# host method path [머리글] → "상태 Content-Type code" — nginx 가 직접 끝낸 응답의 JSON
|
||||
raw() {
|
||||
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf '%s\r\n' "$4"; printf 'Connection: close\r\n\r\n'; } \
|
||||
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r' | awk '
|
||||
NR == 1 { s = $2 }
|
||||
/^[Cc]ontent-[Tt]ype:/ { t = $2 }
|
||||
/^\{/ { if (match($0, /"code":"[A-Z_0-9]+"/)) c = substr($0, RSTART + 8, RLENGTH - 9) }
|
||||
END { print s, (t ? t : "-"), (c ? c : "-") }'
|
||||
}
|
||||
check() { if [ "$got" = "$1" ]; then echo "ok $2 → $got"; else echo "FAIL $2 → $got (기대 $1)"; fails=$((fails + 1)); fi; }
|
||||
expect() { got=$(probe "$2" "$3"); check "$1" "$2$3"; }
|
||||
expect_raw() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
||||
hold() { n=0; while [ "$n" -lt "$3" ]; do wget -q -O /dev/null --header "Host: $1" "$P$2?hold=1" >/dev/null 2>&1 & n=$((n + 1)); done; sleep 3; }
|
||||
release() { killall wget >/dev/null 2>&1; sleep 2; }
|
||||
PUBLIC="/api/public-links/$T/files/$F /api/Public-Links/$T/FILES/$F /api/PUBLIC-LINKS/$T/Files/$F /api//public-links/$T/files/$F /api/public%2Dlinks/$T/files/$F"
|
||||
INAPP="/api/messages/M1/large-files/$F /api/Messages/M1/LARGE-FILES/$F /api/MESSAGES/M1/Large-Files/$F /api//messages/M1/large-files/$F /api/messages/M1%2Fx/large-files/$F"
|
||||
# 휴대폰 앱 받기 — 끝의 / · %66(f) 꼴도. 정보 경로는 받기 location 밖이다
|
||||
APK="/api/app-releases/android/latest/file /api/App-Releases/Android/Latest/FILE /api/APP-RELEASES/android/latest/File /api//app-releases/android/latest/file /api/app-releases/android%2Flatest/file /api/app-releases/android/latest/file/ /api/app-releases/android/latest/%66ile"
|
||||
INFO="/api/app-releases/android/latest /api/App-Releases/Android/Latest /api/app-releases/android/latest/"
|
||||
|
||||
echo "== 받기 location 을 탄다(X-Accel-Buffering) · 포털은 공개 API 를 닫는다 — 대소문자 · %2F · 겹 / 변형도"
|
||||
for p in $PUBLIC; do expect "200 xab" files.ci.test "$p"; expect "404 -" portal.ci.test "$p"; done
|
||||
for p in $INAPP; do expect "200 xab" portal.ci.test "$p"; done
|
||||
echo "== 휴대폰 앱 받기도 받기 location 을 탄다 · 공개 호스트는 닫는다 · 정보 경로는 /api 그대로"
|
||||
for p in $APK; do expect "200 xab" portal.ci.test "$p"; expect "404 -" files.ci.test "$p"; done
|
||||
for p in $INFO; do expect "200 -" portal.ci.test "$p"; expect "404 -" files.ci.test "$p"; done
|
||||
expect "200 -" files.ci.test "/api/Public-Links/$T"
|
||||
expect "200 -" portal.ci.test "/api/Messages/M1"
|
||||
expect "200 xab" files.ci.test "/api/Public-Links/$T/files/$F?pad=1"
|
||||
expect "200 xab" portal.ci.test "/api/Messages/M1/large-files/$F?pad=1"
|
||||
expect "200 xab" portal.ci.test "/api/App-Releases/android/latest/file?pad=1"
|
||||
echo "== nginx 가 끝내는 /api 응답은 JSON"
|
||||
expect_raw "404 application/json RES_001" files.ci.test GET /api/auth/me
|
||||
expect_raw "413 application/json FILE_002" files.ci.test POST /api/auth/login "Content-Length: 2000000"
|
||||
expect_raw "404 application/json RES_001" portal.ci.test GET "/api/Public-Links/$T/files/$F"
|
||||
expect_raw "404 application/json RES_001" files.ci.test GET /api/app-releases/android/latest/file
|
||||
echo "== 동시 연결 — 주소마다 공개 10 · 앱 안 30(휴대폰 앱 받기와 함께). 붙잡아 둔 뒤 변형으로 하나 더"
|
||||
hold files.ci.test "/api/public-links/$T/files/$F" 10
|
||||
for p in $PUBLIC; do expect "429 -" files.ci.test "$p"; done
|
||||
expect_raw "429 application/json REQ_001" files.ci.test GET "/api/Public-Links/$T/files/$F"
|
||||
expect "200 xab" portal.ci.test "/api/messages/M1/large-files/$F"
|
||||
expect "200 xab" portal.ci.test "/api/app-releases/android/latest/file"
|
||||
release
|
||||
hold portal.ci.test "/api/messages/M1/large-files/$F" 30
|
||||
for p in $INAPP $APK; do expect "429 -" portal.ci.test "$p"; done
|
||||
expect_raw "429 application/json REQ_001" portal.ci.test GET "/api/App-Releases/android/latest/FILE"
|
||||
expect "200 -" portal.ci.test "/api/app-releases/android/latest"
|
||||
expect "200 xab" files.ci.test "/api/public-links/$T/files/$F"
|
||||
release
|
||||
hold portal.ci.test "/api/app-releases/android/latest/file" 30
|
||||
for p in $APK $INAPP; do expect "429 -" portal.ci.test "$p"; done
|
||||
expect "200 -" portal.ci.test "/api/app-releases/android/latest"
|
||||
expect "200 xab" files.ci.test "/api/public-links/$T/files/$F"
|
||||
release
|
||||
echo "fails=$fails"
|
||||
[ "$fails" -eq 0 ]
|
||||
EOF
|
||||
{ printf 'FROM %s\n' "$base"; cat <<'EOF'; } > "$ctx/Dockerfile"
|
||||
RUN mkdir -p /tmp/stub && head -c 10485760 /dev/zero > /tmp/stub/big && echo ok > /tmp/stub/small \
|
||||
&& pad=$(head -c 3000 /dev/zero | tr '\0' a) \
|
||||
&& printf 'map $arg_hold $stub_rate { 1 1k; default 0; }\nmap $arg_hold $stub_file { 1 /big; default /small; }\nmap $arg_pad $stub_pad { 1 "%s"; default ""; }\nserver {\n listen 127.0.0.1:3000;\n access_log off;\n location / {\n root /tmp/stub;\n limit_rate $stub_rate;\n add_header X-Accel-Buffering no;\n add_header X-Pad1 $stub_pad;\n add_header X-Pad2 $stub_pad;\n try_files $stub_file =404;\n }\n}\n' "$pad" > /etc/nginx/conf.d/zz-stub.conf
|
||||
COPY --chmod=755 paths.sh /usr/local/bin/paths.sh
|
||||
EOF
|
||||
docker build -t "$tag" "$ctx"
|
||||
docker run -d --name "$name" --network none -e NGINX_BACKEND_UPSTREAM=http://127.0.0.1:3000 \
|
||||
-e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag"
|
||||
for _ in $(seq 1 20); do docker exec "$name" wget -q -O /dev/null --header 'Host: portal.ci.test' http://127.0.0.1:8080/ 2>/dev/null && break; sleep 1; done
|
||||
docker exec "$name" sh /usr/local/bin/paths.sh || { docker logs --tail 40 "$name"; exit 1; }
|
||||
|
||||
# nginx 가 닫는 경로를 두 server 에서 실제 요청으로 본다 (12단계 — 위 단계의 컨테이너를 그대로 쓴다). 가짜 백엔드는 무엇에든
|
||||
# 200(application/octet-stream)이라, nginx 가 직접 끝낸 JSON(code)이면 백엔드에 넘기지 않은 것이다.
|
||||
# - 엔진만 부르는 /api/internal/(엔진 웹훅, docs/mail/api-contract.md 5절) → 404 RES_001 — 대소문자 · 겹 / · %2F · %XX(글자를 퍼센트로) · %5C · .. ·
|
||||
# absolute-form · 끝 / 없음 · 있음. 1m 넘는 본문은 413 FILE_002
|
||||
# - 원문 대상의 날 \(경로) · # · 0x80 이상 바이트(0xA0) → 400 VAL_001 — /api/internal · /api/health · 공개 API · 화면 경로(server 단계 $request_target_rejected).
|
||||
# nginx 는 그 꼴을 닫는 정규식에 맞추지 못하는데 백엔드의 Express(parseurl 의 url.parse 되읽기)는 \ 를 / 로 바꿔 받았다(2026-10-31 리뷰). 쿼리의 \ 는 받는다
|
||||
# - %5C 꼴의 /api/health · 공개 API → 404 RES_001 (닫는 정규식의 [/\x5c])
|
||||
- name: 닫는 경로 시험
|
||||
run: |
|
||||
name="ci-nginx-paths-${{ github.run_id }}"
|
||||
docker exec -i "$name" sh -s <<'EOF'
|
||||
fails=0
|
||||
# host method target [머리글] → "상태 Content-Type code". WAIT 이 있으면 표준 입력을 1초 열어 둔다 — busybox nc 는 표준 입력이 끝나면 닫아
|
||||
# 백엔드 · 화면으로 간(늦게 오는) 응답을 받지 못한다
|
||||
raw() {
|
||||
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf '%s\r\n' "$4"; printf 'Connection: close\r\n\r\n'; [ -n "$WAIT" ] && sleep 1; } \
|
||||
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r' | awk '
|
||||
NR == 1 { s = $2 }
|
||||
/^[Cc]ontent-[Tt]ype:/ { t = $2 }
|
||||
/^\{/ { if (match($0, /"code":"[A-Z_0-9]+"/)) c = substr($0, RSTART + 8, RLENGTH - 9) }
|
||||
END { print s, (t ? t : "-"), (c ? c : "-") }'
|
||||
}
|
||||
# 찍을 때 0x80 이상 바이트는 ~ 로
|
||||
check() {
|
||||
label=$(printf '%s' "$2" | tr '\200-\377' '~')
|
||||
if [ "$got" = "$1" ]; then echo "ok $label → $got"; else echo "FAIL $label → $got (기대 $1)"; fails=$((fails + 1)); fi
|
||||
}
|
||||
expect() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
||||
expect_wait() { got=$(WAIT=1 raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
||||
BS=$(printf '\')
|
||||
NB=$(printf '\240')
|
||||
E=mail-engine-events
|
||||
T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1
|
||||
F=11111111-2222-4333-8444-555555555555
|
||||
for host in portal.ci.test files.ci.test; do
|
||||
echo "== $host — /api/internal 은 404"
|
||||
for path in "/api/internal/${E}" "/api/INTERNAL/${E}" "/api/Internal/Mail-Engine-Events" "/api//internal/${E}" "/api/internal//${E}" \
|
||||
"/api%2Finternal/${E}" "/api/%69nternal/${E}" "/api/%49NTERNAL/${E}" "/api/internal%5C${E}" "/api/internal%5c${E}" \
|
||||
"/api/x/../internal/${E}" "/api/./internal/${E}" "http://${host}/api/internal/${E}" /api/internal /api/internal/ /api/Internal; do
|
||||
expect "404 application/json RES_001" "$host" POST "$path"
|
||||
done
|
||||
expect "404 application/json RES_001" "$host" GET "/api/internal/${E}"
|
||||
expect "413 application/json FILE_002" "$host" POST "/api/internal/${E}" "Content-Length: 2000000"
|
||||
echo "== $host — 원문 대상의 날 \ · # · 0xA0 은 400"
|
||||
for path in "/api/internal${BS}${E}" "/api/internal${BS}${E}#x" "/api/internal${BS}${E}${NB}" "/api${BS}internal/${E}" "/api/internal/${E}#x" "/api/internal/${E}${NB}" \
|
||||
"/api/health${BS}" "/api/health${BS}#a" "/api/health#a" "/api/health${NB}" \
|
||||
"/api/public-links${BS}${T}${BS}files${BS}${F}" "/api/public-links${BS}x#a" "/api/public-links/${T}#x" "/api/public-links/${T}${NB}"; do
|
||||
expect "400 application/json VAL_001" "$host" POST "$path"
|
||||
done
|
||||
expect "404 application/json RES_001" "$host" GET "/api/health%5C"
|
||||
done
|
||||
echo "== 포털 — 공개 API 닫힘(%5C) · 화면 경로의 날 \ 은 400 · 쿼리의 \ 는 받는다 · 경계"
|
||||
expect "404 application/json RES_001" portal.ci.test GET "/api/public-links%5C${T}"
|
||||
expect "400 application/json VAL_001" portal.ci.test GET "/mail${BS}inbox"
|
||||
expect_wait "200 text/html -" portal.ci.test GET "/mail/search?q=a${BS}b"
|
||||
expect_wait "200 application/octet-stream -" portal.ci.test GET "/api/messages?q=a${BS}b"
|
||||
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internals
|
||||
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internal-x/y
|
||||
expect_wait "200 text/html -" portal.ci.test GET "/API/internal/${E}"
|
||||
echo "== 공개 호스트 — 공개 API 의 %5C 꼴은 404 · 화면 /s 의 날 \ 은 400"
|
||||
expect "404 application/json RES_001" files.ci.test GET "/api/public-links%5C${T}"
|
||||
expect "400 application/json VAL_001" files.ci.test GET "/s${BS}${T}"
|
||||
echo "fails=$fails"
|
||||
[ "$fails" -eq 0 ]
|
||||
EOF
|
||||
|
||||
# 메일 프로그램 자동 설정(13단계 — frontend/nginx.conf 의 셋째 server)을 실제 요청으로 본다 — 위 단계의 컨테이너(MAIL_DOMAIN=ci.test · MAIL_HOSTNAME=mx1.ci.test).
|
||||
# 고정 응답이어야 한다 — 질의 · POST 본문에 꾸민 주소를 넣어도 응답에 되돌아오지 않고, 백엔드로 넘기지 않는다(/api 도 404)
|
||||
- name: 메일 프로그램 자동 설정 시험
|
||||
run: |
|
||||
name="ci-nginx-paths-${{ github.run_id }}"
|
||||
docker exec -i "$name" sh -s <<'EOF'
|
||||
fails=0
|
||||
# host method path [본문] → 응답 전체(머리글 + 본문, CR 없이). 본문이 있으면 XML 로 보낸다
|
||||
raw() {
|
||||
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf 'Content-Type: text/xml\r\nContent-Length: %s\r\n' "${#4}"; printf 'Connection: close\r\n\r\n'; [ -n "$4" ] && printf '%s' "$4"; sleep 1; } \
|
||||
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r'
|
||||
}
|
||||
ok() { echo "ok $1"; }
|
||||
bad() { echo "FAIL $1"; fails=$((fails + 1)); }
|
||||
has() { if printf '%s\n' "$2" | grep -F -- "$3" >/dev/null; then ok "$1 — '$3' 있음"; else bad "$1 — '$3' 없음"; fi; }
|
||||
hasnt() { if printf '%s\n' "$2" | grep -F -i -- "$3" >/dev/null; then bad "$1 — '$3' 가 되돌아왔다"; else ok "$1 — '$3' 없음"; fi; }
|
||||
INJ='%3Cx%3Ascript%20xmlns%3Ax%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%3Ealert(1)%3C%2Fx%3Ascript%3E%40ci.test'
|
||||
r=$(raw autoconfig.ci.test GET "/mail/config-v1.1.xml?emailaddress=$INJ")
|
||||
has "Thunderbird" "$r" "HTTP/1.1 200"; has "Thunderbird" "$r" "Content-Type: text/xml; charset=utf-8"
|
||||
has "Thunderbird" "$r" "<hostname>mx1.ci.test</hostname>"; has "Thunderbird" "$r" "<port>993</port>"; has "Thunderbird" "$r" "<port>465</port>"
|
||||
has "Thunderbird" "$r" "<username>%EMAILADDRESS%</username>"; hasnt "Thunderbird" "$r" "script"; hasnt "Thunderbird" "$r" "pop3"
|
||||
r=$(raw autoconfig.ci.test GET /.well-known/autoconfig/mail/config-v1.1.xml)
|
||||
has ".well-known" "$r" "<hostname>mx1.ci.test</hostname>"
|
||||
BODY='<?xml version="1.0"?><Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><EMailAddress><script>evil@ci.test</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema></Request></Autodiscover>'
|
||||
for p in /autodiscover/autodiscover.xml /AutoDiscover/AutoDiscover.xml /Autodiscover/Autodiscover.xml; do
|
||||
r=$(raw autodiscover.ci.test POST "$p" "$BODY")
|
||||
has "Outlook $p" "$r" "HTTP/1.1 200"; has "Outlook $p" "$r" "<Server>mx1.ci.test</Server><Port>993</Port>"; has "Outlook $p" "$r" "<Type>SMTP</Type>"
|
||||
hasnt "Outlook $p" "$r" "evil"; hasnt "Outlook $p" "$r" "script"
|
||||
done
|
||||
for p in / /api/auth/me /index.html /s/x /autodiscover/autodiscover.json; do
|
||||
r=$(raw autoconfig.ci.test GET "$p"); has "그 밖 $p" "$r" "HTTP/1.1 404"
|
||||
done
|
||||
# 포털 · 공개 호스트는 이 경로를 자동 설정으로 답하지 않는다
|
||||
r=$(raw portal.ci.test GET /mail/config-v1.1.xml); hasnt "포털" "$r" "<clientConfig"
|
||||
r=$(raw files.ci.test GET /mail/config-v1.1.xml); has "공개 호스트" "$r" "HTTP/1.1 404"
|
||||
echo "fails=$fails"
|
||||
[ "$fails" -eq 0 ]
|
||||
EOF
|
||||
|
||||
# 운영 서버의 백업 · 점검 스크립트(13단계 — ops/) — shellcheck(판 · digest 고정). 설정 파일 예시에 비밀이 없는지도 본다
|
||||
- name: 운영 스크립트 검사
|
||||
run: |
|
||||
# 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다). 이 이미지에는 셸이 없다(scratch) — 그대로 shellcheck 를 부른다
|
||||
tag="ci-shellcheck:${{ github.run_id }}"
|
||||
printf 'FROM koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d\nCOPY . /mnt/ops\nWORKDIR /mnt/ops\n' | docker build -q -t "$tag" -f- ops >/dev/null
|
||||
docker run --rm "$tag" -x -P SCRIPTDIR -S style backup/arasystem-backup.sh backup/arasystem-restore.sh monitor/arasystem-check.sh lib/common.sh
|
||||
for key in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
||||
grep -Eq "^${key}=change-me$" ops/ops.env.example || { echo "::error::ops/ops.env.example 의 $key 는 change-me 여야 합니다"; exit 1; }
|
||||
done
|
||||
if grep -Eq '^HC_(BACKUP|CHECK)_URL=.+' ops/ops.env.example; then
|
||||
echo "::error::ops/ops.env.example 에 Healthchecks 주소가 들어 있습니다"; exit 1
|
||||
fi
|
||||
|
||||
# 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf.
|
||||
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만
|
||||
# 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 —
|
||||
# AlertExceedsMax · AlertEncrypted 가 꺼지면 한도를 넘거나 암호가 걸린 파일이 "깨끗함(OK)"으로 온다 (mail/README.md "악성 검사 (ClamAV)")
|
||||
- name: ClamAV 설정 검사
|
||||
run: |
|
||||
touch backend/.env.production
|
||||
# clamav · clamav-updater 가 같은 이미지라 두 줄 — 첫 줄을 awk 로 고른다(끝까지 읽는다). grep -m1 · head 는 먼저 끝나 pipefail 셸에서
|
||||
# compose 의 SIGPIPE 가 결과를 뒤집을 수 있다 (위 compose 단계와 같은 까닭)
|
||||
image=$(docker compose -f docker-compose.yml --env-file .env.production.example config --images | awk '/^clamav\/clamav:/ && !seen { print; seen = 1 }')
|
||||
[ -n "$image" ] || { echo "::error::docker-compose.yml 에서 clamav/clamav 이미지를 찾지 못했습니다"; exit 1; }
|
||||
tag="ci-clamav-config:${{ github.run_id }}"
|
||||
printf 'FROM %s\nCOPY clamd.conf freshclam.conf /etc/clamav/\n' "$image" | docker build -t "$tag" -f- mail/clamav
|
||||
conf=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav -n 2>&1)
|
||||
printf '%s\n' "$conf"
|
||||
if printf '%s\n' "$conf" | grep -E '^(ERROR|WARNING)' >/dev/null; then
|
||||
echo "::error::clamconf 가 mail/clamav 의 설정을 읽지 못했습니다 (위 ERROR 줄)"; exit 1
|
||||
fi
|
||||
for want in 'StreamMaxLength = "2146435072"' 'MaxFileSize = "2146435072"' 'MaxScanSize = "4292870144"' 'MaxScanTime = "300000"' 'AlertExceedsMax = "yes"' 'AlertEncrypted = "yes"' 'ConcurrentDatabaseReload disabled' 'TCPSocket = "3310"'; do
|
||||
printf '%s\n' "$conf" | grep -F "$want" >/dev/null || { echo "::error::clamd.conf 에 '$want' 가 없습니다 (mail/clamav/clamd.conf)"; exit 1; }
|
||||
done
|
||||
# -n 은 기본값과 같은 값을 숨긴다 — 기본값과 같지만 판정에 기대는 값(서명 다시 읽기 10분 · 진짜 서명이 휴리스틱보다 먼저)은
|
||||
# -n 없이 clamd.conf 칸에서 본다 (기본값이 바뀐 판으로 올려도 알아차리게)
|
||||
clamd=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav 2>&1 | awk '/^Config file: /{ on = ($3 == "clamd.conf") } on')
|
||||
for want in 'SelfCheck = "600"' 'HeuristicScanPrecedence disabled'; do
|
||||
printf '%s\n' "$clamd" | grep -F -x "$want" >/dev/null || { echo "::error::clamd.conf 의 값이 '$want' 가 아닙니다 (mail/clamav/clamd.conf)"; exit 1; }
|
||||
done
|
||||
|
||||
# 메일 전용 DNS 리졸버(mail-resolver)의 설정 검사 — compose 와 같은 이미지 · 같은 사용자(101:102)로 unbound-checkconf.
|
||||
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다)
|
||||
- name: unbound 설정 검사
|
||||
run: |
|
||||
touch backend/.env.production
|
||||
image=$(docker compose -f docker-compose.yml --env-file .env.production.example config --images | grep unbound)
|
||||
tag="ci-unbound-config:${{ github.run_id }}"
|
||||
printf 'FROM %s\nCOPY unbound.conf /etc/unbound/unbound.conf\n' "$image" | docker build -t "$tag" -f- mail/resolver
|
||||
docker run --rm --user 101:102 --entrypoint unbound-checkconf "$tag"
|
||||
|
||||
- name: 정리
|
||||
if: always()
|
||||
run: docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
||||
run: |
|
||||
docker rm -f "ci-nginx-paths-${{ github.run_id }}" || true
|
||||
docker image rm -f "ci-nginx-paths:${{ github.run_id }}" || true
|
||||
docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
||||
docker image rm -f "ci-unbound-config:${{ github.run_id }}" || true
|
||||
docker image rm -f "ci-clamav-config:${{ github.run_id }}" || true
|
||||
docker image rm -f "ci-shellcheck:${{ github.run_id }}" || true
|
||||
# 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다)
|
||||
docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true
|
||||
|
||||
error-codes-sync:
|
||||
name: 에러 코드 SSOT 동기화 검사
|
||||
|
||||
@@ -21,8 +21,13 @@
|
||||
/redis-data/
|
||||
# CI 러너 등록 정보
|
||||
ci/runner-data/
|
||||
# 운영 서버의 백업 · 감시 설정(13단계 — S3 키 · Healthchecks 주소 · restic 비밀번호). 서버의 /etc/arasystem/ 에 두지만 저장소 안에 잘못 만들어도 올라가지 않게
|
||||
/ops/*.env
|
||||
/ops/restic-password
|
||||
# 업로드 파일 (로컬 저장) — 코드가 아니다
|
||||
backend/uploads/
|
||||
# 대용량 첨부 파일 (11단계 — FILE_STORAGE_DIR 의 기본값 ./files, 볼륨을 붙이지 않은 개발 · 호스트 실행) — 코드가 아니다
|
||||
backend/files/
|
||||
|
||||
# ==========================================
|
||||
# 의존성/빌드 산출물
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 프로젝트 개요
|
||||
|
||||
이 저장소는 **풀스택 모노레포 기본 템플릿**이다. 누구나 클론하여 바로 개발을 시작할 수 있도록 공통 규칙과 언어별 규칙을 정의한다.
|
||||
이 저장소는 **ARA SYSTEM** — 사내 그룹웨어(첫 모듈은 메일: 웹메일 · 휴대폰 앱)다. 백엔드 · 웹 · 앱과 메일 엔진 · 운영 설정을 한 저장소에서 다루고, 여기에 공통 규칙과 스택별 규칙을 둔다.
|
||||
프로젝트 소개 · 문서 지도는 [`README.md`](README.md), 작업 차례는 [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| 디렉터리 | 스택 | 역할 | 개발 규칙 (스킬) |
|
||||
| ----------- | -------------- | ------------------------ | ----------------------------------------- |
|
||||
|
||||
@@ -1,109 +1,97 @@
|
||||
# 풀스택 모노레포 기본 템플릿
|
||||
# ARA SYSTEM
|
||||
|
||||
NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에서 운영하는 풀스택 템플릿입니다.
|
||||
세 클라이언트는 **동일한 표준 응답 포맷**과 **공통 에러 코드 맵**을 공유하며, Docker Compose로 한 번에 구동됩니다.
|
||||
사내 그룹웨어다. 첫 모듈은 **메일** — 웹메일(포털)과 휴대폰 앱(안드로이드)이다.
|
||||
메일 엔진(Stalwart)이 메일을 주고받고, 우리 서버(NestJS)가 엔진을 감싸 웹과 앱에 같은 API 를 준다. 화면은 직접 만든다(Vue 3 · Flutter).
|
||||
`@arasystem.it.kr` 메일은 2026-11-05 옛 서버(mailcow)에서 옮겨 와 전 직원이 쓰고 있다.
|
||||
|
||||
## 지금
|
||||
|
||||
- 운영: 메일 1~13단계. 메일 14단계(자체 검색 색인 · 같은 대화)와 앱 첫 판 · 휴대폰 앱 받기는 main 에 합쳤고 서버 배포를 기다린다.
|
||||
- 개발 중: 메일 15단계(메일 · 메일함 다루기). 다음은 16단계(개인 설정 — 주소록 가져오기 · 여러 서명 등).
|
||||
- 단계별 내용 · 다음 차례 · 미뤄 둔 후보는 [`docs/roadmap.md`](./docs/roadmap.md).
|
||||
|
||||
## 구성
|
||||
|
||||
| 디렉터리 | 스택 | 역할 | 포트(기본) |
|
||||
| ----------- | -------------- | ------------------------ | ---------- |
|
||||
| `backend/` | NestJS + TypeORM | REST API 서버 | 3000 |
|
||||
| `frontend/` | Vue 3 + Vite | 웹 프론트엔드 | 5173 |
|
||||
| `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 |
|
||||
| 이름 | 스택 | 하는 일 | 포트(개발) |
|
||||
| --- | --- | --- | --- |
|
||||
| `backend/` | NestJS · TypeORM | 웹 · 앱이 함께 쓰는 REST API — 메일 엔진 중계, 로그인 세션, 검색 색인, 앱 알림, 대용량 첨부 | 3000 |
|
||||
| `frontend/` | Vue 3 · Vite | 웹메일 화면. 운영은 nginx 가 화면과 같은 주소의 `/api` 를 백엔드로 넘긴다 | 5173 |
|
||||
| `flutter/` | Flutter · Riverpod | 휴대폰 앱(안드로이드 — 스토어 없이 사내 배포) | - |
|
||||
| `db` | PostgreSQL 15 | 로그인 세션 · 설정 · 검색 색인 · 대용량 첨부 기록 등 | 5432 |
|
||||
| `mail` | Stalwart | 메일 엔진(JMAP · IMAP · SMTP) — 직원 로그인 = 메일 계정 | 관리 8180(운영은 25 · 465 · 993 · 443) |
|
||||
| `mail-resolver` | unbound | 메일 엔진 전용 DNS(스팸 차단 목록 · DNSSEC) | - |
|
||||
| `clamav` | ClamAV | 대용량 첨부 악성 검사(운영은 늘, 개발은 profile `clamav`) | - |
|
||||
| `mailpit` | Mailpit | 개발 전용 — 바깥 주소로 보낸 메일을 모은다 | 8025 |
|
||||
| `redis` | Redis | 기본 꺼짐 — 서버를 여러 대로 늘릴 때의 자리 | 6379 |
|
||||
| `ops/` | 셸 · systemd | 운영 서버의 백업(restic) · 15분 점검 · 부팅 때 되살리기 | - |
|
||||
| `ci/` | Gitea Actions 러너 | PR 마다 lint · 시험 · 빌드 · 인프라 검사 | - |
|
||||
|
||||
## 문서 지도
|
||||
|
||||
| 알고 싶은 것 | 문서 |
|
||||
| --- | --- |
|
||||
| 서버 · 웹 · 앱이 함께 지키는 API 약속 | [`docs/mail/api-contract.md`](./docs/mail/api-contract.md) |
|
||||
| 웹 화면 디자인 · 화면 문구 규칙 · 단계별 화면 | [`docs/design/webmail/README.md`](./docs/design/webmail/README.md) — 디자인 원본은 같은 폴더의 `*.dc.html`(캔버스 보드) |
|
||||
| 앱 화면 | [`docs/design/app/README.md`](./docs/design/app/README.md) |
|
||||
| 메일 엔진 설정 · 운영 배포 · 백업 · 감시 · 전환 | [`mail/README.md`](./mail/README.md) |
|
||||
| 앱 개발 실행 · 사내 배포(서명 키 · APK 올리기) | [`flutter/README.md`](./flutter/README.md) |
|
||||
| CI 러너 | [`ci/README.md`](./ci/README.md) |
|
||||
| 개발 규칙(공통 · 스택별 · 역할) | [`CLAUDE.md`](./CLAUDE.md) · 아래 "개발 규칙" |
|
||||
| 로드맵 | [`docs/roadmap.md`](./docs/roadmap.md) |
|
||||
| 로고 · 색 | [`docs/brand/`](./docs/brand/) |
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
- 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 / Docker Compose — 백엔드 · DB · 메일 엔진(컨테이너 안 Node 는 24 LTS 로 고정 — docker 스킬 "Node 버전")
|
||||
- Node.js 24 LTS — 호스트에서 웹 개발 서버 · 메일 setup 스크립트를 돌릴 때
|
||||
- Flutter 3.41.9(Dart 3.11.5) — 앱(compose 대상이 아니다 — [`flutter/README.md`](./flutter/README.md))
|
||||
|
||||
## 빠른 시작 (Docker)
|
||||
## 빠른 시작 (개발)
|
||||
|
||||
```bash
|
||||
# 1) 환경변수 파일 생성 (예제 복사)
|
||||
# 실제 설정값은 루트 .env.development 한 곳에서 관리한다 (SSOT).
|
||||
# backend/frontend 는 docker-compose 가 값을 주입하므로 예제만 복사하면 된다.
|
||||
cp .env.development.example .env.development # ← 여기에만 값 입력
|
||||
cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿)
|
||||
# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요
|
||||
# 1) 환경변수 파일 — 값은 루트 .env.development 한 곳에 쓴다(SSOT)
|
||||
cp .env.development.example .env.development
|
||||
cp backend/.env.development.example backend/.env.development # 있어야 한다(JWT 등 백엔드 고유 비밀)
|
||||
|
||||
# 2) 백엔드 스택 기동 (backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail + mailpit 포함)
|
||||
# 2) 백엔드 스택 — backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail · mail-resolver · mailpit 까지
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
|
||||
# 2-1) 메일 엔진 초기화 — 처음 한 번 (여러 번 실행해도 안전, mail/README.md)
|
||||
# 3) 메일 엔진 초기화 — 처음 한 번(여러 번 돌려도 안전하다). 개발 시험 계정도 여기서 만든다(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
|
||||
|
||||
# 4) 프론트엔드는 호스트에서 직접 띄운다 (컨테이너보다 수십 배 빠르다)
|
||||
# 4) 웹은 호스트에서 띄운다 — /api 는 vite 프록시가 백엔드 컨테이너로 넘긴다
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
기동 후:
|
||||
- 웹 <http://localhost:5173> · API <http://localhost:3000/api> · Swagger <http://localhost:3000/api-docs>
|
||||
- 의존성을 바꿨을 때만 `up -d --build`(백엔드는 `--renew-anon-volumes` 도 — docker 스킬).
|
||||
- 백엔드를 처음 띄우면 watch 빌드에 수십 분이 걸릴 수 있다(Windows 바인드 마운트). 브랜치를 바꿨다고 다시 시작하지 않는다.
|
||||
- 웹을 컨테이너로 띄워야 하면 `--profile frontend`(느리다 — 호스트 vite 와 함께 띄우지 않는다). 운영(`docker-compose.yml` 단독)은 nginx 정적 호스팅이다.
|
||||
- 포트는 루트 `.env.{env}` 를 따른다(`FRONTEND_PORT` · `BACKEND_PORT` — `vite.config.ts` 가 직접 읽는다). 포트가 이미 쓰이면 다른 포트로 옮기지 않고 실패한다.
|
||||
- Redis 는 뜨지 않는다. 필요해지면 `COMPOSE_PROFILES` 에 `redis` 를 더한다.
|
||||
|
||||
- 웹: <http://localhost:5173>
|
||||
- API: <http://localhost:3000/api>
|
||||
- Swagger 문서: <http://localhost:3000/api-docs>
|
||||
전체 실행 · 배포 명령은 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md).
|
||||
|
||||
> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않습니다.**
|
||||
> vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트 너머에 있어 첫 서빙까지 수 분이 걸립니다.
|
||||
> 호스트에서 `npm run dev` 로 띄우면 1~2초이고, `/api` 요청은 vite 프록시가 백엔드 컨테이너로 넘깁니다.
|
||||
> 컨테이너로 띄워야 한다면 `--profile frontend` 를 붙이세요 (아래 "프론트엔드 실행 방식" 참조).
|
||||
> 운영(`docker-compose.yml` 단독)은 그대로 nginx 정적 호스팅으로 동작합니다.
|
||||
|
||||
> Redis 는 기본적으로 뜨지 않습니다. 필요해지면 루트 `.env.{env}` 에 `COMPOSE_PROFILES=redis` 를 설정하세요.
|
||||
|
||||
### 프론트엔드 실행 방식
|
||||
|
||||
| 방식 | 명령 | 첫 서빙 | 용도 |
|
||||
| --- | --- | --- | --- |
|
||||
| **호스트 vite (개발 기본)** | `cd frontend && npm run dev` | 1~2초 (최초 의존성 사전번들 10초 내외) | 일상 개발 |
|
||||
| 컨테이너 vite (선택) | `docker compose ... --profile frontend up -d` | 수 분 (Windows 바인드 마운트) | 호스트에 Node 를 둘 수 없을 때 |
|
||||
| nginx 정적 (운영) | `docker compose --env-file .env.production up -d --build` | - | 배포 |
|
||||
|
||||
> 컨테이너 방식을 상시 사용하려면 루트 `.env.development` 에 `COMPOSE_PROFILES=frontend` 를 넣습니다.
|
||||
> (redis 와 함께 켤 때는 `COMPOSE_PROFILES=frontend,redis`)
|
||||
> 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트를 쓰므로 **동시에 띄우지 마세요.**
|
||||
|
||||
> **포트는 루트 `.env.{env}` 를 따릅니다.** 호스트에서 띄우는 vite 도 `FRONTEND_PORT` 로 열리고,
|
||||
> `/api` 프록시 대상은 `BACKEND_PORT` 에서 나옵니다 — `vite.config.ts` 가 루트 env 를 직접 읽습니다.
|
||||
> 포트가 이미 사용 중이면 조용히 다른 포트로 옮기지 않고 **실패**합니다
|
||||
> (`CORS_ORIGIN` 과 compose 포트 매핑이 그 값에 묶여 있어, 말없이 옮겨가면 원인 모르게 어긋납니다).
|
||||
|
||||
전체 실행/배포 명령어는 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) 참조.
|
||||
|
||||
## 모바일 앱 실행 (Flutter)
|
||||
|
||||
Flutter 는 Docker Compose 대상이 아니므로 로컬에서 직접 실행합니다.
|
||||
## 앱 실행 (Flutter)
|
||||
|
||||
```bash
|
||||
cd flutter && cp .env.example .env && flutter pub get && flutter run
|
||||
```
|
||||
|
||||
> `flutter/.env` 의 `API_URL` 을 백엔드 주소로 설정합니다. (Android 에뮬레이터는 `http://10.0.2.2:3000/api`)
|
||||
`flutter/.env` 의 `API_URL` 을 백엔드 주소로 둔다(Android 에뮬레이터는 `http://10.0.2.2:3000/api`). 실기기 · 새 메일 알림 · 사내 배포는 [`flutter/README.md`](./flutter/README.md).
|
||||
|
||||
## 환경변수
|
||||
|
||||
`.env*` 는 커밋하지 않습니다. 각 디렉터리의 `*.example` 파일을 복사해 사용하세요.
|
||||
`.env*` 는 커밋하지 않는다. 각 자리의 `*.example` 을 복사해 쓴다. **루트 `.env.{env}` 가 단일 진실 공급원(SSOT)**이다 — backend · frontend 는 compose 가 루트 값을 넣어 준다
|
||||
(구조는 [`.claude/rules/env-structure.md`](./.claude/rules/env-structure.md)).
|
||||
|
||||
**루트 `.env.{env}` 가 단일 진실 공급원(SSOT)** 입니다. backend·frontend 는 docker-compose 가
|
||||
루트 값을 주입하므로 자체 env 에 값을 중복 작성하지 않습니다. (자세한 위임 구조는 [`CLAUDE.md`](./CLAUDE.md) 의 "환경변수 구조" 참조)
|
||||
| 파일 | 용도 | 값을 쓰는 곳 |
|
||||
| --- | --- | --- |
|
||||
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT(compose 가 넣어 준다) | 여기 |
|
||||
| `backend/.env.{env}.example` | 백엔드 고유 비밀(JWT · 앱 알림 키 등)만 | 최소(파일은 있어야 한다) |
|
||||
| `flutter/.env.example` | 앱 — `API_URL`(compose 대상이 아니다) | 따로 |
|
||||
|
||||
| 파일 | 용도 | 값 작성 위치 |
|
||||
| ----------------------------- | ---------------------------------------- | --------------------- |
|
||||
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
|
||||
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
|
||||
| `frontend/.env.{env}.example` | 프론트 — API 주소는 같은 출처의 `/api` 고정(빌드 arg) | 작성 불필요 |
|
||||
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
|
||||
|
||||
`{env}` = `development` | `production`
|
||||
`{env}` = `development` | `production`. 운영 서버의 백업 · 점검 설정은 `/etc/arasystem/`(`ops/ops.env.example` — mail/README "백업").
|
||||
|
||||
## 표준 응답 포맷
|
||||
|
||||
@@ -114,97 +102,74 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
|
||||
// 실패 — 사용자에게 보이는 모든 실패는 에러 코드를 가진다
|
||||
{ "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } }
|
||||
|
||||
// 목록 — data 안의 형태가 고정되어 있다
|
||||
// 목록 — data 안의 꼴이 정해져 있다
|
||||
{ "success": true, "data": { "items": [], "pageInfo": { "hasNext": false, "nextCursor": null, "total": 0 } } }
|
||||
```
|
||||
|
||||
`traceId` 는 요청마다 발급되는 추적 ID(`x-request-id` 헤더와 동일)로, 서버 로그에 같은 값이 남습니다.
|
||||
사용자 문의가 들어오면 이 값으로 로그를 검색하면 됩니다.
|
||||
`traceId` 는 요청마다 붙는 추적 ID(`x-request-id` 헤더와 같다)라 서버 로그에 같은 값이 남는다 — 문의가 오면 이 값으로 로그를 찾는다.
|
||||
`/api/health` 는 예외다(모니터링 도구가 읽는 원본 꼴).
|
||||
|
||||
> `/api/health` 는 예외입니다. 모니터링 도구가 읽는 엔드포인트라 래퍼 없이 원본 형식을 반환합니다.
|
||||
|
||||
### 에러 코드는 한 곳에서 관리됩니다
|
||||
### 에러 코드는 한 곳에서
|
||||
|
||||
```
|
||||
backend/src/common/errors/error-codes.ts ← 여기만 수정
|
||||
backend/src/common/errors/error-codes.ts ← 여기만 고친다
|
||||
│ cd backend && npm run sync:error-codes
|
||||
├─▶ frontend/src/shared/constants/error-codes.generated.ts (자동 생성)
|
||||
└─▶ flutter/lib/core/api/error_lexicon.g.dart (자동 생성)
|
||||
```
|
||||
|
||||
생성 파일은 직접 수정하지 마세요. CI 가 동기화 여부를 검사합니다.
|
||||
자세한 규칙은 [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md) 참조.
|
||||
생성 파일은 직접 고치지 않는다. CI 가 맞는지 본다 — [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md).
|
||||
|
||||
## 로컬 개발 (Docker 없이)
|
||||
|
||||
DB 는 컨테이너로 띄우고 앱만 호스트에서 실행하는 방식이 가장 간단합니다.
|
||||
## 커밋 전 검사
|
||||
|
||||
```bash
|
||||
# 1) DB 만 컨테이너로 기동 (127.0.0.1:5432 로 노출된다)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d db
|
||||
|
||||
# 2) backend — DB_HOST 기본값이 localhost 라 그대로 붙는다
|
||||
cd backend && npm ci && npm run start:dev
|
||||
|
||||
# 3) frontend — vite dev 서버가 /api 를 localhost:3000 으로 프록시한다 (개발 기본 방식)
|
||||
cd frontend && npm ci && npm run dev
|
||||
|
||||
# 4) flutter
|
||||
cd flutter && flutter pub get && flutter run
|
||||
```
|
||||
|
||||
> 프론트엔드의 API 요청은 `/api` 상대경로로 나가고 vite dev 프록시가 백엔드로 넘깁니다.
|
||||
> 백엔드를 다른 주소에서 띄웠다면 `VITE_DEV_API_PROXY` 로 대상을 바꾸세요.
|
||||
|
||||
### 커밋 전 검사
|
||||
|
||||
```bash
|
||||
cd backend && npm run lint && npm run test && npm run build
|
||||
cd frontend && npm run lint && npm run type-check && npm run test:unit
|
||||
# backend — 컨테이너 안에서(의존성 · lock 이 Linux 기준이라 호스트에서 npm install 하지 않는다)
|
||||
docker exec arasystem-backend npm run lint
|
||||
docker exec arasystem-backend npm run test # 메모리가 빠듯하면 -- --maxWorkers=2
|
||||
docker exec arasystem-backend npx jest --config ./test/jest-e2e.json # e2e — DB · 메일 엔진이 떠 있어야 한다
|
||||
# frontend · flutter — 호스트에서
|
||||
cd frontend && npm run lint && npm run type-check && npm run test:unit # 시간대가 걸린 화면을 고쳤으면 TZ=UTC 로도
|
||||
cd flutter && flutter analyze && flutter test
|
||||
```
|
||||
|
||||
lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다. 자동 수정은 `npm run lint:fix`.
|
||||
같은 검사를 CI([`.github/workflows/ci.yml`](./.github/workflows/ci.yml))가 PR 마다 수행합니다.
|
||||
컨테이너 이름은 `<PROJECT_NAME>-backend`(개발 기본 `arasystem`). lint 는 `--max-warnings 0` 이라 경고 하나도 실패다. 같은 검사를 CI(`.github/workflows/ci.yml` · `ci-flutter.yml`)가 PR 마다 한다.
|
||||
|
||||
> ⚠️ **의존성을 추가·제거할 때는 컨테이너 안에서** 실행하세요.
|
||||
> Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 잘려 Docker 빌드가 깨집니다.
|
||||
> ```bash
|
||||
> docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>
|
||||
> ```
|
||||
> ⚠️ **백엔드 의존성은 컨테이너 안에서만 바꾼다** — Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 빠져 Docker 빌드가 깨진다.
|
||||
> `docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>`
|
||||
|
||||
## 개발 규칙
|
||||
|
||||
| 영역 | 문서 |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| 공통/보안 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) |
|
||||
| 응답·에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) |
|
||||
| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) |
|
||||
| 로깅 | [`logging.md`](./.claude/rules/logging.md) |
|
||||
| 체크리스트 | [`checklist.md`](./.claude/rules/checklist.md) |
|
||||
| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) |
|
||||
| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) |
|
||||
| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) |
|
||||
| Docker | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) |
|
||||
| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) |
|
||||
| 영역 | 문서 |
|
||||
| --- | --- |
|
||||
| 입구 · 공통 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) |
|
||||
| 응답 · 에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) |
|
||||
| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) |
|
||||
| 로깅 · 보안 | [`logging.md`](./.claude/rules/logging.md) · [`security.md`](./.claude/rules/security.md) |
|
||||
| 점검 목록 | [`checklist.md`](./.claude/rules/checklist.md) |
|
||||
| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) |
|
||||
| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) |
|
||||
| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) |
|
||||
| Docker · 운영 | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) |
|
||||
| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) |
|
||||
|
||||
새 화면은 캔버스 보드로 먼저 확인받은 뒤 만든다(디자인 README). 화면 · 알림 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다.
|
||||
|
||||
## 디렉터리 구조
|
||||
|
||||
```
|
||||
.
|
||||
├── backend/ # NestJS API (Controller / Service / DTO / 공통 필터·인터셉터)
|
||||
├── frontend/ # Vue 3 (pages / components / composables / stores / router)
|
||||
├── flutter/ # Flutter (core / features / shared)
|
||||
├── docker-compose.yml # 베이스 정의
|
||||
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
|
||||
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
|
||||
├── .github/
|
||||
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 + 인프라(compose·Dockerfile·nginx) 검사
|
||||
│ └── pull_request_template.md
|
||||
├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리
|
||||
└── .claude/
|
||||
├── settings.json # Claude Code 권한·환경 설정
|
||||
├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·보안·환경변수·테스트)
|
||||
├── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker)
|
||||
└── agents/ # 역할별 규칙 (개발 3종·테스터·리뷰어·인프라·사용자/관리자 관점)
|
||||
├── backend/ # NestJS API — modules/(auth · mail · contacts · file-storage · push · ops 등) · common · shared
|
||||
├── frontend/ # Vue 3 웹메일 — pages · components · composables · stores · router, 운영 nginx 설정
|
||||
├── flutter/ # 휴대폰 앱 — core · features · shared
|
||||
├── mail/ # 메일 엔진 setup 스크립트 · DNS 리졸버 · ClamAV 설정 · 운영 문서(README)
|
||||
├── ops/ # 운영 서버의 백업 · 점검 · 부팅 되살리기(systemd)
|
||||
├── docs/ # API 계약 · 화면 디자인(캔버스 보드) · 브랜드 · 로드맵
|
||||
├── ci/ # CI 러너(Gitea Actions) — 앱 스택과 따로 띄운다
|
||||
├── data/ # 개발용 바인드 마운트 자리(내용은 커밋하지 않는다)
|
||||
├── docker-compose.yml # 운영 · 개발 공통 정의
|
||||
├── docker-compose.dev.yml # 개발 오버라이드(핫리로드 · 127.0.0.1 포트)
|
||||
├── docker-compose.remote-proxy.yml # 리버스 프록시가 다른 서버에 있을 때(운영 선택)
|
||||
├── CLAUDE.md # 개발 규칙의 입구(.claude/ 의 규칙을 불러온다)
|
||||
├── .github/ # CI 워크플로(ci.yml · ci-flutter.yml) · PR 양식
|
||||
└── .claude/ # rules(공통 규칙) · skills(스택별 규칙) · agents(역할) · 설정
|
||||
```
|
||||
|
||||
@@ -12,6 +12,8 @@ node_modules
|
||||
dist
|
||||
coverage
|
||||
uploads
|
||||
# 대용량 첨부 파일 (11단계 — 호스트에서 백엔드를 띄우면 FILE_STORAGE_DIR 기본값 ./files 에 쌓인다. 2GB 짜리가 빌드 컨텍스트에 실리지 않게)
|
||||
files
|
||||
logs
|
||||
*.log
|
||||
.env
|
||||
|
||||
@@ -20,3 +20,11 @@ JWT_SECRET=change-me-generate-a-long-random-string
|
||||
# 세션 절대 만료(로그인 후 30일)는 코드 상수다.
|
||||
JWT_ACCESS_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
# 앱 새 메일 알림 (FCM — 앱 1단계, 선택) — Firebase 서비스 계정 키 JSON 파일을 통째로 base64 한 줄로 넣는다.
|
||||
# 비우면 앱 알림만 끈다(기동은 한다 — app-config 의 pushAvailable: false). 실제 키 값은 이 예시 파일에 적지 않고 커밋하지 않는다.
|
||||
# 알림 전용 서비스 계정(역할 "Firebase Cloud Messaging API Admin" 하나)의 키를 개발 · 운영 따로 만든다(하나만 막을 수 있게).
|
||||
# 만드는 법: base64 -w0 key.json
|
||||
# (PowerShell) [Convert]::ToBase64String([IO.File]::ReadAllBytes("key.json"))
|
||||
# 모양이 틀리면 기동하지 않는다 (src/shared/config/env.validation.ts — 어느 칸이 틀렸는지만 알린다). 키가 새면 Google Cloud 콘솔에서 지우고 새 키로.
|
||||
FCM_SERVICE_ACCOUNT_KEY=
|
||||
|
||||
@@ -20,3 +20,11 @@ JWT_SECRET=change-me-generate-a-long-random-string
|
||||
# 세션 절대 만료(로그인 후 30일)는 코드 상수다.
|
||||
JWT_ACCESS_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
# 앱 새 메일 알림 (FCM — 앱 1단계, 선택) — Firebase 서비스 계정 키 JSON 파일을 통째로 base64 한 줄로 넣는다.
|
||||
# 비우면 앱 알림만 끈다(기동은 한다 — app-config 의 pushAvailable: false). 실제 키 값은 이 예시 파일에 적지 않고 커밋하지 않는다.
|
||||
# 알림 전용 서비스 계정(역할 "Firebase Cloud Messaging API Admin" 하나)의 키를 개발 · 운영 따로 만든다(하나만 막을 수 있게).
|
||||
# 만드는 법: base64 -w0 key.json
|
||||
# (PowerShell) [Convert]::ToBase64String([IO.File]::ReadAllBytes("key.json"))
|
||||
# 모양이 틀리면 기동하지 않는다 (src/shared/config/env.validation.ts — 어느 칸이 틀렸는지만 알린다). 키가 새면 Google Cloud 콘솔에서 지우고 새 키로.
|
||||
FCM_SERVICE_ACCOUNT_KEY=
|
||||
|
||||
+7
-2
@@ -25,6 +25,9 @@ RUN npm ci
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npm run build
|
||||
# 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — dev 는 이 단계에 named volume <PROJECT_NAME>_files 를 붙인다.
|
||||
# 새 볼륨은 이 디렉터리의 소유자(node)를 물려받는다 (없으면 root 소유로 생겨 node 가 쓰지 못한다). 빌드 컨텍스트의 files/ 는 .dockerignore 가 뺀다
|
||||
RUN mkdir -p /app/files
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine3.24 AS production
|
||||
ARG NPM_VERSION
|
||||
@@ -40,11 +43,13 @@ COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
# 업데이트 내역 글(글 파일 하나가 글 하나 — 계약 7절) — 서버가 기동 · 요청 때 /app/release-notes 를 읽기만 한다(modules/release-notes — 쓰지 않는다)
|
||||
COPY --from=builder /app/release-notes ./release-notes
|
||||
|
||||
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads) — node 소유로 만들어 둔다.
|
||||
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다.
|
||||
# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다.
|
||||
# (호스트 디렉터리를 바인드 마운트하면 sudo docker compose 가 root 소유로 만들어 업로드가 실패한다)
|
||||
RUN mkdir -p /app/uploads && chown node:node /app/uploads
|
||||
RUN mkdir -p /app/uploads /app/files && chown node:node /app/uploads /app/files
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
+22
-84
@@ -1,98 +1,36 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
# backend — ARA SYSTEM API (NestJS)
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
웹 · 앱이 함께 쓰는 REST API 다(`/api`). 메일 엔진(Stalwart)을 JMAP 으로 감싸고, 로그인 세션 · 설정 · 검색 색인 · 앱 알림 · 대용량 첨부 · 운영 도구를 맡는다.
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
- 모듈 지도 · 개발 규칙: [`.claude/skills/nestjs/SKILL.md`](../.claude/skills/nestjs/SKILL.md)
|
||||
- API 약속(웹 · 앱과 함께): [`docs/mail/api-contract.md`](../docs/mail/api-contract.md)
|
||||
- 공통 규칙(응답 꼴 · 에러 코드 · 로그 · 보안): [`CLAUDE.md`](../CLAUDE.md)
|
||||
|
||||
## Description
|
||||
## 실행 — 컨테이너로
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
백엔드는 늘 컨테이너로 띄운다. 의존성 · lock 이 Linux 기준이라 **호스트에서 `npm install` 하지 않는다**(바꿀 때도 컨테이너 안에서 — nestjs 스킬 "의존성 추가/제거").
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
# 저장소 루트에서 — 개발 스택(backend + db + 메일 엔진)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
- 처음 띄우면 마이그레이션을 한 번 돌리고 watch 빌드를 시작한다(Windows 바인드 마운트라 수십 분 걸릴 수 있다). 코드를 고치면 저절로 다시 뜬다.
|
||||
- Swagger: <http://localhost:3000/api-docs>
|
||||
|
||||
## 시험 · 검사
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
docker exec arasystem-backend npm run lint # 경고 0
|
||||
docker exec arasystem-backend npm run test # 단위 — 메모리가 빠듯하면 -- --maxWorkers=2
|
||||
docker exec arasystem-backend npx jest --config ./test/jest-e2e.json # e2e — DB · 메일 엔진 필요
|
||||
```
|
||||
|
||||
## Run tests
|
||||
컨테이너 이름은 `<PROJECT_NAME>-backend`(개발 기본 `arasystem`). Git Bash 에서 컨테이너 안 경로를 줄 때는 `MSYS_NO_PATHCONV=1` 을 붙인다.
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
## 자주 하는 일
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
- **에러 코드**: `src/common/errors/error-codes.ts` 만 고치고 호스트에서 `cd backend && npm run sync:error-codes`(웹 · 앱 사전이 함께 바뀐다).
|
||||
- **DB 스키마**: 엔티티를 고친 뒤 `docker exec arasystem-backend npm run migration:generate -- src/migrations/<이름>` → 만든 SQL 을 눈으로 확인 → 엔티티와 함께 커밋(nestjs 스킬 "DB 마이그레이션").
|
||||
- **운영 도구**(운영 서버 안 명령 — 입사 · 비밀번호 재설정 · 휴대폰 앱 올리기 등): `sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js` — 계약 6절 · mail/README.
|
||||
|
||||
Generated
+26
-26
@@ -33,7 +33,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"sanitize-html": "^2.18.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
@@ -5764,18 +5764,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^2.1.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"qs": "^6.16.1",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
@@ -5788,9 +5788,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -6715,6 +6715,7 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -7524,9 +7525,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8413,9 +8414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -10305,9 +10306,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",
|
||||
"integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -11340,9 +11341,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
|
||||
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -11601,12 +11602,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.0.tgz",
|
||||
"integrity": "sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==",
|
||||
"version": "2.18.1",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",
|
||||
"integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.1.0",
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"sanitize-html": "^2.18.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
@@ -90,7 +90,7 @@
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"multer": "^2.4.0"
|
||||
"multer": "^2.5.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 휴대폰 앱이 나왔고, 메일 검색과 여러 설정이 좋아졌습니다
|
||||
|
||||
- 11월 5일 새 웹메일로 옮긴 뒤 새로 생기거나 바뀐 것을 모았습니다. 앞으로 바뀌는 것도 이곳에서 알려 드리겠습니다.
|
||||
|
||||
## 휴대폰 앱
|
||||
- 안드로이드 휴대폰에서 쓰는 ARA SYSTEM 앱이 나왔습니다. 메일을 읽고 쓰고 찾을 수 있고, 답장과 전달, 첨부 파일 보내기도 됩니다.
|
||||
- 앱 받기: PC 웹메일에서 [설정] → [휴대폰 앱]을 열고 휴대폰 카메라로 QR 코드를 찍어 주세요.
|
||||
휴대폰 브라우저에서 웹메일에 로그인한 뒤 [앱 내려받기]를 누르고, 설치를 묻는 안내가 나오면 허용해 주세요.
|
||||
- 앱에는 웹메일과 같은 메일 주소와 비밀번호로 로그인합니다. 2단계 인증을 켜 두셨다면 인증 앱의 코드도 넣어 주세요.
|
||||
- 새 메일이 오면 휴대폰 알림으로 알려 드립니다. 앱의 [설정] → [새 메일 알림]에서 켜고 끌 수 있고,
|
||||
알림에는 새 메일이 몇 통인지만 나오며 보낸 사람이나 제목은 나오지 않습니다.
|
||||
- 아이폰용 앱은 아직 없습니다. 아이폰에서는 브라우저로 웹메일을 쓰거나 기본 메일 앱에 회사 메일을 연결해 주세요.
|
||||
연결 방법은 웹메일의 [설정] → [메일 프로그램 연결]에서 볼 수 있습니다.
|
||||
|
||||
## 웹메일과 휴대폰 앱
|
||||
- 메일 검색이 빨라졌고, 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수도 있습니다.
|
||||
웹메일은 검색 칸의 [조건]을, 앱은 검색 화면의 [조건으로 찾기]를 눌러 주세요.
|
||||
- 찾은 말에는 노란 표시가 붙어 목록과 본문에서 금방 찾을 수 있습니다.
|
||||
- 메일을 읽다가 [이동]을 누르면 바로 다른 메일함으로 옮길 수 있습니다.
|
||||
- 업데이트 뒤 하루쯤은 새 검색을 준비하느라 검색 화면에 안내가 나올 수 있습니다. 그동안에도 찾기는 됩니다.
|
||||
|
||||
## 웹메일 · 메일 읽기와 정리
|
||||
- 메일을 열면 그 메일과 주고받은 메일이 아래에 대화로 모여 나옵니다.
|
||||
[모두 펼치기]로 한꺼번에 읽고, 펼친 메일의 [답장]으로 바로 답할 수 있습니다.
|
||||
- 목록 위의 [대화로 묶기]를 켜면 메일 목록도 대화별로 묶입니다.
|
||||
- 목록의 메일을 끌어다 왼쪽 메일함에 놓으면 그 메일함으로 옮겨집니다.
|
||||
- 메일함 이름 옆 [⋯] → [옮기기]로 메일함을 다른 메일함 아래로 옮길 수 있습니다.
|
||||
하위 메일함이 있는 메일함도 [⋯] → [지우기]로 한꺼번에 지울 수 있고, 안에 있던 메일은 휴지통으로 옮겨집니다.
|
||||
- 보낸 사람을 차단할 때 받은편지함에 있는 그 사람의 메일도 스팸메일함으로 함께 옮길 수 있습니다.
|
||||
메일을 읽다가 [⋯] → [보낸 사람 차단]을 누르고, 창에서 받은편지함의 메일도 옮기는 칸을 켜 주세요.
|
||||
- 예약한 메일을 기다리지 않고 바로 보낼 수 있습니다. 왼쪽의 [예약됨]에서 메일을 열고 [지금 보내기]를 눌러 주세요.
|
||||
|
||||
## 웹메일 · 주소록
|
||||
- 다른 메일이나 휴대폰에서 내보낸 연락처 파일을 한 번에 가져올 수 있습니다.
|
||||
[주소록]에서 [가져오기]를 누르고 파일을 고르면, 넣기 전에 몇 명이 들어가는지 먼저 확인할 수 있습니다.
|
||||
- vCard, CSV, 엑셀 파일을 넣을 수 있고, 네이버, Google, 아이폰, Outlook에서 내보낸 파일도 됩니다.
|
||||
- 이미 주소록에 있는 연락처는 건너뜁니다. 넣은 뒤 나오는 알림의 [되돌리기]를 누르면 이번에 넣은 연락처를 한꺼번에 지울 수 있습니다.
|
||||
- 내 주소록을 파일로 내보낼 수도 있습니다. [주소록]에서 [내보내기]를 누르고 vCard나 CSV를 골라 주세요. 그룹 하나만 내보낼 수도 있습니다.
|
||||
|
||||
## 웹메일 · 설정
|
||||
- 서명을 10개까지 만들어 두고 골라 쓸 수 있습니다. [설정] → [서명]에서 [새 서명]으로 만들고,
|
||||
[메일에 넣을 서명]에서 [새 메일]과 [답장과 전달]에 넣을 서명을 따로 고를 수 있습니다.
|
||||
- 메일을 쓰다가 아래의 [서명]을 누르면 다른 서명으로 바꿀 수 있습니다.
|
||||
- 부재중 자동 응답을 굵게, 색깔 같은 글자 모양으로 꾸며 쓸 수 있습니다. [설정] → [부재중 자동 응답]
|
||||
- 부재중 자동 응답에서 회사 밖에서 온 메일에는 [사내와 같은 응답], [보내지 않기], [따로 쓰기] 가운데 골라 답할 수 있습니다.
|
||||
- 휴대폰을 바꾸셨다면 2단계 인증을 새 휴대폰으로 직접 옮길 수 있습니다.
|
||||
[설정] → [2단계 인증] → [휴대폰 바꾸기]를 누르고 안내를 따라 주세요. 지금 휴대폰의 코드와 비밀번호가 필요합니다.
|
||||
|
||||
## 그 밖에
|
||||
- 화면의 안내 글을 더 알기 쉬운 말로 다듬었습니다.
|
||||
- 앞으로 새로 생기거나 바뀌는 것은 이 업데이트 내역에 올립니다. 웹메일과 앱 모두 [설정] → [업데이트 내역]에서 볼 수 있습니다.
|
||||
@@ -0,0 +1,41 @@
|
||||
# 업데이트 내역 글
|
||||
|
||||
직원이 웹메일과 휴대폰 앱의 설정에서 읽는 「업데이트 내역」의 글입니다. 글 하나가 파일 하나이고, 웹과 앱이 같은 글을 보여 줍니다. 올리는 화면은 없습니다.
|
||||
|
||||
## 글을 더하는 법
|
||||
|
||||
1. 이 폴더에 파일을 하나 더합니다. 파일 이름은 글의 날짜입니다(`2026-11-18.md`). 같은 날 두 번째 글은 `2026-11-18-2.md`로 씁니다.
|
||||
2. 커밋하고 배포합니다(백엔드를 다시 빌드). 웹과 앱에 함께 나옵니다. Gitea 웹 화면에서 파일을 더하거나 고쳐도 됩니다.
|
||||
|
||||
고치거나 지울 때도 그 파일만 다룹니다. 목록에는 최근 글이 위에 나옵니다(날짜 차례, 같은 날은 뒤 번호가 위).
|
||||
|
||||
## 파일 안
|
||||
|
||||
```
|
||||
# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다
|
||||
<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->
|
||||
|
||||
## 휴대폰 앱
|
||||
- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.
|
||||
- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.
|
||||
알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.
|
||||
```
|
||||
|
||||
| 줄 | 뜻 |
|
||||
| --- | --- |
|
||||
| `# 제목` | 파일의 첫 줄이고 꼭 있어야 합니다. 목록의 한 줄과 글 화면의 제목이 됩니다(100자까지). |
|
||||
| `## 머리` | 묶음 머리입니다. 휴대폰 앱, 메일 검색처럼 어디 이야기인지 씁니다(40자까지). |
|
||||
| `- 한 줄` | 앞에 점이 붙은 한 줄입니다(500자까지). 쓴 글이 그대로 나옵니다. |
|
||||
| (빈칸 두 개) 글 | 앞 줄에 띄어쓰기 하나로 이어 붙습니다. 긴 줄을 파일에서 나눠 쓸 때 씁니다. |
|
||||
| `<!-- … -->` | 메모입니다. 화면에 나오지 않습니다. 줄 앞에서 시작합니다(앞에 빈칸은 있어도 되지만, 글 뒤에 붙여 쓰지는 않습니다). 여러 줄로 써도 됩니다. |
|
||||
| `- `(머리 없이) | 머리 없는 묶음입니다. 고친 것 한두 줄처럼 짧은 글에 씁니다. 제목 바로 아래에 씁니다. |
|
||||
|
||||
## 지킬 것
|
||||
|
||||
- 날짜는 파일 이름에서 읽으니 파일 안에 다시 쓰지 않습니다. 있는 날짜여야 합니다(`2026-02-30.md`는 안 됩니다).
|
||||
- 파일 이름은 글 주소가 됩니다. 날짜를 고치면 주소도 바뀌어, 그전 주소로 들어오면 없는 글 화면이 나옵니다.
|
||||
- 글 하나에 줄(`- `)이 하나는 있어야 하고, 묶음 머리 아래에도 줄이 하나는 있어야 합니다.
|
||||
- 굵게, 링크, 그림, 표, `###`, 하위 목록은 쓸 수 없습니다. 쓴 글이 그대로 나옵니다. 단추나 메뉴 이름은 `[설정] → [휴대폰 앱]`처럼 씁니다.
|
||||
- 이 폴더에는 이 안내(`README.md`)와 글 파일만 둡니다. 파일은 UTF-8로 저장합니다.
|
||||
- 형식이 틀린 글은 시험(CI)이 막습니다. 미리 보려면 backend 폴더에서 `npm run test -- release-notes`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다.
|
||||
- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다.
|
||||
@@ -8,9 +8,18 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard';
|
||||
import { OriginGuard } from '@/common/guards/origin.guard';
|
||||
import { AccountSecurityModule } from '@/modules/account-security/account-security.module';
|
||||
import { AppConfigModule } from '@/modules/app-config/app-config.module';
|
||||
import { AppReleasesModule } from '@/modules/app-releases/app-releases.module';
|
||||
import { AuthModule } from '@/modules/auth/auth.module';
|
||||
import { ContactsModule } from '@/modules/contacts/contacts.module';
|
||||
import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module';
|
||||
import { FileStorageModule } from '@/modules/file-storage/file-storage.module';
|
||||
import { HealthModule } from '@/modules/health/health.module';
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { PushModule } from '@/modules/push/push.module';
|
||||
import { ReleaseNotesModule } from '@/modules/release-notes/release-notes.module';
|
||||
import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
@@ -63,8 +72,26 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
HealthModule,
|
||||
// 직원 로그인(쿠키 JWT) — 자격 확인은 MailModule 의 메일 엔진 포트로
|
||||
AuthModule,
|
||||
// 계정 보안 (12단계) — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅 받기 (인증 모듈도 가져온다)
|
||||
AccountSecurityModule,
|
||||
// 웹메일 API (Stalwart JMAP 어댑터)
|
||||
MailModule,
|
||||
// 주소록 API — 내 주소록 · 사내 목록 (8단계, 메일 모듈도 받는 사람 추천에 쓴다)
|
||||
ContactsModule,
|
||||
// 대용량 첨부 (11단계) — 파일 · 조각 올리기 · 악성 검사(FileStorageModule)와 공개 링크 · 내려받기(FileSharingModule).
|
||||
// 메일 모듈도 가져온다(메일 → 공유 → 저장 한 방향) — 2부 파일 저장소가 같은 두 모듈을 쓴다
|
||||
FileStorageModule,
|
||||
FileSharingModule,
|
||||
// 서버 설정 (13단계) — 2단계 인증 필수 정책 · 문의 연락처(GET /api/support-contact). 운영 도구가 바꾼다
|
||||
SystemSettingsModule,
|
||||
// 앱 알림 (앱 1단계) — 등록(/api/push-registrations/current) · 끝난 세션 정리 · 보내기(FCM). 새 메일을 알아채는 쪽은 메일 모듈이다
|
||||
PushModule,
|
||||
// 앱 설정 (앱 1단계) — GET /api/app-config (로그인 없음 — 최소 판 · 알림을 보낼 수 있는지)
|
||||
AppConfigModule,
|
||||
// 휴대폰 앱 받기 (사내 배포) — 최신 판 정보 · APK 파일 (로그인 필요). 올리기는 운영 도구다
|
||||
AppReleasesModule,
|
||||
// 업데이트 내역 — 목록 · 글 (로그인 필요). 글은 저장소의 backend/release-notes 폴더(글 파일 하나가 글 하나), 올리는 API 는 없다
|
||||
ReleaseNotesModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -1,7 +1,292 @@
|
||||
import { isDraftSaveRequest, isSendMessageRequest } from './app.setup';
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ExpressAdapter,
|
||||
NestExpressApplication,
|
||||
} from '@nestjs/platform-express';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import request from 'supertest';
|
||||
|
||||
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,
|
||||
configureApp,
|
||||
createHttpAdapter,
|
||||
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} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
|
||||
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 끝의 / ·쿼리는 가리지 않고, 본문 한도는 대소문자 변형도 넓게 고른다(그 변형은 라우터에서 404)', () => {
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/drafts'],
|
||||
['POST', '/API/Drafts/'],
|
||||
@@ -29,7 +314,7 @@ describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () =>
|
||||
});
|
||||
|
||||
describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
|
||||
it('POST /api/messages 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => {
|
||||
it('POST /api/messages 는 발송 요청이다 — 끝의 / ·쿼리는 라우터처럼 가리지 않고, 본문 한도는 대소문자 변형도 넓게 고른다(그 변형은 라우터에서 404 — 크기 때문에 413 이 되지 않게)', () => {
|
||||
for (const url of [
|
||||
'/api/messages',
|
||||
'/api/messages/',
|
||||
@@ -59,3 +344,69 @@ describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** 라우팅 대소문자 시험용 처리기 — 받기 경로와 같은 모양 */
|
||||
@Controller('probe-routes')
|
||||
class ProbeRoutesController {
|
||||
@Get(':id/large-files/:fileId')
|
||||
download(): string {
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
describe('createHttpAdapter — 라우팅은 대소문자를 가린다 (2026-10-30 인프라 리뷰 High)', () => {
|
||||
const apps: NestExpressApplication[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
/** 처리기 하나만 둔 앱 — 어댑터를 주면 그것으로, 아니면 Nest 의 기본 어댑터로 */
|
||||
const createProbeApp = async (
|
||||
adapter?: ExpressAdapter,
|
||||
): Promise<NestExpressApplication> => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [ProbeRoutesController],
|
||||
}).compile();
|
||||
const app = adapter
|
||||
? moduleRef.createNestApplication<NestExpressApplication>(adapter, {
|
||||
logger: false,
|
||||
})
|
||||
: moduleRef.createNestApplication<NestExpressApplication>({
|
||||
logger: false,
|
||||
});
|
||||
apps.push(app);
|
||||
return app;
|
||||
};
|
||||
|
||||
it('소문자 경로만 처리기에 닿는다 — 대소문자 변형은 404', async () => {
|
||||
const app = await createProbeApp(createHttpAdapter());
|
||||
await app.init();
|
||||
const server = app.getHttpServer();
|
||||
await request(server).get('/probe-routes/a/large-files/b').expect(200);
|
||||
for (const path of [
|
||||
'/Probe-Routes/a/large-files/b',
|
||||
'/probe-routes/a/LARGE-FILES/b',
|
||||
'/PROBE-ROUTES/a/large-files/b',
|
||||
]) {
|
||||
await request(server).get(path).expect(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('어댑터를 만든 뒤에 켜면 늦다 — Nest 의 ExpressAdapter 가 만들어질 때 라우터를 이미 만들어 설정 값만 바뀐다', async () => {
|
||||
const app = await createProbeApp();
|
||||
app.set('case sensitive routing', true);
|
||||
await app.init();
|
||||
await request(app.getHttpServer())
|
||||
.get('/Probe-Routes/a/large-files/b')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('configureApp 은 대소문자를 가리지 않는 라우터로 만든 앱이면 멈춘다 — 기본 어댑터 · 만든 뒤에 켠 설정 모두', async () => {
|
||||
const plain = await createProbeApp();
|
||||
expect(() => configureApp(plain)).toThrow(/createHttpAdapter/);
|
||||
|
||||
const late = await createProbeApp();
|
||||
late.set('case sensitive routing', true);
|
||||
expect(() => configureApp(late)).toThrow(/createHttpAdapter/);
|
||||
});
|
||||
});
|
||||
|
||||
+217
-20
@@ -1,14 +1,46 @@
|
||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import {
|
||||
ExpressAdapter,
|
||||
type NestExpressApplication,
|
||||
} from '@nestjs/platform-express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import express, {
|
||||
json,
|
||||
type Express,
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from 'express';
|
||||
import helmet from 'helmet';
|
||||
import type { IncomingMessage } from 'http';
|
||||
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 { requestTargetMiddleware } from '@/common/middleware/request-target.middleware';
|
||||
import { MAIL_ENGINE_EVENTS_SHAPE_LIMITS } from '@/modules/account-security/account-security.constants';
|
||||
import {
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
type AccountSecuritySettings,
|
||||
} from '@/modules/account-security/account-security.settings';
|
||||
import {
|
||||
createMailEngineEventsBodyParser,
|
||||
isMailEngineEventsRequest,
|
||||
} from '@/modules/account-security/mail-engine-events.parser';
|
||||
import {
|
||||
FILE_SHARING_SETTINGS,
|
||||
type FileSharingSettings,
|
||||
} from '@/modules/file-sharing/file-sharing.settings';
|
||||
import { createPublicHostMiddleware } from '@/modules/file-sharing/public-host';
|
||||
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 과 일치) */
|
||||
@@ -28,17 +60,69 @@ export const JSON_BODY_LIMIT = '1mb';
|
||||
*/
|
||||
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,
|
||||
};
|
||||
|
||||
/** Express 설정 이름 — 라우팅이 대소문자를 가린다 (createHttpAdapter) */
|
||||
const CASE_SENSITIVE_ROUTING = 'case sensitive routing';
|
||||
|
||||
/**
|
||||
* 앱을 만들 HTTP 어댑터 — main.ts 와 e2e 가 **같은 함수**를 쓴다(NestFactory.create · createNestApplication 의 어댑터 인자).
|
||||
*
|
||||
* 라우팅은 **대소문자를 가린다** — `/api/Messages/…` · `…/LARGE-FILES/…` · `/api/public-links/…/FILES/…` · `/API/…` 는 처리기에 닿지 않고 404 RES_001.
|
||||
* 대소문자를 가리지 않으면 앞단 nginx 의 받기 location(대소문자를 가리는 정규식 · prefix)이 놓친 변형이 받기 처리기까지 와서
|
||||
* 동시 연결 상한(limit_conn)과 버퍼링 끄기를 비켜 갔다(2026-10-30 인프라 리뷰 High — nginx 도 고친다). 클라이언트(웹 · 앱)는 모두 소문자 경로다.
|
||||
*
|
||||
* Express 5 는 라우터를 처음 꺼낼 때 만들며 그때 이 설정을 읽는데, Nest 의 ExpressAdapter 는 만들어질 때 이미 `app.use` 를 부른다
|
||||
* (express-adapter.js 의 생성자) — 그래서 configureApp() 에서 켜면 늦다(2026-10-30 확인: express 5.2.1 · router 2.2.0 · @nestjs/platform-express 11.2.5).
|
||||
* Express 인스턴스를 직접 만들어 어댑터보다 먼저 켠다. configureApp 이 맨 앞에서 라우터가 정말 대소문자를 가리는지 확인한다
|
||||
*/
|
||||
export function createHttpAdapter(): ExpressAdapter {
|
||||
const instance = express();
|
||||
instance.enable(CASE_SENSITIVE_ROUTING);
|
||||
return new ExpressAdapter(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 앱의 라우터가 대소문자를 가리는가 — Express 5 의 `app.router` 가 만들어질 때 받은 값(router 패키지가 `caseSensitive` 로 둔다).
|
||||
* 설정 값(`app.enabled`)이 아니라 라우터를 본다 — 앱을 만든 뒤에 설정을 켜면 값은 켜져도 라우터는 그대로라서다.
|
||||
* router 가 이 이름을 바꾸면 여기서 기동이 멈춘다 — 그때는 e2e 의 대소문자 테스트로 확인하고 고친다
|
||||
*/
|
||||
function routesAreCaseSensitive(app: NestExpressApplication): boolean {
|
||||
const instance: unknown = app.getHttpAdapter().getInstance();
|
||||
const router: unknown = (instance as Partial<Express> | null)?.router;
|
||||
return (
|
||||
typeof router === 'function' &&
|
||||
(router as { caseSensitive?: unknown }).caseSensitive === true
|
||||
);
|
||||
}
|
||||
|
||||
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
|
||||
const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`;
|
||||
|
||||
/** 라우터가 보는 경로 — Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다 */
|
||||
/**
|
||||
* 본문 한도를 고를 경로 — 쿼리를 빼고, 라우터처럼 끝의 '/' 는 가리지 않는다.
|
||||
* 대소문자는 가리지 않는다 — 라우터는 가리지만(createHttpAdapter) 본문 한도는 넓게 고른다: 발송 · 임시 저장 경로의 대소문자 변형도 같은 4MB 파서로 읽은 뒤
|
||||
* 라우터에서 404 RES_001 이 된다(본문 크기 때문에 413 이 되지 않고 다른 대소문자 변형처럼 404 로 같게)
|
||||
*/
|
||||
function routedPath(req: Pick<IncomingMessage, 'url'>): string {
|
||||
return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 요청인가 — `POST /api/messages`.
|
||||
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다.
|
||||
* 라우터가 발송 핸들러로 보내는 모양(끝의 '/' · 쿼리)이면 모두 해당하고, 대소문자 변형도 넓게 해당한다 — 그 변형은 라우터에서 404 (위 routedPath).
|
||||
*/
|
||||
export function isSendMessageRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
@@ -61,6 +145,87 @@ export function isDraftSaveRequest(
|
||||
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 변환 · 검사 전에 끊도록 남겨 둔다.
|
||||
* 엔진 웹훅(12단계 — `POST /api/internal/mail-engine-events`)만 그 경로의 한도(MAIL_ENGINE_EVENTS_SHAPE_LIMITS)로 본다
|
||||
*/
|
||||
export function rejectOversizedJsonBody(
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const body: unknown = req.body;
|
||||
const limits = isMailEngineEventsRequest(req)
|
||||
? MAIL_ENGINE_EVENTS_SHAPE_LIMITS
|
||||
: JSON_SHAPE_LIMITS;
|
||||
const problem = findJsonShapeProblem(body, 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'] ?? '')
|
||||
@@ -74,14 +239,33 @@ function isJsonBody(req: IncomingMessage): boolean {
|
||||
* HTTP 파이프라인 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다
|
||||
* (따로 적으면 e2e 가 실제 응답과 다른 설정으로 돌게 된다).
|
||||
*
|
||||
* 앱은 `{ bodyParser: false }` 로 만들어야 한다 — 본문 파서를 여기서 직접 등록한다.
|
||||
* 앱은 `createHttpAdapter()` 와 `{ bodyParser: false }` 로 만들어야 한다 — 라우팅의 대소문자 구분은 어댑터를 만들 때 켜고(여기서는 늦다),
|
||||
* 본문 파서는 여기서 직접 등록한다. 대소문자를 가리지 않는 라우터로 만든 앱이면 기동을 멈춘다.
|
||||
* CORS·Swagger·종료 훅은 배포 환경에 묶인 설정이라 main.ts 에 둔다.
|
||||
*/
|
||||
export function configureApp(app: NestExpressApplication): void {
|
||||
if (!routesAreCaseSensitive(app)) {
|
||||
throw new Error(
|
||||
'라우팅이 대소문자를 가리지 않습니다 — 앱은 createHttpAdapter() 로 만든 어댑터로 만들어야 합니다(app.setup.ts). ' +
|
||||
'예: NestFactory.create(AppModule, createHttpAdapter(), { bodyParser: false })',
|
||||
);
|
||||
}
|
||||
|
||||
// 요청 추적 ID — **가장 먼저** 붙인다. 본문 파서·helmet 보다 앞이라 그 단계의 실패(413·400·415)에도
|
||||
// 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다
|
||||
app.use(requestIdMiddleware);
|
||||
|
||||
// 요청 대상 원문 검사 — 날 `\` · `#` · 제어 문자 · 0x80 이상 글자가 있으면 라우팅 전에 400 VAL_001 (common/middleware/request-target.middleware.ts).
|
||||
// Express 의 parseurl 이 그런 대상을 옛 url.parse 로 다시 읽으며 `\` 를 `/` 로 바꿔, 앞단 nginx 가 정규식으로 닫은 경로가 여기서 열렸다 (2026-10-31)
|
||||
app.use(requestTargetMiddleware);
|
||||
|
||||
// 공개 호스트(대용량 첨부의 내려받기 주소 — 11단계)를 포털과 따로 두면 그 호스트에서는 공개 링크 API 만, 포털에서는 공개 링크 API 를 닫는다.
|
||||
// 본문 파서 앞이라 막은 요청의 본문을 읽지 않는다. 같은 주소면(개발) 아무것도 막지 않는다 (modules/file-sharing/public-host.ts)
|
||||
const sharing = app.get<FileSharingSettings>(FILE_SHARING_SETTINGS, {
|
||||
strict: false,
|
||||
});
|
||||
app.use(createPublicHostMiddleware(sharing.dedicatedPublicHost));
|
||||
|
||||
// 믿을 프록시 뒤에서 실제 클라이언트 IP 를 읽는다 — 로그인 실패 한도·요청 수 한도의 기준 (TRUST_PROXY).
|
||||
// 운영 체인: 클라이언트 → 외부 리버스 프록시 → 프론트 nginx → 백엔드. 값이 없으면(비운영) 1
|
||||
app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
|
||||
@@ -103,23 +287,36 @@ export function configureApp(app: NestExpressApplication): void {
|
||||
//
|
||||
// 경로별 한도: 발송·임시 저장 요청만 4MB 파서가 읽고(type 판정으로 고른다), 나머지는 1MB 파서가 읽는다.
|
||||
// 같은 요청을 두 번 읽지 않는다 — body-parser 는 이미 다 읽은 요청을 건너뛴다(read.js 의 isFinished 확인).
|
||||
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다
|
||||
app.useBodyParser('json', {
|
||||
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
|
||||
inflate: false,
|
||||
type: (req: IncomingMessage) =>
|
||||
(isSendMessageRequest(req) || isDraftSaveRequest(req)) && isJsonBody(req),
|
||||
});
|
||||
app.useBodyParser('json', { limit: JSON_BODY_LIMIT, inflate: false });
|
||||
|
||||
// 전역 DTO 검증
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true, // DTO 에 없는 값은 버린다
|
||||
forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400
|
||||
transform: true, // DTO·기본 타입으로 변환
|
||||
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다.
|
||||
// 두 파서 모두 읽기 전 확인(verify — UTF-8 만 · 모양 한도)을 건다 — Nest 의 useBodyParser 는 verify 를 rawBody 용으로 남겨 두고 받지 않아(타입에서 뺀다)
|
||||
// 같은 파서(express.json = body-parser)를 직접 붙인다 (useBodyParser 도 안에서 이렇게 붙인다)
|
||||
//
|
||||
// 엔진 웹훅(12단계 — 계약 5절 "엔진 웹훅 받기")은 맨 앞의 자기 파서가 읽는다: 본문을 받기 전에 소켓 주소(앱 네트워크) · 서명 키 유무를 보고
|
||||
// 아니면 본문을 받지 않고 401 AUTH_001(연결을 닫는다), 서명(HMAC)은 원문을 받은 뒤 JSON 으로 읽기 전에 본다.
|
||||
// 한도는 4MB · 배열 10,000 · 값 200,000 (modules/account-security/mail-engine-events.parser.ts)
|
||||
const accountSecurity = app.get<AccountSecuritySettings>(
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
{ strict: false },
|
||||
);
|
||||
app.use(createMailEngineEventsBodyParser(accountSecurity));
|
||||
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());
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
|
||||
import { OpsCli } from '@/modules/ops/ops-cli';
|
||||
import { OPS_ALLOW_NO_TTY_ENV, TerminalIo } from '@/modules/ops/ops-io';
|
||||
import { OpsModule } from '@/modules/ops/ops.module';
|
||||
|
||||
/**
|
||||
* 운영 도구 (13단계 — 계약 6절 "운영 도구") — 서버 안에서 사람이 돌리는 명령:
|
||||
* sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js
|
||||
* 한국어 메뉴로 묻고, 바뀔 것을 보여 주고, y/N 으로 확인받은 뒤 실행한다. 한 일은 ops_audit_log 에 남는다.
|
||||
* Nest 앱 컨텍스트로 띄운다 — HTTP 서버 · cron · 실시간 엔진 연결 · 악성 검사 · 마이그레이션 없이 (modules/ops/ops.module.ts).
|
||||
* 화면에는 경고 · 오류 로그만 섞인다(info 로그는 끈다)
|
||||
*/
|
||||
async function main(): Promise<number> {
|
||||
const app = await NestFactory.createApplicationContext(OpsModule, {
|
||||
logger: ['error', 'warn'],
|
||||
});
|
||||
// 표준 입력이 터미널이 아니면 도구가 비밀번호를 묻기 전에 멈춘다(-it 를 빠뜨림). 자동 시험만 OPS_ALLOW_NO_TTY=1 로 파이프를 받는다
|
||||
const io = new TerminalIo(process.stdin, process.stdout, {
|
||||
allowNonInteractive: process.env[OPS_ALLOW_NO_TTY_ENV] === '1',
|
||||
});
|
||||
try {
|
||||
return await app.get(OpsCli).run(io);
|
||||
} finally {
|
||||
io.close();
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then((code) => {
|
||||
process.exitCode = code;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
process.stderr.write(
|
||||
`운영 도구를 시작하지 못했습니다: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 제한된 세션 (계약 6절 "제한된 세션" — 13단계) — 로그인은 했지만 먼저 해야 할 일이 있는 세션.
|
||||
* - passwordChange: 엔진의 비밀번호가 만료됐다(관리자가 준 임시 비밀번호) — 새 비밀번호를 정할 때까지
|
||||
* - twoFactorSetup: 회사가 2단계 인증을 필수로 정했고 시작일이 지났는데 아직 켜지 않았다 — 켤 때까지
|
||||
* 차례는 passwordChange 가 먼저다. 제한은 세션 표(auth_sessions.restriction)와 액세스 토큰(`rst`)에 있다
|
||||
*/
|
||||
export const SESSION_RESTRICTIONS = [
|
||||
'passwordChange',
|
||||
'twoFactorSetup',
|
||||
] as const;
|
||||
export type SessionRestriction = (typeof SESSION_RESTRICTIONS)[number];
|
||||
|
||||
export function isSessionRestriction(
|
||||
value: unknown,
|
||||
): value is SessionRestriction {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(SESSION_RESTRICTIONS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** 메타데이터 키 — JwtAuthGuard 가 읽는다 */
|
||||
export const ALLOW_RESTRICTED_SESSION = 'allowRestrictedSession';
|
||||
|
||||
/**
|
||||
* 제한된 세션으로도 부를 수 있는 핸들러 · 컨트롤러 (계약 6절) — `/api/auth/*`(me · password · sessions) ·
|
||||
* `/api/two-factor` · `enrollments` · `activation` 만 단다. 달지 않은 보호 라우트는 제한된 세션이면 403 AUTH_010 이다(JwtAuthGuard).
|
||||
* 로그인 없이 부르는 라우트(로그인 · 갱신 · 로그아웃 · 문의 연락처)는 가드가 없어 상관없다
|
||||
*/
|
||||
export const AllowRestrictedSession = (): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(ALLOW_RESTRICTED_SESSION, true);
|
||||
@@ -69,3 +69,28 @@ export function ApiErrorResponse(code: ErrorCode, description?: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** AUTH_001 의 기본 설명 — 로그인이 필요한 API */
|
||||
const SESSION_REQUIRED_DESCRIPTION =
|
||||
'로그인 필요 (쿠키 · 앱의 Bearer 토큰 없음 · 만료 · 끊은 세션 · 웹 · 앱 토큰을 바꿔 씀) — 클라이언트는 갱신(웹 /auth/refresh · 앱 /auth/app/refresh)을 한 번 시도한다';
|
||||
|
||||
/**
|
||||
* 로그인이 필요한 API 의 공통 실패 (13단계 — 계약 6절) — 401 AUTH_001(로그인 필요) + 403 AUTH_010(제한된 세션 — 비밀번호 바꾸기 · 2단계 켜기 전).
|
||||
* `@UseGuards(JwtAuthGuard)` 를 건 컨트롤러(클래스)에 함께 붙인다 — 가드가 두 실패를 모두 낸다. 핸들러에 같은 상태 코드를 따로 적으면 그것이 이긴다.
|
||||
* 제한된 세션도 부르는 곳(`@AllowRestrictedSession()` — 인증 · 로그인된 브라우저 · 2단계 상태 · 등록 · 켜기)에는 붙이지 않는다 — AUTH_001 만 적는다
|
||||
*
|
||||
* 사용 예:
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* @ApiSessionErrors()
|
||||
*/
|
||||
export function ApiSessionErrors(
|
||||
description: string = SESSION_REQUIRED_DESCRIPTION,
|
||||
) {
|
||||
return applyDecorators(
|
||||
ApiErrorResponse('AUTH_001', description),
|
||||
ApiErrorResponse(
|
||||
'AUTH_010',
|
||||
'제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전) — 이 API 는 부를 수 없다. 클라이언트는 알림 없이 me 로 제한을 다시 받는다 (13단계)',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { SessionRestriction } from './allow-restricted-session.decorator';
|
||||
|
||||
/**
|
||||
* 로그인 세션을 연 클라이언트 (계약 1절 "앱 로그인") — 웹(브라우저 · 쿠키) · 앱(모바일 · Bearer).
|
||||
* 세션 표의 `client` 칸과 같은 값이다. 세션 규칙은 같고 토큰을 나르는 길만 다르다
|
||||
*/
|
||||
export type SessionClient = 'web' | 'app';
|
||||
|
||||
/** 인증된 직원 — 액세스 토큰에서 복원한 값 (요청마다 메일 엔진에 묻지 않는다) */
|
||||
export interface AuthenticatedUser {
|
||||
@@ -9,6 +16,31 @@ export interface AuthenticatedUser {
|
||||
readonly email: string;
|
||||
/** 표시 이름 */
|
||||
readonly name: string;
|
||||
/**
|
||||
* 액세스 토큰이 끝나는 시각 (epoch ms) — 가드(`JwtAuthGuard` · `JwtStrategy`)를 거친 요청에만 있다.
|
||||
* 로그인 · 갱신 응답의 직원 정보에는 없다. 오래 열린 연결(실시간 알림)을 토큰이 끝나기 전에 닫는 데 쓴다
|
||||
*/
|
||||
readonly accessTokenExpiresAt?: number;
|
||||
/**
|
||||
* 이 액세스 토큰의 로그인 세션 id (auth_sessions.id) — 가드를 거친 요청에만, 12단계 뒤에 발급한 토큰에만 있다.
|
||||
* 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝내는 데 쓴다 (계약 5절 "로그인된 브라우저")
|
||||
*/
|
||||
readonly sessionId?: string;
|
||||
/**
|
||||
* 앱 토큰(`Authorization: Bearer` — 표시 `cli: 'app'`)으로 들어온 요청이면 'app' — 가드를 거친 요청에만 있다. 웹(쿠키)이면 없다.
|
||||
* 앱 요청은 리프레시 쿠키가 없으므로 "지금 세션"을 액세스 토큰의 sid 로 안다 (계약 1절 "앱 로그인 › 지금 세션")
|
||||
*/
|
||||
readonly client?: SessionClient;
|
||||
/**
|
||||
* 제한된 세션이면 그 제한 (계약 6절 "제한된 세션" — 13단계) — 액세스 토큰의 `rst`. 없으면(null · 없음) 제한이 없다.
|
||||
* JwtAuthGuard 가 허용한 핸들러(@AllowRestrictedSession) 밖이면 403 AUTH_010 으로 막는다
|
||||
*/
|
||||
readonly restriction?: SessionRestriction | null;
|
||||
/**
|
||||
* 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 액세스 토큰의 `tfd`. 아니면 null · 없음.
|
||||
* 로그인 · 갱신 때 정해 토큰에 싣는다 (`me` 가 엔진 · DB 에 묻지 않고 답한다)
|
||||
*/
|
||||
readonly twoFactorDeadline?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Header } from '@nestjs/common';
|
||||
|
||||
/** 공유 캐시 · 브라우저에 남기지 않는다 — 내 파일 · 링크처럼 직원만 보는 응답 (계약 4절 "공개 경로 밖의 응답은 private, no-store") */
|
||||
export const PRIVATE_NO_STORE = 'private, no-store';
|
||||
|
||||
/** 성공 응답에 `Cache-Control: private, no-store` 를 붙인다 (핸들러마다) */
|
||||
export const NoStore = (): MethodDecorator =>
|
||||
Header('Cache-Control', PRIVATE_NO_STORE);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
|
||||
import { TrimString, TrimStringOrNull } from './trim-string.decorator';
|
||||
|
||||
class Named {
|
||||
@TrimString()
|
||||
name!: unknown;
|
||||
}
|
||||
|
||||
class Optional {
|
||||
@TrimStringOrNull()
|
||||
name!: unknown;
|
||||
}
|
||||
|
||||
const nameOf = (name: unknown) => plainToInstance(Named, { name }).name;
|
||||
const optionalOf = (name: unknown) => plainToInstance(Optional, { 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 ']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TrimStringOrNull — 앞뒤 공백을 지우고 빈 글은 null', () => {
|
||||
it('지운 뒤 남은 글이 없으면 null, 있으면 지운 글', () => {
|
||||
expect(optionalOf(' 한빛상사 ')).toBe('한빛상사');
|
||||
expect(optionalOf('\n첫 줄\n둘째 줄\n')).toBe('첫 줄\n둘째 줄');
|
||||
for (const blank of ['', ' ', '\t\n\u00a0\u3000']) {
|
||||
expect(optionalOf(blank)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('문자열이 아니면 그대로 둔다 — null 은 null, 키가 없으면 undefined(형식 검사가 막는다)', () => {
|
||||
expect(optionalOf(null)).toBeNull();
|
||||
expect(optionalOf(7)).toBe(7);
|
||||
expect(plainToInstance(Optional, {}).name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 앞뒤 공백을 지우고, 남은 글이 없으면 null 로 바꾼다 — "값 없음"을 null 로 받는 칸(연락처의 이름 · 회사 · 부서 · 직책 · 메모 —
|
||||
* 계약 3절 "빈 글은 null")이 쓴다. 검사는 바꾼 값으로 한다 (null 이면 ValidateIf 로 건너뛰게 둔다). 문자열이 아니면 그대로 둔다
|
||||
*/
|
||||
export function TrimStringOrNull(): PropertyDecorator {
|
||||
return Transform(trimStringOrNullValue);
|
||||
}
|
||||
|
||||
/** TrimStringOrNull 의 변환 */
|
||||
export function trimStringOrNullValue({ value }: TransformFnParams): unknown {
|
||||
const raw: unknown = value;
|
||||
if (typeof raw !== 'string') return raw;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === '' ? null : trimmed;
|
||||
}
|
||||
@@ -44,6 +44,73 @@ 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('제한된 세션(AUTH_010 — 13단계)은 403 이고, 403 의 상태 역매핑은 그대로 AUTH_003 이다', () => {
|
||||
expect(ERROR_CODES.AUTH_010.status).toBe(HttpStatus.FORBIDDEN);
|
||||
// AUTH_010 은 가드가 명시적으로만 던진다 — 프레임워크의 403 은 권한 부족(AUTH_003)으로 둔다
|
||||
expect(resolveCodeByStatus(HttpStatus.FORBIDDEN)).toBe('AUTH_003');
|
||||
});
|
||||
|
||||
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('MAIL_013 의 문구는 필터와 차단 목록(9단계)을 함께 말한다 — 같은 엔진 규칙의 한도라서', () => {
|
||||
expect(ERROR_CODES.MAIL_013.message).toContain('필터');
|
||||
expect(ERROR_CODES.MAIL_013.message).toContain('차단');
|
||||
});
|
||||
|
||||
it('이미 보낸 · 기다리는 발송이 아닌 메일의 취소(MAIL_014)는 409 다 (9단계)', () => {
|
||||
expect(ERROR_CODES.MAIL_014.status).toBe(HttpStatus.CONFLICT);
|
||||
});
|
||||
|
||||
it('붙잡힌 메일을 옮기거나 지우기(MAIL_015)는 409 다 — 취소로만 보낸편지함을 떠난다 (9단계)', () => {
|
||||
expect(ERROR_CODES.MAIL_015.status).toBe(HttpStatus.CONFLICT);
|
||||
});
|
||||
|
||||
it('주소록 — 같은 이름의 그룹(CONTACT_001)과 주소록 한도(CONTACT_002)는 409 다', () => {
|
||||
expect(ERROR_CODES.CONTACT_001.status).toBe(HttpStatus.CONFLICT);
|
||||
expect(ERROR_CODES.CONTACT_002.status).toBe(HttpStatus.CONFLICT);
|
||||
});
|
||||
|
||||
it('대용량 첨부(11단계) — 상태가 맞지 않음 409 FILE_003 · 조각 손상 400 FILE_004 · 보관 한도 409 FILE_005 · 서버 공간 507 FILE_006 · 더 받을 수 없음 410 FILE_007 · 검사 중 409 FILE_008 · 쓸 수 없는 파일 400 MAIL_016', () => {
|
||||
expect(ERROR_CODES.FILE_003.status).toBe(HttpStatus.CONFLICT);
|
||||
expect(ERROR_CODES.FILE_004.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
expect(ERROR_CODES.FILE_005.status).toBe(HttpStatus.CONFLICT);
|
||||
expect(ERROR_CODES.FILE_006.status).toBe(HttpStatus.INSUFFICIENT_STORAGE);
|
||||
expect(ERROR_CODES.FILE_007.status).toBe(HttpStatus.GONE);
|
||||
expect(ERROR_CODES.FILE_008.status).toBe(HttpStatus.CONFLICT);
|
||||
expect(ERROR_CODES.MAIL_016.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
});
|
||||
|
||||
it('410 · 507 은 상태 역매핑에 넣지 않는다 — FILE_007 · FILE_006 은 BusinessException 으로만 나온다', () => {
|
||||
expect(resolveCodeByStatus(HttpStatus.GONE)).toBe('BIZ_001');
|
||||
expect(resolveCodeByStatus(HttpStatus.INSUFFICIENT_STORAGE)).toBe(
|
||||
'SYS_001',
|
||||
);
|
||||
});
|
||||
|
||||
it('대용량 첨부 한도 문구에는 숫자가 없다 — 한도가 env 로 바뀐다', () => {
|
||||
for (const code of ['FILE_005', 'FILE_006', 'MAIL_016'] as const) {
|
||||
expect(ERROR_CODES[code].message).not.toMatch(/\d/);
|
||||
}
|
||||
});
|
||||
|
||||
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
|
||||
const clientOnlyCodes = Object.entries(ERROR_CODES)
|
||||
.filter(([, definition]) => 'clientOnly' in definition)
|
||||
|
||||
@@ -20,42 +20,81 @@ export interface ErrorCodeDefinition {
|
||||
const COMMON_ERROR_CODES = {
|
||||
SYS_001: {
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
message: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message:
|
||||
'문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
REQ_001: {
|
||||
status: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message:
|
||||
'너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
AUTH_001: {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
AUTH_002: {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
message: '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.',
|
||||
// 세션 만료는 클라이언트가 "인증된 세션이 있었는데 401 을 받은" 상황에서 생성한다.
|
||||
clientOnly: true,
|
||||
},
|
||||
AUTH_003: {
|
||||
status: HttpStatus.FORBIDDEN,
|
||||
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
message: '이 기능을 쓸 수 있는 권한이 없습니다.',
|
||||
},
|
||||
// 로그인 자격 증명 불일치 — 계정 없음/비밀번호 틀림을 구분하지 않는다 (.claude/rules/security.md)
|
||||
AUTH_004: {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
message: '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.',
|
||||
},
|
||||
// 비밀번호 바꾸기(PUT /api/auth/password)의 지금 비밀번호가 틀림 — 세션은 그대로다.
|
||||
// 401 이 아니라 400 인 까닭: 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 (계약 1절)
|
||||
AUTH_005: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '지금 비밀번호가 올바르지 않습니다.',
|
||||
},
|
||||
// 2단계 인증 코드가 틀렸거나 2분 안에 다시 쓴 코드 — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기 (계약 5절).
|
||||
// 401 이 아닌 까닭은 AUTH_005 와 같다(같은 코드를 로그인한 채 쓰는 설정에서도 쓴다)
|
||||
AUTH_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.',
|
||||
},
|
||||
// 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절)
|
||||
AUTH_007: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '인증 앱의 코드를 입력해 주세요.',
|
||||
},
|
||||
// 2단계 인증 등록이 없음 · 10분 지남 · 그사이 다른 곳에서 바뀜 — 화면은 처음부터 다시 (계약 5절)
|
||||
AUTH_008: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.',
|
||||
},
|
||||
// 앱 비밀번호 상한(엔진 기본 5개) — 엔진 상한을 바꾸면 이 문구와 화면 상수도 함께 (계약 5절)
|
||||
AUTH_009: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
|
||||
},
|
||||
// 제한된 세션(비밀번호 바꾸기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API 를 불렀다.
|
||||
// 화면은 알림 없이 me 를 다시 받아 그 제한의 단계로 간다 (계약 6절 "제한된 세션"). 403 의 상태 역매핑은 그대로 AUTH_003 이다
|
||||
AUTH_010: {
|
||||
status: HttpStatus.FORBIDDEN,
|
||||
message: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.',
|
||||
},
|
||||
VAL_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
message:
|
||||
'입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.',
|
||||
},
|
||||
RES_001: {
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
message: '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.',
|
||||
},
|
||||
BIZ_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
message: '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
@@ -72,16 +111,52 @@ const COMMON_ERROR_CODES = {
|
||||
const DOMAIN_ERROR_CODES = {
|
||||
FILE_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
message:
|
||||
'쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.',
|
||||
},
|
||||
FILE_002: {
|
||||
status: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
message: '파일 크기가 허용 범위를 초과했습니다.',
|
||||
message: '파일이 너무 커서 올릴 수 없습니다.',
|
||||
},
|
||||
// 대용량 첨부(11단계 — 계약 4절) — 올리기 · 파일의 지금 상태와 맞지 않는 요청: 끝낸 올리기에 조각 · 같은 조각을 동시에 · 받은 조각과 내용이 다른 조각 ·
|
||||
// 빠진 조각이 있는데 끝내기 · 끝내기가 진행 중 · 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 — 클라이언트는 GET 으로 상태를 보고 잇는다
|
||||
FILE_003: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 조각의 Content-Digest(sha-256)가 받은 바이트와 다르다 — 그 조각을 받지 않았다 (같은 조각을 다시 보낸다)
|
||||
FILE_004: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다. 문구에 숫자를 넣지 않는다(한도가 env 로 바뀐다)
|
||||
FILE_005: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.',
|
||||
},
|
||||
// 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 ENOSPC. 507 은 클라이언트의 저절로 다시 보내기 대상(502 · 503 · 504)과 상태로 갈린다
|
||||
FILE_006: {
|
||||
status: HttpStatus.INSUFFICIENT_STORAGE,
|
||||
message:
|
||||
'회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
// 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 (공개 받기 · 우리 웹메일 받기)
|
||||
FILE_007: {
|
||||
status: HttpStatus.GONE,
|
||||
message:
|
||||
'이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.',
|
||||
},
|
||||
// 아직 보안 검사(악성 검사) 중인 파일 — 끝나면 받는다
|
||||
FILE_008: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
// 메일 — 외부 메일 엔진(Stalwart) 장애를 SYS_001(우리 버그)과 구분하기 위해 분리한다
|
||||
MAIL_001: {
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
message: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message: '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
},
|
||||
MAIL_002: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
@@ -90,14 +165,14 @@ const DOMAIN_ERROR_CODES = {
|
||||
MAIL_003: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
|
||||
'메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
// 발송 때 엔진에서 첨부(blob)를 찾지 못함 — 올려 두고 오래 지나 만료된 업로드·그사이 지워진 원본 메일의 첨부(전달)·잘못된 첨부 id.
|
||||
// 문구는 원인을 단정하지 않는다 — 올린 파일은 다시 올리고, 전달하는 원본 첨부는 빼야 한다 (계약 2절)
|
||||
MAIL_004: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
'첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
},
|
||||
// 같은 Idempotency-Key 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다.
|
||||
// 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다
|
||||
@@ -110,7 +185,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
// 문구의 숫자는 attachment.policy.ts 의 MAX_ATTACHMENTS_PER_MESSAGE 와 같아야 한다 (attachment.policy.spec.ts 가 확인한다)
|
||||
MAIL_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
|
||||
message: '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.',
|
||||
},
|
||||
// 본문이 너무 길다 — 텍스트 100,000자·서식 본문 500,000자 초과, 서식 본문이 정화 전 비용 한도나 정화 뒤 2,000,000바이트를 넘음.
|
||||
// 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 미리 막고 본문 아래에 이 문구를 보인다 (계약 2절)
|
||||
@@ -126,6 +201,70 @@ const DOMAIN_ERROR_CODES = {
|
||||
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:
|
||||
'다른 곳에서 필터가 바뀌어 저장하지 않았습니다. 다시 불러온 뒤 고쳐 주세요.',
|
||||
},
|
||||
// 필터 · 부재중 응답 · 보낸 사람 차단(9단계)으로 만든 엔진 규칙(Sieve 스크립트)이 엔진 한도를 넘는다 — 저장하지 않았다
|
||||
// (계약 2절 "메일 필터"의 크기 · "보낸 사람 차단"). 뜻은 6단계 그대로이고 문구만 차단 목록을 함께 말한다
|
||||
MAIL_013: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.',
|
||||
},
|
||||
// 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)을 취소하려 했는데 이미 보냈다(엔진이 풀었음) · 기다리는 발송이 아니다 — 아무것도 바꾸지 않았다.
|
||||
// 화면은 인터셉터 알림으로 보인다 (계약 2절 "보내기 취소 · 예약 발송")
|
||||
MAIL_014: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '이미 보낸 메일이라 취소할 수 없습니다.',
|
||||
},
|
||||
// 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)의 보낸 사본을 옮기거나 지우려 했다 — 붙잡힌 메일은 취소로만 보낸편지함을 떠난다
|
||||
// (옮기거나 지워도 엔진은 시간이 되면 보낸다). 아무것도 바꾸지 않았다. 여러 통 처리는 이 코드 대신 그 id 를 failedIds · skippedIds 로 돌려준다
|
||||
// (계약 2절 "보내기 취소 · 예약 발송")
|
||||
MAIL_015: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
|
||||
},
|
||||
// 보내거나 저장할 대용량 첨부(11단계 — 계약 4절) 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험.
|
||||
// 메일은 나가지 않았다 · 저장하지 않았다. 클라이언트는 파일마다 GET /api/file-uploads/{id} 로 다시 물어 쓸 수 없는 것을 보인다 (MAIL_004 와 같은 흐름)
|
||||
MAIL_016: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
},
|
||||
// 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).
|
||||
// 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절)
|
||||
CONTACT_001: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.',
|
||||
},
|
||||
// 주소록 한도(연락처 3,000개 · 그룹 100개 · 그룹 하나에 구성원 500명)를 넘는다 — 아무것도 바꾸지 않았다 (계약 3절).
|
||||
// 문구의 숫자는 modules/contacts/contacts.constants.ts 의 한도와 같아야 한다 (contacts.constants.spec.ts 가 확인한다)
|
||||
CONTACT_002: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
export const ERROR_CODES = {
|
||||
|
||||
@@ -193,3 +193,63 @@ describe('HttpExceptionFilter', () => {
|
||||
expect(result.body.error).not.toHaveProperty('traceId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpExceptionFilter — 공개 경로 옵션 · 이미 쓰기 시작한 응답 (11단계)', () => {
|
||||
const hostFor = (response: object) =>
|
||||
({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
method: 'GET',
|
||||
url: '/api/public-links/x',
|
||||
header: () => undefined,
|
||||
}),
|
||||
getResponse: () => response,
|
||||
}),
|
||||
}) as unknown as ArgumentsHost;
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('quietStatuses 의 실패는 debug 로만 — 형식은 그대로 표준 실패', () => {
|
||||
const debug = jest
|
||||
.spyOn(Logger.prototype, 'debug')
|
||||
.mockImplementation(() => undefined);
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const json = jest.fn();
|
||||
const response = { status: jest.fn().mockReturnValue({ json }) };
|
||||
|
||||
new HttpExceptionFilter({ quietStatuses: [404, 409, 410] }).catch(
|
||||
new BusinessException('FILE_007'),
|
||||
hostFor(response),
|
||||
);
|
||||
expect(response.status).toHaveBeenCalledWith(HttpStatus.GONE);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: { code: 'FILE_007', message: ERROR_CODES.FILE_007.message },
|
||||
});
|
||||
expect(debug).toHaveBeenCalledTimes(1);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
|
||||
new HttpExceptionFilter({ quietStatuses: [404] }).catch(
|
||||
new BusinessException('REQ_001'),
|
||||
hostFor(response),
|
||||
);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('응답을 이미 쓰기 시작했으면 실패 본문을 덧붙이지 않고 연결을 끊는다', () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const response = {
|
||||
headersSent: true,
|
||||
destroy: jest.fn(),
|
||||
status: jest.fn(),
|
||||
};
|
||||
new HttpExceptionFilter().catch(
|
||||
new Error('stream broke'),
|
||||
hostFor(response),
|
||||
);
|
||||
expect(response.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,9 +30,23 @@ const CLIENT_ERROR_STATUS_THRESHOLD = 400;
|
||||
* 에러 코드 ↔ 메시지 ↔ HTTP 상태는 error-codes.ts 레지스트리가 SSOT 이며,
|
||||
* 이 필터는 조회만 한다(여기에 문구를 하드코딩하지 말 것).
|
||||
*/
|
||||
/** 필터 옵션 — 컨트롤러에 따로 붙일 때만 쓴다 (전역 필터는 기본값) */
|
||||
export interface HttpExceptionFilterOptions {
|
||||
/**
|
||||
* debug 로만 남길 상태 코드 — 바깥 사람 · 봇이 잦게 부르는 공개 경로의 예상된 실패(없는 링크 404 · 검사 중 409 · 끝난 링크 410)가
|
||||
* warn 으로 쌓여 알림 피로가 되지 않게 (계약 4절 "공개 내려받기" — 외부 이미지 중계와 같은 결). 형식은 그대로 표준 실패다
|
||||
*/
|
||||
readonly quietStatuses?: readonly number[];
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
private readonly quietStatuses: ReadonlySet<number>;
|
||||
|
||||
constructor(options: HttpExceptionFilterOptions = {}) {
|
||||
this.quietStatuses = new Set(options.quietStatuses ?? []);
|
||||
}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
@@ -44,6 +58,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
|
||||
this.log(exception, status, code, request, traceId);
|
||||
|
||||
// 응답을 이미 쓰기 시작했다(파일을 흘려보내던 중 등) — 실패 본문을 덧붙일 수 없다. 연결을 끊어 받는 쪽이 실패로 알게 한다
|
||||
if (response.headersSent) {
|
||||
response.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
error: { code, message, ...(traceId ? { traceId } : {}) },
|
||||
@@ -158,6 +178,10 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.quietStatuses.has(status)) {
|
||||
this.logger.debug(`${prefix}${context}`);
|
||||
return;
|
||||
}
|
||||
this.logger.warn(`${prefix}${context}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { AppClientGuard } from './app-client.guard';
|
||||
|
||||
const contextOf = (user: AuthenticatedUser | undefined): ExecutionContext =>
|
||||
({
|
||||
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||
}) 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('AppClientGuard (앱 토큰만 받는 경로)', () => {
|
||||
const guard = new AppClientGuard();
|
||||
const base = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
|
||||
it('앱 토큰(cli: app · 세션 id)이면 지난다', () => {
|
||||
expect(
|
||||
guard.canActivate(
|
||||
contextOf({ ...base, client: 'app', sessionId: 'sid-1' }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('웹 쿠키 세션 · 세션 id 없는 토큰은 403 AUTH_003', () => {
|
||||
expect(
|
||||
codeOf(() =>
|
||||
guard.canActivate(contextOf({ ...base, sessionId: 'sid-1' })),
|
||||
),
|
||||
).toBe('AUTH_003');
|
||||
expect(
|
||||
codeOf(() => guard.canActivate(contextOf({ ...base, client: 'app' }))),
|
||||
).toBe('AUTH_003');
|
||||
});
|
||||
|
||||
it('JwtAuthGuard 없이 걸어 사용자가 없으면 AUTH_001 — 조용히 지나가지 않는다', () => {
|
||||
expect(codeOf(() => guard.canActivate(contextOf(undefined)))).toBe(
|
||||
'AUTH_001',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/**
|
||||
* 앱 토큰만 받는 경로 (계약 1절 "앱 새 메일 알림 · 앱 설정") — `JwtAuthGuard` **뒤에** 건다: `@UseGuards(JwtAuthGuard, AppClientGuard)`.
|
||||
* 앱 토큰(Bearer · 표시 cli: app · 세션 id)이 아니면 403 AUTH_003 — 토큰은 맞지만 이 경로를 쓸 수 없는 클라이언트(웹 쿠키 세션)다.
|
||||
* 웹 화면이 부를 일이 없어 사용자가 볼 일도 없다. 제한된 세션(AUTH_010) · 토큰 없음(AUTH_001)은 앞의 JwtAuthGuard 가 먼저 막는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppClientGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const user = context.switchToHttp().getRequest<Request>().user as
|
||||
| AuthenticatedUser
|
||||
| undefined;
|
||||
if (!user) {
|
||||
// JwtAuthGuard 없이 걸었다 — 조용히 통과하지 않게
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'app-client-guard-without-jwt' },
|
||||
});
|
||||
}
|
||||
if (user.client !== 'app' || !user.sessionId) {
|
||||
throw new BusinessException('AUTH_003', {
|
||||
context: { reason: 'app-client-required' },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthModuleOptions } from '@nestjs/passport';
|
||||
|
||||
import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@@ -14,7 +19,7 @@ function codeOf(run: () => unknown): string | undefined {
|
||||
}
|
||||
|
||||
describe('JwtAuthGuard.handleRequest', () => {
|
||||
const guard = new JwtAuthGuard();
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
|
||||
it('검증된 사용자는 그대로 통과시킨다', () => {
|
||||
expect(guard.handleRequest(null, user, undefined)).toBe(user);
|
||||
@@ -69,3 +74,93 @@ describe('JwtAuthGuard.handleRequest', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** 제한된 세션 시험용 — 허용한 컨트롤러 · 허용하지 않은 컨트롤러와 핸들러 */
|
||||
@AllowRestrictedSession()
|
||||
class AllowedController {}
|
||||
|
||||
class MixedController {}
|
||||
|
||||
const meHandler = (): void => undefined;
|
||||
const statusHandler = (): void => undefined;
|
||||
const deactivateHandler = (): void => undefined;
|
||||
// 핸들러 하나에만 표시 — 메서드 데코레이터와 같은 메타데이터
|
||||
AllowRestrictedSession()(statusHandler);
|
||||
|
||||
function contextOf(
|
||||
controller: abstract new () => object,
|
||||
handler: () => void,
|
||||
): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => handler,
|
||||
getClass: () => controller,
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('JwtAuthGuard — 제한된 세션 (계약 6절, 13단계)', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
const restricted = { ...user, restriction: 'passwordChange' as const };
|
||||
|
||||
it('제한 없는 세션은 허용 표시가 없는 핸들러도 통과한다', () => {
|
||||
const context = contextOf(MixedController, deactivateHandler);
|
||||
expect(guard.handleRequest(null, user, undefined, context)).toBe(user);
|
||||
expect(
|
||||
guard.handleRequest(
|
||||
null,
|
||||
{ ...user, restriction: null },
|
||||
undefined,
|
||||
context,
|
||||
),
|
||||
).toEqual({ ...user, restriction: null });
|
||||
});
|
||||
|
||||
it('제한된 세션은 허용 표시가 없는 핸들러에서 403 AUTH_010 이다 — 제한은 로그 context 에만', () => {
|
||||
const context = contextOf(MixedController, deactivateHandler);
|
||||
expect(
|
||||
codeOf(() => guard.handleRequest(null, restricted, undefined, context)),
|
||||
).toBe('AUTH_010');
|
||||
try {
|
||||
guard.handleRequest(null, restricted, undefined, context);
|
||||
} catch (error) {
|
||||
expect((error as BusinessException).context).toEqual({
|
||||
reason: 'session-restricted',
|
||||
restriction: 'passwordChange',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('핸들러나 컨트롤러에 @AllowRestrictedSession() 이 있으면 제한된 세션도 통과한다', () => {
|
||||
expect(
|
||||
guard.handleRequest(
|
||||
null,
|
||||
restricted,
|
||||
undefined,
|
||||
contextOf(MixedController, statusHandler),
|
||||
),
|
||||
).toBe(restricted);
|
||||
expect(
|
||||
guard.handleRequest(
|
||||
null,
|
||||
{ ...user, restriction: 'twoFactorSetup' },
|
||||
undefined,
|
||||
contextOf(AllowedController, meHandler),
|
||||
),
|
||||
).toEqual({ ...user, restriction: 'twoFactorSetup' });
|
||||
});
|
||||
|
||||
it('실행 맥락을 모르면 제한된 세션을 막는다 (닫힌 쪽)', () => {
|
||||
expect(codeOf(() => guard.handleRequest(null, restricted, undefined))).toBe(
|
||||
'AUTH_010',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JwtAuthGuard — PassportModule 설정', () => {
|
||||
it('PassportModule.register 의 설정(AuthModuleOptions)을 부모 가드에 넘긴다 (리뷰 L13)', () => {
|
||||
const options = Object.assign(new AuthModuleOptions(), { session: false });
|
||||
const guard = new JwtAuthGuard(new Reflector(), options);
|
||||
expect((guard as unknown as { options: AuthModuleOptions }).options).toBe(
|
||||
options,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { type ExecutionContext, Injectable, Optional } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard, AuthModuleOptions } from '@nestjs/passport';
|
||||
|
||||
import { ALLOW_RESTRICTED_SESSION } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
@@ -8,20 +10,34 @@ import { BusinessException } from '@/common/errors/business.exception';
|
||||
export const JWT_STRATEGY = 'jwt';
|
||||
|
||||
/**
|
||||
* 액세스 토큰 쿠키로 보호하는 가드.
|
||||
* 액세스 토큰으로 보호하는 가드 — 웹은 HttpOnly 쿠키, 앱(모바일)은 `Authorization: Bearer` (계약 1절 "앱 로그인").
|
||||
* 앱 토큰(표시 cli: app)은 Bearer 로만, 웹 토큰은 쿠키로만 받는다 — 서로 바꿔 쓰면 AUTH_001 (modules/auth/jwt.strategy.ts).
|
||||
* 토큰이 없거나·만료됐거나·위조됐으면 원인을 구분하지 않고 AUTH_001 로 응답한다.
|
||||
* (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 /auth/refresh 를 한 번 시도한다)
|
||||
* (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 갱신(웹 /auth/refresh · 앱 /auth/app/refresh)을 한 번 시도한다)
|
||||
*
|
||||
* 사용 예:
|
||||
* 제한된 세션(토큰의 `rst` — 계약 6절 "제한된 세션")은 `@AllowRestrictedSession()` 을 단 핸들러 · 컨트롤러만 통과한다 —
|
||||
* 그 밖은 403 AUTH_010 (실시간 알림 `/api/mail-events` 포함). 허용 목록은 가드를 거는 쪽이 아니라 핸들러가 정한다 —
|
||||
* 새 보호 라우트는 달지 않으면 저절로 막힌다(닫힌 쪽).
|
||||
*
|
||||
* 사용 예 (Swagger 에는 @ApiCookieAuth() 만 적는다 — 앱의 Bearer 는 main.ts 가 "또는"으로 붙인다):
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* @ApiCookieAuth()
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) {
|
||||
// PassportModule.register 의 설정(AuthModuleOptions)을 부모에 그대로 넘긴다 — 생성자를 두면 Nest 가 부모의 주입을 대신하지 않는다
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
@Optional() options?: AuthModuleOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
handleRequest<TUser = AuthenticatedUser>(
|
||||
error: unknown,
|
||||
user: unknown,
|
||||
info: unknown,
|
||||
context?: ExecutionContext,
|
||||
): TUser {
|
||||
// 전략의 validate() 가 코드와 함께 거부한 경우 그대로 전달한다
|
||||
if (error instanceof BusinessException) throw error;
|
||||
@@ -31,8 +47,26 @@ export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) {
|
||||
context: { reason: describeFailure(error ?? info) },
|
||||
});
|
||||
}
|
||||
|
||||
const restriction = (user as AuthenticatedUser).restriction ?? null;
|
||||
if (restriction !== null && !this.allowsRestricted(context)) {
|
||||
throw new BusinessException('AUTH_010', {
|
||||
context: { reason: 'session-restricted', restriction },
|
||||
});
|
||||
}
|
||||
return user as TUser;
|
||||
}
|
||||
|
||||
/** 핸들러 · 컨트롤러에 @AllowRestrictedSession() 이 있는가 — 실행 맥락이 없으면(가드 밖에서 부름) 막는다 */
|
||||
private allowsRestricted(context: ExecutionContext | undefined): boolean {
|
||||
if (!context) return false;
|
||||
return (
|
||||
this.reflector.getAllAndOverride<boolean | undefined>(
|
||||
ALLOW_RESTRICTED_SESSION,
|
||||
[context.getHandler(), context.getClass()],
|
||||
) === true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 토큰 원문은 남기지 않고 실패 종류만 남긴다 (예: 'No auth token', 'jwt expired') */
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { connect } from 'net';
|
||||
|
||||
import { requestIdMiddleware } from './request-id.middleware';
|
||||
import { requestTargetMiddleware } from './request-target.middleware';
|
||||
|
||||
/** 원문 바이트 그대로 요청을 보내고 응답(상태 · 머리글 · 본문)을 읽는다 — supertest 는 경로를 고쳐 보낸다(`\` → `/` 등) */
|
||||
function sendRaw(
|
||||
server: Server,
|
||||
head: Buffer,
|
||||
): Promise<{ status: number; headers: string; body: string }> {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(port, '127.0.0.1');
|
||||
const chunks: Buffer[] = [];
|
||||
socket.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
socket.on('error', reject);
|
||||
socket.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
const [headers, ...rest] = text.split('\r\n\r\n');
|
||||
resolve({
|
||||
status: Number(headers.split(' ')[1]),
|
||||
headers: headers.toLowerCase(),
|
||||
body: rest.join('\r\n\r\n'),
|
||||
});
|
||||
});
|
||||
socket.write(head);
|
||||
});
|
||||
}
|
||||
|
||||
const requestLine = (method: string, target: string) =>
|
||||
Buffer.from(
|
||||
`${method} ${target} HTTP/1.1\r\nHost: portal.test\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
|
||||
'latin1',
|
||||
);
|
||||
|
||||
describe('요청 대상 원문 검사 미들웨어 (라우팅 전에 400 VAL_001)', () => {
|
||||
let server: Server;
|
||||
const reached: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.set('case sensitive routing', true);
|
||||
app.use(requestIdMiddleware);
|
||||
app.use(requestTargetMiddleware);
|
||||
app.get('/api/health', (_req, res) => {
|
||||
reached.push('health');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post('/api/internal/mail-engine-events', (_req, res) => {
|
||||
reached.push('webhook');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.get('/api/search', (req, res) => {
|
||||
reached.push('search');
|
||||
res.json({ q: req.query.q });
|
||||
});
|
||||
server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
reached.length = 0;
|
||||
});
|
||||
|
||||
it.each([
|
||||
['POST', '/api/internal\\mail-engine-events#x'],
|
||||
['GET', '/api/health\\#a'],
|
||||
['GET', '/api/health#a'],
|
||||
['GET', 'http://portal.test/api/internal\\mail-engine-events'],
|
||||
])(
|
||||
'%s %s — 처리기에 닿지 않고 표준 실패 형식의 400 VAL_001 (traceId · 연결 닫기)',
|
||||
async (method, target) => {
|
||||
const res = await sendRaw(server, requestLine(method, target));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers).toContain('connection: close');
|
||||
const body = JSON.parse(res.body) as {
|
||||
success: boolean;
|
||||
error: { code: string; traceId?: string };
|
||||
};
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error.code).toBe('VAL_001');
|
||||
expect(body.error.traceId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(res.headers).toContain(`x-request-id: ${body.error.traceId}`);
|
||||
expect(reached).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('정상 경로 · 퍼센트 인코딩한 한글은 그대로 지나간다', async () => {
|
||||
const health = await sendRaw(server, requestLine('GET', '/api/health'));
|
||||
expect(health.status).toBe(200);
|
||||
|
||||
const search = await sendRaw(
|
||||
server,
|
||||
requestLine('GET', '/api/search?q=%ED%95%9C%EA%B8%80'),
|
||||
);
|
||||
expect(search.status).toBe(200);
|
||||
expect(JSON.parse(search.body)).toEqual({ q: '한글' });
|
||||
expect(reached).toEqual(['health', 'search']);
|
||||
});
|
||||
|
||||
it('0xA0 · 제어 문자가 든 대상도 막는다 — 지금 Node 는 HTTP 파서가 먼저 거절하지만 파서가 느슨해져도 여기서 막는다', () => {
|
||||
for (const target of ['/api/a b', '/api/a\u0000b', '/api/a\u007fb']) {
|
||||
const status = jest.fn().mockReturnThis();
|
||||
const json = jest.fn();
|
||||
const setHeader = jest.fn();
|
||||
const next = jest.fn();
|
||||
requestTargetMiddleware(
|
||||
{
|
||||
originalUrl: target,
|
||||
url: target,
|
||||
header: () => 'trace-1',
|
||||
} as unknown as Request,
|
||||
{ status, json, setHeader } as unknown as Response,
|
||||
next,
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VAL_001',
|
||||
message: expect.any(String) as unknown,
|
||||
traceId: 'trace-1',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { getErrorDefinition } from '@/common/errors/error-codes';
|
||||
import { hasForbiddenRequestTargetChar } from '@/shared/utils/request-target';
|
||||
import { REQUEST_ID_HEADER } from './request-id.middleware';
|
||||
|
||||
/**
|
||||
* 요청 대상 원문 검사 미들웨어 (express) — `configureApp()` 이 요청 추적 ID 바로 뒤, 라우팅 · 본문 파서 앞에 붙인다.
|
||||
* 원문 대상(`req.originalUrl` · `req.url`)에 날 `\` · `#` · 제어 문자 · 0x80 이상 글자가 있으면 표준 실패 형식의 400 VAL_001 로 끝낸다
|
||||
* (규칙과 까닭은 shared/utils/request-target.ts). 앞단 nginx 도 server 단계에서 400 으로 막는다(release-ops — 두 겹).
|
||||
* 본문을 받지 않고 끝내므로 연결을 닫는다. 로그를 남기지 않는다(두드려 보는 요청 — 대상 원문에 로그를 흐트러뜨리는 글자가 들 수 있다)
|
||||
*/
|
||||
export function requestTargetMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const original = req.originalUrl ?? req.url;
|
||||
if (
|
||||
!hasForbiddenRequestTargetChar(original) &&
|
||||
(req.url === original || !hasForbiddenRequestTargetChar(req.url))
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const traceId = req.header(REQUEST_ID_HEADER) ?? undefined;
|
||||
res.setHeader('Connection', 'close');
|
||||
res.status(HttpStatus.BAD_REQUEST).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VAL_001',
|
||||
message: getErrorDefinition('VAL_001').message,
|
||||
...(traceId ? { traceId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
+30
-8
@@ -9,10 +9,11 @@ import type {
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
|
||||
import { configureApp } from '@/app.setup';
|
||||
import { configureApp, createHttpAdapter } 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 { addAlternativeSecurity } from '@/shared/utils/openapi-security';
|
||||
import {
|
||||
buildAllowedOrigins,
|
||||
normalizeOrigin,
|
||||
@@ -21,12 +22,21 @@ import {
|
||||
/** PORT 가 없거나 비었을 때 듣는 포트 */
|
||||
const DEFAULT_PORT = 3000;
|
||||
|
||||
/** Swagger 인증 이름 — `@ApiCookieAuth()` · `@ApiBearerAuth()` 의 기본 이름과 같다 */
|
||||
const SWAGGER_COOKIE_AUTH = 'cookie';
|
||||
const SWAGGER_BEARER_AUTH = 'bearer';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(winstonConfig),
|
||||
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
|
||||
bodyParser: false,
|
||||
});
|
||||
// 어댑터는 createHttpAdapter — 라우팅이 대소문자를 가린다(어댑터를 만들 때 켜야 한다 — app.setup.ts). e2e 도 같은 함수를 쓴다
|
||||
const app = await NestFactory.create<NestExpressApplication>(
|
||||
AppModule,
|
||||
createHttpAdapter(),
|
||||
{
|
||||
logger: WinstonModule.createLogger(winstonConfig),
|
||||
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
|
||||
bodyParser: false,
|
||||
},
|
||||
);
|
||||
|
||||
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 (컨테이너 healthcheck 가 읽는다)
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
@@ -67,14 +77,26 @@ async function bootstrap() {
|
||||
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
|
||||
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
|
||||
|
||||
// 4. Swagger API 문서 설정 — 인증은 HttpOnly 쿠키(access_token)뿐이다 (Authorization 헤더는 읽지 않는다)
|
||||
// 4. Swagger API 문서 설정 — 인증은 웹은 HttpOnly 쿠키(access_token), 앱은 Authorization: Bearer (앱 로그인이 준 토큰만 — 계약 1절 "앱 로그인").
|
||||
// 보호 라우트는 @ApiCookieAuth() 만 적는다 — 가드(JwtAuthGuard)가 두 길을 다 받으므로 Bearer 는 여기서 "또는"으로 한 번에 붙인다
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Backend API Documentation')
|
||||
.setDescription('The API documentation conforming to the global guidelines')
|
||||
.setVersion('1.0')
|
||||
.addCookieAuth(ACCESS_TOKEN_COOKIE)
|
||||
.addCookieAuth(ACCESS_TOKEN_COOKIE, { type: 'apiKey' }, SWAGGER_COOKIE_AUTH)
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description:
|
||||
'앱(모바일) 전용 — POST /api/auth/app/login · refresh 가 준 액세스 토큰만 받는다(웹 토큰은 401). 웹은 쿠키를 쓴다',
|
||||
},
|
||||
SWAGGER_BEARER_AUTH,
|
||||
)
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
addAlternativeSecurity(document, SWAGGER_COOKIE_AUTH, SWAGGER_BEARER_AUTH);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
|
||||
// 5. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 보내기 · 읽기 설정 표 (docs/mail/api-contract.md 2절 "보내기 · 읽기 설정" — 9단계).
|
||||
* - mail_settings: 직원 주소마다 한 줄 — 보내기 취소 초(undo_send_seconds) · 외부 이미지(external_images). 없으면 처음 값(10 · ask)
|
||||
* 새 표만 만든다 — 기존 표(auth_sessions · mail_signatures · mail_signature_images · mail_directory)는 건드리지 않는다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/mail-user-settings/mail-user-settings.entity.ts)
|
||||
*/
|
||||
export class AddMailSettings1793255372738 implements MigrationInterface {
|
||||
name = 'AddMailSettings1793255372738';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_settings" ("user_email" character varying(254) NOT NULL, "undo_send_seconds" smallint NOT NULL, "external_images" character varying(16) NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_b2ca6c2963fd9eca971baa44421" PRIMARY KEY ("user_email"))`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "mail_settings"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 대용량 첨부 표 (docs/mail/api-contract.md 4절 "대용량 첨부" — 11단계).
|
||||
* - stored_files: 파일(올리기 id = 파일 id) — 주인 · 이름 · 선언한 크기 · 상태 · 검사 결과 · 보관 끝(expires_at). 바이트는 파일 볼륨(/app/files)
|
||||
* 부분 인덱스: 살아 있는 파일의 기간(정리) · 검사를 기다리는 파일 · 지운 파일(기록 정리)
|
||||
* - stored_file_chunks: 받은 조각(번호 · 크기 · SHA-256) — 파일을 지우면 함께 지워진다
|
||||
* - share_links: 공유 링크(메일 한 통) — 토큰은 SHA-256 만(유일 인덱스), 보낸 사람 · 보낸 메일 · 끝 · 끊음 · 취소
|
||||
* - share_link_files: 링크의 파일 — 보낸 차례 · 그때의 이름 · 크기 · 받은 수. 링크를 지우면 함께 지워진다(파일 표와는 묶지 않는다)
|
||||
* 새 표만 만든다 — 기존 표(auth_sessions · mail_signatures · mail_signature_images · mail_directory · mail_settings)는 건드리지 않는다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/file-storage/*.entity.ts · modules/file-sharing/*.entity.ts)
|
||||
*/
|
||||
export class AddLargeFileSharing1793348306562 implements MigrationInterface {
|
||||
name = 'AddLargeFileSharing1793348306562';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "share_links" ("id" uuid NOT NULL, "token_hash" bytea NOT NULL, "owner_email" character varying(254) NOT NULL, "sender_name" character varying(200), "purpose" character varying(32) NOT NULL, "message_id" character varying(255), "sent_email_id" character varying(255), "sent_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, "revoked_by" character varying(32), "canceled_at" TIMESTAMP WITH TIME ZONE, "cancel_reason" character varying(32), CONSTRAINT "PK_70320b79ecd8acab96419fcdd6d" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_share_links_token_hash" ON "share_links" ("token_hash") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_share_links_owner_message" ON "share_links" ("owner_email", "message_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_share_links_owner_sent_email" ON "share_links" ("owner_email", "sent_email_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_share_links_owner_sent" ON "share_links" ("owner_email", "sent_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "share_link_files" ("link_id" uuid NOT NULL, "file_id" uuid NOT NULL, "position" smallint NOT NULL, "name" character varying(255) NOT NULL, "size" bigint NOT NULL, "download_count" integer NOT NULL DEFAULT '0', "last_downloaded_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_2c813a6dd8adba8c8902c8314b4" PRIMARY KEY ("link_id", "file_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_share_link_files_file" ON "share_link_files" ("file_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "stored_files" ("id" uuid NOT NULL, "owner_email" character varying(254) NOT NULL, "purpose" character varying(16) NOT NULL, "name" character varying(255) NOT NULL, "size" bigint NOT NULL, "chunk_size" integer NOT NULL, "chunk_count" integer NOT NULL, "last_modified" bigint, "state" character varying(16) NOT NULL, "removed_reason" character varying(16), "first_chunk_sha256" character varying(44), "scan_status" character varying(16), "scan_signature" character varying(255), "scan_attempts" integer NOT NULL DEFAULT '0', "scan_next_at" TIMESTAMP WITH TIME ZONE, "scanned_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_chunk_at" TIMESTAMP WITH TIME ZONE, "completed_at" TIMESTAMP WITH TIME ZONE, "draft_attached_at" TIMESTAMP WITH TIME ZONE, "linked_at" TIMESTAMP WITH TIME ZONE, "linked_until" TIMESTAMP WITH TIME ZONE, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "removed_at" TIMESTAMP WITH TIME ZONE, "bytes_deleted_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_5d5be862bf53851c1794b4adf4e" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_stored_files_removed" ON "stored_files" ("removed_at") WHERE "state" = 'removed'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_stored_files_scan_pending" ON "stored_files" ("scan_next_at") WHERE "scan_status" = 'pending'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_stored_files_alive_expires" ON "stored_files" ("expires_at") WHERE "state" <> 'removed'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_stored_files_owner_created" ON "stored_files" ("owner_email", "created_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "stored_file_chunks" ("file_id" uuid NOT NULL, "chunk_index" integer NOT NULL, "size" integer NOT NULL, "sha256" bytea NOT NULL, "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_ea6c24c7b1bd69e0a10dd48d2b3" PRIMARY KEY ("file_id", "chunk_index"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "share_link_files" ADD CONSTRAINT "FK_def8bbe7f42c897e71e8c2472c7" FOREIGN KEY ("link_id") REFERENCES "share_links"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "stored_file_chunks" ADD CONSTRAINT "FK_37be6c847f971f1b9536e7dd076" FOREIGN KEY ("file_id") REFERENCES "stored_files"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "stored_file_chunks" DROP CONSTRAINT "FK_37be6c847f971f1b9536e7dd076"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "share_link_files" DROP CONSTRAINT "FK_def8bbe7f42c897e71e8c2472c7"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "stored_file_chunks"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_stored_files_owner_created"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_stored_files_alive_expires"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_stored_files_scan_pending"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_stored_files_removed"`);
|
||||
await queryRunner.query(`DROP TABLE "stored_files"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_share_link_files_file"`);
|
||||
await queryRunner.query(`DROP TABLE "share_link_files"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_share_links_owner_sent"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_share_links_owner_sent_email"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_share_links_owner_message"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_share_links_token_hash"`);
|
||||
await queryRunner.query(`DROP TABLE "share_links"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 대용량 첨부 — 끝내기를 시작한 때 (11단계 리뷰 L4, 2026-10-30).
|
||||
* - stored_files.completion_started_at: 끝내는 중(completing)일 때만 값이 있다. 정리 작업은 이것이 오래된 끝내기만 멈춘 것으로 되살린다
|
||||
* (마지막 조각의 때로 고르면 방금 시작한 끝내기를 되돌릴 수 있었다)
|
||||
* 빈 값을 허용하는 칸 하나를 더한다 — 기본값이 없어 표를 다시 쓰지 않는다(곧 끝난다). 기존 행은 null 이고, 정리 작업은 그때 마지막 조각 · 만든 때로 본다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/file-storage/stored-file.entity.ts)
|
||||
*/
|
||||
|
||||
export class AddFileCompletionStartedAt1793358223047 implements MigrationInterface {
|
||||
name = 'AddFileCompletionStartedAt1793358223047';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "stored_files" ADD "completion_started_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "stored_files" DROP COLUMN "completion_started_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 계정 보안 표 (docs/mail/api-contract.md 5절 "계정 보안" — 12단계).
|
||||
* - login_events: 로그인 기록(웹메일 · 메일 프로그램) — 90일 보존. 직원 · 일어난 때 · 차례 번호 인덱스(커서 페이지),
|
||||
* 일어난 때 인덱스(정리), 엔진 이벤트의 겹침 거르기 키 유일 인덱스(웹메일 기록은 null — 여럿이어도 된다)
|
||||
* - app_password_usages: 앱 비밀번호의 서버 기록(이름 · 엔진의 만든 때 · 마지막 사용) — 직원 · 앱 비밀번호 id 마다 한 줄
|
||||
* - two_factor_settings: 웹메일에서 2단계 인증을 켠 때 — 직원 주소마다 한 줄 (비밀은 두지 않는다)
|
||||
* - auth_sessions: 로그인 · 갱신한 클라이언트의 IP · User-Agent 칸(12단계 전의 세션은 null)과 직원 주소 인덱스(로그인된 브라우저 목록 · 다른 곳 모두 끊기)
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하고 표를 새로 만들기만 한다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/account-security/*.entity.ts · modules/auth/auth-session.entity.ts)
|
||||
*/
|
||||
export class AddAccountSecurity1793415011070 implements MigrationInterface {
|
||||
name = 'AddAccountSecurity1793415011070';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "login_events" ("id" BIGSERIAL NOT NULL, "user_email" character varying(254) NOT NULL, "occurred_at" TIMESTAMP WITH TIME ZONE NOT NULL, "method" character varying(32) NOT NULL, "is_success" boolean NOT NULL, "is_two_factor" boolean NOT NULL, "failure_reason" character varying(32), "ip" character varying(64), "user_agent" character varying(512), "app_password_id" character varying(16), "app_password_name" character varying(40), "dedupe_key" character(43), CONSTRAINT "PK_5cd6102474db62bc181590b9902" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_login_events_occurred_at" ON "login_events" ("occurred_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_login_events_dedupe_key" ON "login_events" ("dedupe_key") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_login_events_user_occurred" ON "login_events" ("user_email", "occurred_at", "id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "app_password_usages" ("user_email" character varying(254) NOT NULL, "app_password_id" character varying(16) NOT NULL, "name" character varying(40) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_used_at" TIMESTAMP WITH TIME ZONE, "last_used_method" character varying(8), "last_used_ip" character varying(64), CONSTRAINT "PK_6db68c01bf629755c5eeaf95322" PRIMARY KEY ("user_email", "app_password_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "two_factor_settings" ("user_email" character varying(254) NOT NULL, "enabled_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e1f907880f3d367cca2717934b6" PRIMARY KEY ("user_email"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "ip" character varying(64)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "user_agent" character varying(512)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_auth_sessions_user_email" ON "auth_sessions" ("user_email") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_auth_sessions_user_email"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" DROP COLUMN "user_agent"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "auth_sessions" DROP COLUMN "ip"`);
|
||||
await queryRunner.query(`DROP TABLE "two_factor_settings"`);
|
||||
await queryRunner.query(`DROP TABLE "app_password_usages"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_login_events_user_occurred"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_login_events_dedupe_key"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_login_events_occurred_at"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "login_events"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 전환 준비 표 (docs/mail/api-contract.md 6절 "전환 준비 — 13단계").
|
||||
* - auth_sessions.restriction: 제한된 세션 — passwordChange(비밀번호 바꾸기 전) · twoFactorSetup(2단계 인증 켜기 전) · null. 기존 세션은 null(제한 없음)
|
||||
* - system_settings: 서버 설정 한 줄(id 1) — 2단계 인증 필수 정책(꺼짐 · 시작일 · 예외 주소)과 문의 연락처. 처음 값은 꺼짐 · 연락처 없음이다
|
||||
* - ops_audit_log: 운영 도구의 실행 기록 — 언제 · 누가 · 명령 · 대상 · 결과(비밀은 남기지 않는다). 일어난 때 인덱스
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하고 표를 새로 만들기만 한다.
|
||||
* (엔티티: modules/auth/auth-session.entity.ts · modules/system-settings/system-settings.entity.ts · modules/ops/ops-audit-log.entity.ts —
|
||||
* 만든 뒤 migration:generate 가 "변경 없음"인 것을 확인했다)
|
||||
*/
|
||||
export class AddCutoverPrep1793585725784 implements MigrationInterface {
|
||||
name = 'AddCutoverPrep1793585725784';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "restriction" character varying(32)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "system_settings" ("id" smallint NOT NULL, "two_factor_required" boolean NOT NULL, "two_factor_start_date" date, "two_factor_exempt" jsonb NOT NULL, "support_contact" character varying(200), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_82521f08790d248b2a80cc85d40" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
// 한 줄만 둔다 — 처음 값: 2단계 인증 필수 꺼짐 · 예외 없음 · 문의 연락처 없음
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "system_settings" ("id", "two_factor_required", "two_factor_start_date", "two_factor_exempt", "support_contact", "updated_at") VALUES (1, false, NULL, '[]', NULL, now())`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "ops_audit_log" ("id" BIGSERIAL NOT NULL, "occurred_at" TIMESTAMP WITH TIME ZONE NOT NULL, "operator" character varying(100) NOT NULL, "command" character varying(32) NOT NULL, "target" character varying(254), "result" character varying(16) NOT NULL, "detail" character varying(500), CONSTRAINT "PK_79f2db0d5b46ba1d43348bbc85c" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_ops_audit_log_occurred_at" ON "ops_audit_log" ("occurred_at") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_ops_audit_log_occurred_at"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "ops_audit_log"`);
|
||||
await queryRunner.query(`DROP TABLE "system_settings"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" DROP COLUMN "restriction"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 검색 색인 표 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕, 설계 A).
|
||||
* - mail_search_messages: 직원 주소 + 엔진 메일 id 마다 한 줄 — 메일함 · 키워드 · 받은 시각 · 답장 관계 · 원문 글(NFC) · 접기 글(fold_*) ·
|
||||
* 글자 두 개 묶음(grams) · 대화 키. 색인: PK, (직원, 받은 시각, id) · (직원, Message-ID) · (직원, 대화 키),
|
||||
* GIN 셋(grams · mailbox_ids · refs — TypeORM 이 GIN 을 적지 못해 엔티티에는 synchronize: false 로 이름만 있다)
|
||||
* - mail_search_states: 직원마다 한 줄 — 상태(building · ready) · 엔진 Email state · 채우기 자리 · 멈춘 까닭 · 실패 수
|
||||
* - mail_settings.group_by_conversation: 목록을 대화로 묶어 보기(기본 끔 — API 는 14단계 3차)
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 표를 새로 만들고 칸 하나를 더하기만 한다.
|
||||
* (자동 생성: npm run migration:generate — GIN 색인 셋만 손으로 더했다. 엔티티는 modules/mail-search/*.entity.ts ·
|
||||
* modules/mail-user-settings/mail-user-settings.entity.ts)
|
||||
*/
|
||||
export class AddMailSearchIndex1793873715423 implements MigrationInterface {
|
||||
name = 'AddMailSearchIndex1793873715423';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_search_messages" ("user_email" character varying(254) NOT NULL, "email_id" character varying(64) NOT NULL, "mailbox_ids" text array NOT NULL, "keywords" text array NOT NULL, "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, "size" integer NOT NULL, "message_id" text, "refs" text array NOT NULL, "subject" text NOT NULL, "subject_base" text NOT NULL, "from_text" text NOT NULL, "to_text" text NOT NULL, "cc_text" text NOT NULL, "attachment_names" text NOT NULL, "has_attachment" boolean NOT NULL, "preview" text NOT NULL, "body_text" text NOT NULL, "fold_from" text NOT NULL, "fold_to" text NOT NULL, "fold_subject" text NOT NULL, "fold_attachment" text NOT NULL, "fold_body" text NOT NULL, "grams" text array NOT NULL, "thread_key" character varying(64) NOT NULL, "engine_thread_id" character varying(64), "indexed_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_bdeada2a9bbda7dece469d81b6f" PRIMARY KEY ("user_email", "email_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_thread_key" ON "mail_search_messages" ("user_email", "thread_key") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_message_id" ON "mail_search_messages" ("user_email", "message_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_received" ON "mail_search_messages" ("user_email", "received_at", "email_id") `,
|
||||
);
|
||||
// GIN — 찾는 말의 글자 두 개 묶음이 모두 든 줄(grams @> …) · 메일함 범위(mailbox_ids && …) · 늦게 온 원본이 자식을 찾기(refs @> …)
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_mailbox_ids" ON "mail_search_messages" USING GIN ("mailbox_ids")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_refs" ON "mail_search_messages" USING GIN ("refs")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_search_states" ("user_email" character varying(254) NOT NULL, "status" character varying(32) NOT NULL, "email_state" text, "build_anchor" character varying(64), "build_done" integer NOT NULL DEFAULT '0', "build_total" integer, "build_started_at" TIMESTAMP WITH TIME ZONE, "ready_at" TIMESTAMP WITH TIME ZONE, "last_synced_at" TIMESTAMP WITH TIME ZONE, "paused_reason" character varying(32), "failure_count" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e8aa94c380c49ff05919e5177ab" PRIMARY KEY ("user_email"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_states_status" ON "mail_search_states" ("status") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_settings" ADD "group_by_conversation" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_settings" DROP COLUMN "group_by_conversation"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_states_status"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "mail_search_states"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_refs"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_mailbox_ids"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_grams"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_received"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_message_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_thread_key"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "mail_search_messages"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 검색 색인 2차 (14단계 설계 G — 1만 통 측정 뒤).
|
||||
* - 글자 두 개 묶음 GIN 을 직원 주소와 함께로 바꾼다(btree_gin). 주소 칸은 **`lower("user_email")` 식**으로 넣는다 —
|
||||
* 후보를 찾는 쿼리가 같은 식으로만 직원을 거르면 이 색인 말고 쓸 수 있는 색인이 없다. 주소 그대로(`"user_email"`)면 계획기가 흔한 묶음에서
|
||||
* 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀어(TOAST) 보는 길을 골랐다(1만 통에 260~700ms — 이 색인이면 10ms 안팎, 2026-11-05 측정).
|
||||
* 표의 주소는 늘 소문자다(서비스가 맞춘다) — 식은 값을 바꾸지 않는다.
|
||||
* `btree_gin` 은 PostgreSQL 13 부터 trusted 확장이라 DB 주인 계정이 만든다(이미 있으면 그대로)
|
||||
* - 상태 줄에 색인 판(index_format)을 더한다 — 지금까지 만든 줄은 1(이 칸의 처음 값). 서버가 판이 다른 줄을 building 으로 돌려 다시 채운다
|
||||
* 기존 표의 칸 · 행은 지우지 않는다. 내릴 때 확장은 남긴다(다른 곳이 쓸 수 있다).
|
||||
* (자동 생성: npm run migration:generate — GIN 두 개 · 확장은 손으로 적었다. 엔티티는 modules/mail-search/*.entity.ts)
|
||||
*/
|
||||
export class SearchIndexUserGrams1793882273224 implements MigrationInterface {
|
||||
name = 'SearchIndexUserGrams1793882273224';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS btree_gin`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_grams"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_states" ADD "index_format" smallint NOT NULL DEFAULT '1'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_states" DROP COLUMN "index_format"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_user_grams"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 검색 색인 3차 리뷰 L3 (14단계 — 대화로 묶은 목록의 참여자).
|
||||
* - 색인 줄에 첫 보낸 사람의 주소(`from_email` — 254) · 이름(`from_name` — 100 코드 포인트)을 따로 둔다. 참여자는 이 칸만 읽는다 —
|
||||
* 보낸 사람 글(`from_text` — "이름 <주소>")을 다시 해석하면 주소 없이 이름만 `대표 <ceo@…>` 꼴인 보낸 사람이 그 주소로 보이고, 400자에서 잘린 줄은 빠졌다
|
||||
* - 둘 다 null 을 받는다(주소가 없는 보낸 사람 · 지금 있는 줄). 지금 있는 줄은 색인 판 4(INDEX_FORMAT)로 다시 채우며 채운다
|
||||
* 기존 칸 · 행은 지우지 않는다. (자동 생성: npm run migration:generate — 엔티티는 modules/mail-search/mail-search-message.entity.ts)
|
||||
*/
|
||||
export class AddMailSearchSender1793898213073 implements MigrationInterface {
|
||||
name = 'AddMailSearchSender1793898213073';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_messages" ADD "from_email" character varying(254)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_messages" ADD "from_name" character varying(100)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_messages" DROP COLUMN "from_name"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_messages" DROP COLUMN "from_email"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 앱 전용 토큰 경로 (docs/mail/api-contract.md 1절 "앱 로그인" — 앱 1단계).
|
||||
* - auth_sessions.client: 세션을 연 클라이언트 — web(브라우저 · 쿠키) · app(모바일 앱 · Bearer). 기존 세션은 기본값 'web'.
|
||||
* DB 기본값을 두어 이 칸을 모르는 코드(다른 브랜치)가 넣는 행도 웹 세션으로 들어간다
|
||||
* 로그인 기록의 방식 `app` 은 칸을 바꾸지 않는다 — login_events.method 는 제약 없는 varchar(32) 다.
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하기만 한다.
|
||||
* (엔티티: modules/auth/auth-session.entity.ts — migration:generate 의 결과에서 이 칸의 두 문장만 옮겼다.
|
||||
* 개발 DB 에 다른 브랜치의 칸(mail_settings.group_by_conversation)이 있어 generate 가 그 칸을 지우는 문장도 냈다 — 넣지 않는다)
|
||||
*/
|
||||
export class AddAppSessionClient1793933074677 implements MigrationInterface {
|
||||
name = 'AddAppSessionClient1793933074677';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "client" character varying(16) NOT NULL DEFAULT 'web'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "auth_sessions" DROP COLUMN "client"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 검색 색인 — 메일함 범위 찾기 (14단계 실사용 시험, 2026-11-06).
|
||||
* - 글자 두 개 묶음 GIN 에 메일함 칸을 더한다: GIN 하나 (lower("user_email"), "grams", "mailbox_ids") 가 직원 · 묶음 · 메일함을 한 번에 맞춘다.
|
||||
* 주소 · 묶음 GIN(`IDX_mail_search_messages_user_grams`)과 메일함만 담은 GIN(`IDX_mail_search_messages_mailbox_ids`)은 지운다.
|
||||
* - 까닭: 메일함 범위의 후보 쿼리(`"mailbox_ids" @> $n` — search-sql.ts 의 candidatesSql)에서 계획기가 메일함 GIN 만으로 그 메일함의 줄을 모두 읽고
|
||||
* 묶음 배열(TOAST)을 줄마다 풀어 보는 길을 골랐다. 메일함 id 는 계정마다 같은 꼴(a · b …)이라 그 posting 은 회사 전체이고, 묶음 확인이
|
||||
* 주소 확인보다 먼저 돌아 다른 직원의 줄까지 풀었다 — 1만 통 메일함 안 1.1초, 그 메일함 id 가 남의 큰 메일함과 같은 직원은 빈 메일함에도 1.6초.
|
||||
* 계획기는 TOAST 를 푸는 값을 모르고, 묶음 GIN 의 대기 목록(fastupdate)이 쌓이면 그 GIN 을 더 비싸게 보아 이 길로 넘어간다 —
|
||||
* 그래서 고를 길을 하나만 남긴다(이 색인 말고 `lower("user_email")` · 묶음 · 메일함 조건을 받는 색인이 없다. 메일함만 담은 색인을 다시 만들지 않는다).
|
||||
* - 같은 표의 흉내(10명 · 10만 줄 — 메일함 id 가 겹친다)에서 큰 메일함 29ms · 작은 메일함 1~7ms · 전체 13ms
|
||||
* (전에는 큰 메일함 43~125ms · 작은 메일함 5~88ms — 메일함 GIN 과 함께 쓸 때와 쓰지 않을 때). 그 표의 몫(직원 주소 · 메일함 칸)은 묶음보다 훨씬 작다.
|
||||
* 쓰기 비용은 지금과 같다 — 메일함이 바뀐 줄은 지금도 두 GIN 에 새로 들어간다(색인된 칸이라 HOT 이 아니다). 표의 칸 · 행은 그대로다.
|
||||
* btree_gin 확장은 SearchIndexUserGrams 가 만들었다. 내릴 때는 두 색인을 다시 만든다.
|
||||
* (손으로 적었다 — TypeORM 이 GIN 을 적지 못한다. 엔티티는 modules/mail-search/mail-search-message.entity.ts 에 synchronize: false 로 이름만)
|
||||
*/
|
||||
export class SearchIndexUserGramsMailboxes1793979304483 implements MigrationInterface {
|
||||
name = 'SearchIndexUserGramsMailboxes1793979304483';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_user_grams_mailboxes" ON "mail_search_messages" USING GIN (lower("user_email"), "grams", "mailbox_ids")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_user_grams"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_mailbox_ids"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_mailbox_ids" ON "mail_search_messages" USING GIN ("mailbox_ids")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_user_grams_mailboxes"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 앱 알림 등록 표 (docs/mail/api-contract.md 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계).
|
||||
* - push_registrations: 앱 세션(sid)마다 한 줄 — 휴대폰 주소(FID) · 플랫폼 · 앱 판 · N 을 세는 시작(seen_at) · 마지막으로 보낸 때 · 만든 때 · 바꾼 때.
|
||||
* 직원 주소 인덱스(직원마다 읽기 · 상한 · 퇴사 마무리), FID 유일 인덱스(같은 휴대폰은 한 줄에만). 세션 표와 외래 키로 묶지 않는다
|
||||
* 기존 표의 칸 · 행은 건드리지 않는다 — 표를 새로 만들기만 한다.
|
||||
* (엔티티: modules/push/push-registration.entity.ts — migration:generate 의 결과(이 표의 세 문장뿐이었다)를 그대로 옮겼다)
|
||||
*/
|
||||
export class AddPushRegistrations1794057659283 implements MigrationInterface {
|
||||
name = 'AddPushRegistrations1794057659283';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "push_registrations" ("session_id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "fid" character varying(64) NOT NULL, "platform" character varying(16) NOT NULL, "app_version" character varying(16) NOT NULL, "seen_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_pushed_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_6f35810b223ea06c6d65e6f4219" PRIMARY KEY ("session_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_push_registrations_user_email" ON "push_registrations" ("user_email") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_push_registrations_fid" ON "push_registrations" ("fid") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_push_registrations_fid"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_push_registrations_user_email"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "push_registrations"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 판 표 (docs/mail/api-contract.md 1절 "휴대폰 앱 받기" — 사내 배포, 2026-11-08).
|
||||
* - app_releases: 운영 도구 「휴대폰 앱 올리기」가 올린 판마다 한 줄 — 플랫폼 · 판 · 크기 · SHA-256 · 디스크 이름(file_key UUID) · 올린 이(운영자 이름) · 올린 때.
|
||||
* file_key 유일 인덱스(디스크 이름은 한 줄에만), (platform, created_at) 인덱스(최신 = 그 플랫폼에서 가장 늦게 올린 줄)
|
||||
* 기존 표의 칸 · 행은 건드리지 않는다 — 표를 새로 만들기만 한다.
|
||||
* (엔티티: modules/app-releases/app-release.entity.ts — migration:generate 의 결과(이 표의 세 문장뿐이었다)를 그대로 옮겼다)
|
||||
*/
|
||||
export class AddAppReleases1794140434961 implements MigrationInterface {
|
||||
name = 'AddAppReleases1794140434961';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "app_releases" ("id" uuid NOT NULL, "platform" character varying(16) NOT NULL, "version" character varying(16) NOT NULL, "size_bytes" bigint NOT NULL, "sha256" character(64) NOT NULL, "file_key" uuid NOT NULL, "uploaded_by" character varying(100) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e5b28b6fe128172639b75251cd1" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_app_releases_file_key" ON "app_releases" ("file_key") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_app_releases_platform_created_at" ON "app_releases" ("platform", "created_at") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_app_releases_platform_created_at"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_app_releases_file_key"`);
|
||||
await queryRunner.query(`DROP TABLE "app_releases"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 여러 서명 (docs/mail/api-contract.md 2절 "서명" — 16단계).
|
||||
* mail_signatures 를 "한 사람에 한 줄(열쇠 = 주소)"에서 "서명마다 한 줄(열쇠 = id)"로 바꾼다 — 표를 새로 만들지 않고 고쳐 쓴다
|
||||
* (퇴사 마무리는 그대로 주소로 이 표의 줄을 지운다 — modules/ops/ops-data.service.ts).
|
||||
* - 지금 서명은 "기본 서명"이라는 이름의 서명 하나가 되고, 만든 시각은 저장한 시각이다. **내용이 비어 있던 서명(서식 · 텍스트가 모두 없거나 공백뿐)은 옮기지 않는다**(보드 결정)
|
||||
* - 두 체크(is_for_new · is_for_reply — 그 서명을 새 메일 · 답장과 전달에 넣는다)는 「메일에 넣을 서명」 둘의 표시가 된다 — 켠 곳의 기본이 그 서명, 끈 곳은 넣지 않음.
|
||||
* 칸 이름만 바꿔 값을 그대로 둔다(migration:generate 는 칸을 지우고 새로 만들어 값을 잃는다). 처음 값은 false(새로 만든 서명은 기본이 아니다)
|
||||
* - 한 사람에 기본 하나씩 — 부분 유일 인덱스 둘. 목록(만든 차례)은 (user_email, created_at) 인덱스
|
||||
* - 서명 그림 표(mail_signature_images)는 그대로 — 그림은 모든 서명이 함께 쓴다
|
||||
* 되돌리기(down): 한 사람에 한 줄만 남긴다 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 하나, **나머지 서명은 지운다**.
|
||||
* 남긴 서명의 두 표시가 옛 두 체크가 된다. 옛 서버는 서명을 다음에 저장할 때 그 서명이 가리키지 않는 그림을 지운다.
|
||||
* (엔티티: modules/mail-signature/mail-signature.entity.ts — migration:generate 의 결과에서 이름(PK · 인덱스)은 그대로 두고 값을 지키게 고쳤다)
|
||||
*/
|
||||
export class MultipleMailSignatures1794269810995 implements MigrationInterface {
|
||||
name = 'MultipleMailSignatures1794269810995';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 내용이 비어 있던 서명은 옮기지 않는다 — 서식 · 텍스트 모두 없거나 공백뿐 (서비스는 빈 서식을 null 로 저장해 왔다)
|
||||
await queryRunner.query(
|
||||
`DELETE FROM "mail_signatures" WHERE ("body_html" IS NULL OR "body_html" !~ '\\S') AND ("body_text" IS NULL OR "body_text" !~ '\\S')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD "id" uuid NOT NULL DEFAULT gen_random_uuid()`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "id" DROP DEFAULT`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD "name" character varying(64) NOT NULL DEFAULT '기본 서명'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "name" DROP DEFAULT`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD "created_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "mail_signatures" SET "created_at" = "updated_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "created_at" SET NOT NULL`,
|
||||
);
|
||||
// 두 체크 → 「메일에 넣을 서명」 둘의 표시 (값은 그대로)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_new" TO "is_new_message_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_new_message_default" SET DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_reply" TO "is_reply_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_reply_default" SET DEFAULT false`,
|
||||
);
|
||||
// 열쇠 — 주소 → 서명 id
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b" PRIMARY KEY ("id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_mail_signatures_reply_default" ON "mail_signatures" ("user_email") WHERE "is_reply_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_mail_signatures_new_message_default" ON "mail_signatures" ("user_email") WHERE "is_new_message_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_signatures_user_email_created_at" ON "mail_signatures" ("user_email", "created_at") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 한 사람에 한 줄만 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 남기고 나머지는 지운다
|
||||
await queryRunner.query(
|
||||
`DELETE FROM "mail_signatures" AS "s" USING (SELECT "id", row_number() OVER (PARTITION BY "user_email" ORDER BY "is_new_message_default" DESC, "is_reply_default" DESC, "created_at" ASC, "id" ASC) AS "rank" FROM "mail_signatures") AS "ranked" WHERE "s"."id" = "ranked"."id" AND "ranked"."rank" > 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_signatures_user_email_created_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."UQ_mail_signatures_new_message_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."UQ_mail_signatures_reply_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email")`,
|
||||
);
|
||||
// 남긴 서명의 표시 → 옛 두 체크 (처음 값은 옛 그대로 true)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_reply_default" TO "is_for_reply"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_reply" SET DEFAULT true`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_new_message_default" TO "is_for_new"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_new" SET DEFAULT true`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" DROP COLUMN "created_at"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "name"`);
|
||||
await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "id"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { JsonShapeLimits } from '@/shared/utils/json-shape';
|
||||
|
||||
/**
|
||||
* 계정 보안(12단계 — 계약 5절 "계정 보안") 상수 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅.
|
||||
* 로그인 · 코드 실패 한도는 같은 카운터를 쓰므로 modules/auth/auth.constants.ts 에 있다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 인증 앱에 보일 발급자 이름 — 앱에는 「ARA SYSTEM (주소)」로 저장된다 (계약 5절 "정한 것" 1).
|
||||
* 엔진(totp-rs from_url)이 이 이름이 든 otpauth 주소를 받고 그 주소로 코드를 확인하는 것을 개발 엔진에서 확인했다 (2026-10-31)
|
||||
*/
|
||||
export const TWO_FACTOR_ISSUER = 'ARA SYSTEM';
|
||||
/** 비밀 길이 — 160비트(base32 32자) */
|
||||
export const TWO_FACTOR_SECRET_BYTES = 20;
|
||||
/** 등록을 시작하고 켜기까지 — 10분 (계약 5절 "한도 · 규칙") */
|
||||
export const TWO_FACTOR_ENROLLMENT_TTL_MS = 10 * 60_000;
|
||||
/**
|
||||
* 같은 직원이 성공에 쓴 코드를 다시 받지 않는 시간 — 2분 (코드가 받아지는 90초보다 길게 — 로그인 · 설정 모두).
|
||||
* 엔진은 같은 코드를 다시 받는다(재사용을 막지 않는다 — 2026-10-31 확인)
|
||||
*/
|
||||
export const TWO_FACTOR_CODE_REUSE_MS = 2 * 60_000;
|
||||
|
||||
/** 앱 비밀번호 이름 — 1~40자 (앞뒤 공백을 지운 값, JS 문자열 길이) */
|
||||
export const APP_PASSWORD_NAME_MAX_LENGTH = 40;
|
||||
/**
|
||||
* 앱 비밀번호의 권한 — 메일 프로그램만(로그인 · IMAP · 메일 보내기). 엔진 기본(Inherit)이면 앱 비밀번호로 JMAP 을 쓰고
|
||||
* 앱 비밀번호를 또 만들 수 있다 — 그래서 늘 좁힌다 (계약 5절 "앱 비밀번호", 2026-10-31 확인)
|
||||
*/
|
||||
export const APP_PASSWORD_PERMISSIONS = {
|
||||
authenticate: true,
|
||||
imapAuthenticate: true,
|
||||
emailSend: true,
|
||||
} as const;
|
||||
|
||||
/** 로그인 기록 보존 — 90일 (날마다 정리) */
|
||||
export const LOGIN_EVENT_RETENTION_DAYS = 90;
|
||||
/**
|
||||
* 엔진 이벤트 기록을 한 문장에 넣는 행 수 — PostgreSQL 한 문장의 매개변수 상한(65,535)을 넘지 않게 나눈다.
|
||||
* 행마다 11칸이라 5,958행부터 넘는다(2026-10-31 리뷰) — 엔진은 실패한 묶음에 새 이벤트를 합쳐 다시 보내 묶음이 줄지 않는다
|
||||
*/
|
||||
export const LOGIN_EVENT_INSERT_CHUNK = 1_000;
|
||||
|
||||
/** 엔진 웹훅을 받는 경로 (글로벌 prefix 뒤) — 앞단 nginx 가 /api/internal/ 을 닫는다 (계약 5절 "엔진 웹훅 받기") */
|
||||
export const MAIL_ENGINE_EVENTS_ROUTE = 'internal/mail-engine-events';
|
||||
/** 이 경로만의 JSON 본문 한도 — 공격이 오면 엔진의 1초 묶음이 커진다 (계약 5절) */
|
||||
export const MAIL_ENGINE_EVENTS_JSON_LIMIT = '4mb';
|
||||
export const MAIL_ENGINE_EVENTS_SHAPE_LIMITS: JsonShapeLimits = {
|
||||
maxArrayLength: 10_000,
|
||||
maxValues: 200_000,
|
||||
maxDepth: 32,
|
||||
};
|
||||
/** 엔진이 본문에 붙이는 서명 머리글 — 본문 원문의 HMAC-SHA256, base64 */
|
||||
export const MAIL_ENGINE_SIGNATURE_HEADER = 'x-signature';
|
||||
/**
|
||||
* 웹훅 거절의 warn 을 같은 까닭마다 이 시간에 한 번만 남긴다 — 서명 키가 어긋나면 엔진이 1초마다 묶음을 보내고 다시 보내
|
||||
* 거절마다 남기면 하루 수만 줄이 된다 (2026-10-31 개발 — 서명 키 없이 뜬 백엔드에 1초마다). 그사이 거절한 수는 다음 줄에 붙인다
|
||||
*/
|
||||
export const MAIL_ENGINE_REJECTION_LOG_WINDOW_MS = 60_000;
|
||||
|
||||
/** 앱 네트워크 대역의 기본값 — compose 의 APP_NETWORK_SUBNET 기본값과 같다 (docker 스킬) */
|
||||
export const DEFAULT_APP_NETWORK_SUBNET = '172.16.71.0/24';
|
||||
|
||||
/** 웹훅 서명 키 — 영문 · 숫자 32자 이상 (Node 의 --env-file 이 `#` 부터 자른다 — 계약 5절 "환경 변수") */
|
||||
export const MAIL_WEBHOOK_SECRET_PATTERN = /^[A-Za-z0-9]{32,}$/;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuthSessionModule } from '@/modules/auth/auth-session.module';
|
||||
import { LoginAttemptModule } from '@/modules/auth/login-attempt.module';
|
||||
import { ContactsModule } from '@/modules/contacts/contacts.module';
|
||||
import { JmapAccountSecurityProvider } from '@/modules/mail/jmap/jmap-account-security.provider';
|
||||
import { MailEngineModule } from '@/modules/mail/mail-engine.module';
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module';
|
||||
import { ACCOUNT_SECURITY_PROVIDER } from './account-security.provider';
|
||||
import {
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
type AccountSecuritySettings,
|
||||
buildAccountSecuritySettings,
|
||||
} from './account-security.settings';
|
||||
import { AppPasswordUsageEntity } from './app-password-usage.entity';
|
||||
import { AppPasswordsController } from './app-passwords.controller';
|
||||
import { AppPasswordsService } from './app-passwords.service';
|
||||
import { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsCleanup } from './login-events.cleanup';
|
||||
import { LoginEventsController } from './login-events.controller';
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
import { MailEngineEventsController } from './mail-engine-events.controller';
|
||||
import { MailEngineEventsGuard } from './mail-engine-events.guard';
|
||||
import { MailEngineEventsService } from './mail-engine-events.service';
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import { TwoFactorEnrollments } from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
import { TwoFactorPolicyService } from './two-factor-policy.service';
|
||||
import { TwoFactorSettingEntity } from './two-factor-setting.entity';
|
||||
import { TwoFactorController } from './two-factor.controller';
|
||||
import { TwoFactorService } from './two-factor.service';
|
||||
|
||||
function createSettings(config: ConfigService): AccountSecuritySettings {
|
||||
const settings = buildAccountSecuritySettings((key) =>
|
||||
config.get<string>(key),
|
||||
);
|
||||
if (settings.webhookSecret === null) {
|
||||
// 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다)
|
||||
new Logger('AccountSecurityModule').warn(
|
||||
'MAIL_WEBHOOK_SECRET 이 없어 메일 엔진 웹훅을 모두 401 로 거절합니다 — 메일 프로그램(IMAP · SMTP)의 로그인 기록이 남지 않습니다.',
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 보안 모듈 (12단계 — 계약 5절 "계정 보안") — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅 받기.
|
||||
* 엔진을 바꿀 때는 ACCOUNT_SECURITY_PROVIDER 의 구현(useClass)만 바꾼다 — 서비스 · 컨트롤러는 포트만 안다.
|
||||
* 지금 구현은 Stalwart(JMAP) — 메일과 같은 엔진 연결(MailEngineModule)을 쓰고, 어댑터는 `modules/mail/jmap/` 에 있다.
|
||||
*
|
||||
* 인증 모듈이 로그인 · 비밀번호 바꾸기에 쓰도록 코드 한도 · 잠금 · 로그인 기록 · 앱 비밀번호를 내보낸다.
|
||||
* 이 모듈은 인증 모듈을 가져오지 않는다(인증 → 계정 보안 방향만) — 함께 쓰는 실패 카운터는 LoginAttemptModule 하나가 내놓는다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
TwoFactorSettingEntity,
|
||||
AppPasswordUsageEntity,
|
||||
LoginEventEntity,
|
||||
]),
|
||||
MailEngineModule,
|
||||
// 지금 비밀번호 확인(verifyCredentials — 본인 Basic)
|
||||
MailModule,
|
||||
// 로그인 기록의 실패를 사내 목록으로 거른다
|
||||
ContactsModule,
|
||||
LoginAttemptModule,
|
||||
// 13단계 — 2단계 인증 필수 정책(서버 설정)과, 켜면 세션의 twoFactorSetup 제한 지우기
|
||||
SystemSettingsModule,
|
||||
AuthSessionModule,
|
||||
],
|
||||
controllers: [
|
||||
TwoFactorController,
|
||||
AppPasswordsController,
|
||||
LoginEventsController,
|
||||
MailEngineEventsController,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: ACCOUNT_SECURITY_SETTINGS,
|
||||
inject: [ConfigService],
|
||||
useFactory: createSettings,
|
||||
},
|
||||
{
|
||||
provide: ACCOUNT_SECURITY_PROVIDER,
|
||||
useClass: JmapAccountSecurityProvider,
|
||||
},
|
||||
TwoFactorService,
|
||||
TwoFactorEnrollments,
|
||||
TwoFactorCodeGuard,
|
||||
TwoFactorLocks,
|
||||
TwoFactorPolicyService,
|
||||
AppPasswordsService,
|
||||
LoginEventsService,
|
||||
LoginEventsCleanup,
|
||||
MailEngineEventsService,
|
||||
MailEngineEventsGuard,
|
||||
],
|
||||
exports: [
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
// 13단계 — 인증 모듈이 로그인 · 갱신에서 2단계 인증 필수 정책을 본다
|
||||
TwoFactorPolicyService,
|
||||
TwoFactorCodeGuard,
|
||||
TwoFactorLocks,
|
||||
AppPasswordsService,
|
||||
LoginEventsService,
|
||||
],
|
||||
})
|
||||
export class AccountSecurityModule {}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 계정 보안 엔진 포트 (계약 5절 — 2단계 인증 · 앱 비밀번호) — 서비스 계층은 이 인터페이스만 안다 (메일 엔진 포트 mail.provider.ts 와 같은 규칙).
|
||||
*
|
||||
* 지금 구현은 Stalwart(JMAP) — `modules/mail/jmap/jmap-account-security.provider.ts`(게이트웨이 대리 접근으로 x:AccountPassword · x:AppPassword).
|
||||
* 엔진을 바꿀 때는 이 인터페이스의 새 구현을 account-security.module.ts 에 연결한다.
|
||||
*
|
||||
* 규칙:
|
||||
* - 우리 도메인 타입만 받고 돌려준다 (엔진 타입 · id 형식을 밖으로 흘리지 않는다 — 앱 비밀번호 id 는 불투명 문자열)
|
||||
* - 실패는 BusinessException(에러 코드)으로 던진다 — 엔진 장애 MAIL_001 · 한도 REQ_001 · 계정을 쓸 수 없음 AUTH_001
|
||||
* - `user` 는 인증된 직원 주소다. 어댑터는 그 직원의 자격 증명에만 접근한다
|
||||
* - 비밀번호 · 코드 · 비밀(otpauth) · 앱 비밀번호 값 · 이름은 어떤 로그에도 남기지 않는다
|
||||
*/
|
||||
export const ACCOUNT_SECURITY_PROVIDER = Symbol('ACCOUNT_SECURITY_PROVIDER');
|
||||
|
||||
/** 앱 비밀번호 하나 — 값은 만들 때만 (CreatedAppPassword) */
|
||||
export interface AppPasswordRecord {
|
||||
/** 엔진의 앱 비밀번호 id — 지운 번호를 엔진이 다시 쓴다 (createdAt 과 함께 봐야 같은 것이다) */
|
||||
readonly id: string;
|
||||
/** 이름 — 직원이 붙인 글 */
|
||||
readonly name: string;
|
||||
/** 만든 때 (ISO 8601 UTC) */
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
/** 방금 만든 앱 비밀번호 — 값(password)은 이 한 번만 엔진이 준다 */
|
||||
export interface CreatedAppPassword extends AppPasswordRecord {
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 켜기의 엔진 판단:
|
||||
* - enabled: 켰다
|
||||
* - refused: 엔진이 받지 않았다 — 지금 비밀번호가 맞지 않음 · 그사이 다른 곳에서 켜짐(지금 코드를 요구함)
|
||||
* - unknown: 결과를 알 수 없게 끝났다(시간 초과 · 연결 끊김 · 5xx) — 엔진을 다시 읽어 맞춘다
|
||||
*/
|
||||
export type TwoFactorEnableResult = 'enabled' | 'refused' | 'unknown';
|
||||
|
||||
/**
|
||||
* 2단계 인증 끄기의 엔진 판단 — 비밀번호는 부르는 쪽이 먼저 확인한다:
|
||||
* - disabled: 껐다
|
||||
* - code-rejected: 엔진이 받지 않았다 — 코드 틀림 (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다)
|
||||
* - unknown: 결과를 알 수 없게 끝났다 — 엔진을 다시 읽어 맞춘다
|
||||
*/
|
||||
export type TwoFactorDisableResult = 'disabled' | 'code-rejected' | 'unknown';
|
||||
|
||||
/**
|
||||
* 2단계 인증 휴대폰 바꾸기의 엔진 판단 (16단계 — 켠 채 새 비밀로) — 비밀번호는 부르는 쪽이 먼저 확인한다:
|
||||
* - replaced: 바꿨다 — 옛 비밀은 바로 죽는다
|
||||
* - code-rejected: 엔진이 받지 않았다 — 지금 휴대폰의 코드 틀림 (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다)
|
||||
* - unknown: 결과를 알 수 없게 끝났다 — 엔진은 otpUrl 을 가려 보여 주므로 다시 읽어도 가를 수 없다
|
||||
*/
|
||||
export type TwoFactorReplaceResult = 'replaced' | 'code-rejected' | 'unknown';
|
||||
|
||||
export interface AccountSecurityProvider {
|
||||
/** 엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다 (계약 5절 "2단계 인증") */
|
||||
isTwoFactorEnabled(user: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 2단계 인증을 켠다 — 지금 비밀번호와 otpauth 주소(서버가 만든 비밀). 엔진은 코드를 보지 않고 저장한다 —
|
||||
* 첫 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다
|
||||
*/
|
||||
enableTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpauthUrl: string,
|
||||
): Promise<TwoFactorEnableResult>;
|
||||
|
||||
/** 2단계 인증을 끈다 — 지금 비밀번호와 지금 코드 */
|
||||
disableTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpCode: string,
|
||||
): Promise<TwoFactorDisableResult>;
|
||||
|
||||
/**
|
||||
* 2단계 인증을 켠 채 휴대폰(비밀)을 바꾼다 (16단계) — 지금 비밀번호 · 지금 휴대폰의 코드 · 새 otpauth 주소를 한 요청으로.
|
||||
* 2단계가 꺼지는 틈이 없다. 엔진은 새 비밀의 코드를 보지 않는다 — 새 휴대폰의 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다
|
||||
*/
|
||||
replaceTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpCode: string,
|
||||
otpauthUrl: string,
|
||||
): Promise<TwoFactorReplaceResult>;
|
||||
|
||||
/** 앱 비밀번호 목록 (차례는 부르는 쪽이 정한다) */
|
||||
listAppPasswords(user: string): Promise<AppPasswordRecord[]>;
|
||||
|
||||
/**
|
||||
* 앱 비밀번호를 만든다 — 권한은 메일 프로그램만(로그인 · IMAP · 보내기), 만료 · 허용 IP 없음.
|
||||
* 엔진 상한을 넘으면 AUTH_009. 결과를 모르게 끝나면 MAIL_001 (값을 받지 못했으니 다시 만든다 — 목록에 남은 것은 직원이 지운다)
|
||||
*/
|
||||
createAppPassword(user: string, name: string): Promise<CreatedAppPassword>;
|
||||
|
||||
/** 앱 비밀번호 하나를 지운다 — 이미 없으면 그대로 끝난다 */
|
||||
deleteAppPassword(user: string, id: string): Promise<void>;
|
||||
|
||||
/** 그 직원의 앱 비밀번호를 모두 지운다 → 지운 수 (비밀번호를 바꿨을 때 — 계약 5절 "비밀번호 바꾸기") */
|
||||
deleteAllAppPasswords(user: string): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { buildAccountSecuritySettings } from './account-security.settings';
|
||||
|
||||
const read =
|
||||
(values: Record<string, string>) =>
|
||||
(key: string): string | undefined =>
|
||||
values[key];
|
||||
|
||||
describe('buildAccountSecuritySettings (계약 5절 "환경 변수")', () => {
|
||||
it('서명 키 · 앱 네트워크 대역을 읽는다', () => {
|
||||
const settings = buildAccountSecuritySettings(
|
||||
read({
|
||||
MAIL_WEBHOOK_SECRET: 'Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a',
|
||||
APP_NETWORK_SUBNET: '10.20.0.0/16',
|
||||
}),
|
||||
);
|
||||
expect(settings.webhookSecret).toBe('Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a');
|
||||
expect(settings.appNetwork).toEqual({
|
||||
address: '10.20.0.0',
|
||||
prefix: 16,
|
||||
family: 4,
|
||||
});
|
||||
expect(settings.isAppNetworkAddress('10.20.3.4')).toBe(true);
|
||||
expect(settings.isAppNetworkAddress('::ffff:10.20.3.4')).toBe(true);
|
||||
expect(settings.isAppNetworkAddress('172.16.71.2')).toBe(false);
|
||||
});
|
||||
|
||||
it('비거나 형식이 틀리면 서명 키 없음(웹훅을 모두 거절) · 대역은 compose 기본값 172.16.71.0/24', () => {
|
||||
const cases: Record<string, string>[] = [
|
||||
{},
|
||||
{ MAIL_WEBHOOK_SECRET: '', APP_NETWORK_SUBNET: '' },
|
||||
{ MAIL_WEBHOOK_SECRET: 'short', APP_NETWORK_SUBNET: 'not-a-cidr' },
|
||||
];
|
||||
for (const values of cases) {
|
||||
const settings = buildAccountSecuritySettings(read(values));
|
||||
expect(settings.webhookSecret).toBeNull();
|
||||
expect(settings.isAppNetworkAddress('172.16.71.2')).toBe(true);
|
||||
expect(settings.isAppNetworkAddress('172.16.72.2')).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createCidrMatcher, parseCidr, type Cidr } from '@/shared/utils/cidr';
|
||||
import {
|
||||
DEFAULT_APP_NETWORK_SUBNET,
|
||||
MAIL_WEBHOOK_SECRET_PATTERN,
|
||||
} from './account-security.constants';
|
||||
|
||||
/**
|
||||
* 계정 보안 설정 (계약 5절 "환경 변수" · "엔진 웹훅 받기") — 루트 env 에서 compose 가 넣는다. 검사는 env.validation.ts 가 기동 때 한다.
|
||||
*/
|
||||
export const ACCOUNT_SECURITY_SETTINGS = Symbol('ACCOUNT_SECURITY_SETTINGS');
|
||||
|
||||
export interface AccountSecuritySettings {
|
||||
/**
|
||||
* 엔진 웹훅 서명 키(`MAIL_WEBHOOK_SECRET` — setup 이 엔진의 x:WebHook 에 같은 값을 넣는다). 없거나 형식이 틀리면 null —
|
||||
* 웹훅을 모두 401 로 거절한다 (운영은 env 검증이 필수로 막는다)
|
||||
*/
|
||||
readonly webhookSecret: string | null;
|
||||
/** 엔진이 붙는 앱 네트워크 대역(`APP_NETWORK_SUBNET` — 없으면 compose 기본값 172.16.71.0/24) — 웹훅의 소켓 주소가 이 안이어야 한다 */
|
||||
readonly appNetwork: Cidr;
|
||||
/** 소켓 주소가 앱 네트워크 안인가 */
|
||||
readonly isAppNetworkAddress: (address: string | null | undefined) => boolean;
|
||||
}
|
||||
|
||||
/** env 값을 읽어 설정을 만든다 (순수 함수 — 읽기 함수를 주입받는다). 대역이 틀리면 기본값 (형식 오류는 env 검증이 기동 때 막는다) */
|
||||
export function buildAccountSecuritySettings(
|
||||
read: (key: string) => string | undefined,
|
||||
): AccountSecuritySettings {
|
||||
const secret = read('MAIL_WEBHOOK_SECRET')?.trim() ?? '';
|
||||
const configured = read('APP_NETWORK_SUBNET')?.trim();
|
||||
const appNetwork =
|
||||
(configured ? parseCidr(configured) : null) ?? defaultAppNetwork();
|
||||
return {
|
||||
webhookSecret: MAIL_WEBHOOK_SECRET_PATTERN.test(secret) ? secret : null,
|
||||
appNetwork,
|
||||
isAppNetworkAddress: createCidrMatcher(appNetwork),
|
||||
};
|
||||
}
|
||||
|
||||
/** compose 기본값의 대역 — 상수라 늘 읽힌다 */
|
||||
function defaultAppNetwork(): Cidr {
|
||||
const cidr = parseCidr(DEFAULT_APP_NETWORK_SUBNET);
|
||||
if (cidr === null) {
|
||||
throw new Error(
|
||||
`기본 앱 네트워크 대역(${DEFAULT_APP_NETWORK_SUBNET})을 읽지 못했습니다.`,
|
||||
);
|
||||
}
|
||||
return cidr;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 엔진의 JMAP id — 64비트 수의 32진수라 13자까지 */
|
||||
const APP_PASSWORD_ID_COLUMN_LENGTH = 16;
|
||||
/** 쓰인 곳 — imap · smtp */
|
||||
const METHOD_COLUMN_LENGTH = 8;
|
||||
/** IP 문자열 — IPv6 최대 45자에 여유 */
|
||||
const IP_COLUMN_LENGTH = 64;
|
||||
|
||||
/** 앱 비밀번호가 쓰인 곳 (엔진 웹훅의 리스너) */
|
||||
export type AppPasswordUseMethod = 'imap' | 'smtp';
|
||||
|
||||
/**
|
||||
* 앱 비밀번호의 서버 기록 (계약 5절 "앱 비밀번호 — 마지막 사용") — 엔진에 마지막 사용 칸이 없어 웹훅의 auth.success(앱 비밀번호)로 만든다.
|
||||
* 엔진은 지운 번호를 다시 쓴다 — 그래서 **같은 번호 · 같은 createdAt** 일 때만 잇는다(목록이 엔진의 createdAt 과 견준다).
|
||||
* 만들 때 서버가 이 줄을 새로 둔다(웹메일 밖에서 만든 것은 목록을 볼 때). 이름은 그때의 이름이다 — 로그인 기록에 앱 비밀번호 이름을 남길 때 쓴다.
|
||||
* 값(비밀)은 두지 않는다
|
||||
*/
|
||||
@Entity({ name: 'app_password_usages' })
|
||||
export class AppPasswordUsageEntity {
|
||||
@PrimaryColumn({
|
||||
name: 'user_email',
|
||||
type: 'varchar',
|
||||
length: EMAIL_COLUMN_LENGTH,
|
||||
})
|
||||
userEmail!: string;
|
||||
|
||||
@PrimaryColumn({
|
||||
name: 'app_password_id',
|
||||
type: 'varchar',
|
||||
length: APP_PASSWORD_ID_COLUMN_LENGTH,
|
||||
})
|
||||
appPasswordId!: string;
|
||||
|
||||
/** 그때의 이름 (40자까지 — 엔진에서 더 긴 이름이면 잘라 둔다) */
|
||||
@Column({ type: 'varchar', length: APP_PASSWORD_NAME_MAX_LENGTH })
|
||||
name!: string;
|
||||
|
||||
/** 엔진이 알려 준 만든 때 — 같은 번호를 다시 쓴 앱 비밀번호와 가른다 */
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
|
||||
lastUsedAt!: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'last_used_method',
|
||||
type: 'varchar',
|
||||
length: METHOD_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
lastUsedMethod!: AppPasswordUseMethod | null;
|
||||
|
||||
@Column({
|
||||
name: 'last_used_ip',
|
||||
type: 'varchar',
|
||||
length: IP_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
lastUsedIp!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
APP_PASSWORD_ID_PATTERN,
|
||||
credentialNumberToId,
|
||||
isAppPasswordShape,
|
||||
} from './app-password';
|
||||
|
||||
describe('isAppPasswordShape — 엔진이 앱 비밀번호로 읽는 모양 (웹메일에서 거절한다)', () => {
|
||||
it('엔진이 만드는 값(app_ + 엔진 base32 36자)은 앱 비밀번호다', () => {
|
||||
expect(isAppPasswordShape('app_abcdefghijklmnopqrstuvwxyz0123791203')).toBe(
|
||||
true,
|
||||
);
|
||||
// 넷째 글자는 엔진이 건너뛴다 · 대문자 K-Z 도 엔진이 받는다(역표의 특이점)
|
||||
expect(isAppPasswordShape(`appX${'kLMNOPQRSTUVWXYZ'.repeat(2)}abcd`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['39자', `app_${'a'.repeat(35)}`],
|
||||
['41자', `app_${'a'.repeat(37)}`],
|
||||
['app 으로 시작하지 않음', `ap__${'a'.repeat(36)}`],
|
||||
['대문자 APP', `APP_${'a'.repeat(36)}`],
|
||||
['엔진이 받지 않는 글자(4 · 5 · 6 · 8)', `app_${'4568'.repeat(9)}`],
|
||||
['엔진이 받지 않는 대문자 A-J', `app_${'ABCDEFGHIJ'.repeat(3)}abcdef`],
|
||||
['기호', `app_${'a'.repeat(35)}!`],
|
||||
['보통 비밀번호', 'correct-horse-battery-staple'],
|
||||
])('%s 은 앱 비밀번호가 아니다', (_label, value) => {
|
||||
expect(isAppPasswordShape(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credentialNumberToId — 엔진의 자격 번호 → JMAP id (Stalwart Id::as_string)', () => {
|
||||
it.each([
|
||||
[0, 'a'],
|
||||
[1, 'b'],
|
||||
[2, 'c'],
|
||||
[25, 'z'],
|
||||
[26, '7'],
|
||||
[31, '3'],
|
||||
[32, 'ba'],
|
||||
[33, 'bb'],
|
||||
[1024, 'baa'],
|
||||
])('%d → %s', (value, id) => {
|
||||
expect(credentialNumberToId(value)).toBe(id);
|
||||
expect(APP_PASSWORD_ID_PATTERN.test(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('음이 아닌 안전한 정수가 아니면 null', () => {
|
||||
for (const value of [-1, 1.5, Number.NaN, '1', null, undefined, 2 ** 60]) {
|
||||
expect(credentialNumberToId(value)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 앱 비밀번호 판정 (순수 함수) — 계약 5절 "앱 비밀번호".
|
||||
*/
|
||||
|
||||
/**
|
||||
* 엔진이 앱 비밀번호로 읽는 모양 — `app` + 아무 글자 하나 + 엔진의 base32 글자 36자 (모두 40자).
|
||||
* 엔진(Stalwart 0.16.27 — `auth/credential.rs`)은 `app` 으로 시작하고 넷째 글자를 건너뛴 뒤 사용자 정의 base32
|
||||
* (소문자 a-z · 숫자 0 1 2 3 7 9, 그리고 역표의 특이점으로 대문자 K-Z)로 22바이트(자격 번호 4 + 비밀 18)를 읽을 수 있으면 앱 비밀번호로 본다.
|
||||
* 엔진이 만드는 값은 `app_` + 36자다. 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기에서 이 모양이면 엔진에 묻지 않고 거절한다 —
|
||||
* 엔진에는 앱 비밀번호를 웹메일에서 빼는 설정이 없다. 우연히 이 모양인 진짜 비밀번호는 엔진도 앱 비밀번호로 읽어 어차피 실패한다.
|
||||
* 정규식은 길이가 고정이라 입력 길이에 비례해 끝난다
|
||||
*/
|
||||
const APP_PASSWORD_SHAPE = /^app[\s\S][a-zK-Z012379]{36}$/;
|
||||
|
||||
export function isAppPasswordShape(password: string): boolean {
|
||||
return password.length === 40 && APP_PASSWORD_SHAPE.test(password);
|
||||
}
|
||||
|
||||
/** 엔진의 JMAP id 글자 (Stalwart `utils::codec::base32_custom::BASE32_ALPHABET`) */
|
||||
const JMAP_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013';
|
||||
const JMAP_ID_RADIX = 32;
|
||||
|
||||
/**
|
||||
* 엔진의 자격 번호(웹훅 `data.id` — 앱 비밀번호로 들어온 성공 이벤트) → 앱 비밀번호의 JMAP id (`x:AppPassword` 의 id).
|
||||
* 엔진은 자격 번호를 그대로 JMAP id 로 적는다 — 위 글자로 쓴 32진수, 큰 자리부터(0 은 `a`, 1 은 `b` — Stalwart `types::id::Id::as_string`).
|
||||
* 음이 아닌 안전한 정수가 아니면 null
|
||||
*/
|
||||
export function credentialNumberToId(value: unknown): string | null {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
||||
return null;
|
||||
}
|
||||
let rest = value;
|
||||
let id = '';
|
||||
do {
|
||||
id = JMAP_ID_ALPHABET[rest % JMAP_ID_RADIX] + id;
|
||||
rest = Math.floor(rest / JMAP_ID_RADIX);
|
||||
} while (rest > 0);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 앱 비밀번호 id 의 모양 — 엔진 JMAP id 글자만, 짧게 (경로 매개변수 검사) */
|
||||
export const APP_PASSWORD_ID_PATTERN = /^[a-z0-9]{1,13}$/;
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
AppPasswordsService,
|
||||
type AppPasswordView,
|
||||
} from './app-passwords.service';
|
||||
import {
|
||||
AppPasswordPageDto,
|
||||
CreateAppPasswordDto,
|
||||
CreatedAppPasswordDto,
|
||||
type AppPasswordDto,
|
||||
} from './dto/app-password.dto';
|
||||
|
||||
function toDto(view: AppPasswordView): AppPasswordDto {
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
createdAt: view.createdAt.toISOString(),
|
||||
lastUsed: view.lastUsed
|
||||
? {
|
||||
at: view.lastUsed.at.toISOString(),
|
||||
method: view.lastUsed.method,
|
||||
ip: view.lastUsed.ip,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 앱 비밀번호 (계약 5절 "앱 비밀번호") — 메일 프로그램(IMAP 993 · SMTP 465)에 넣는 비밀번호. 판단은 AppPasswordsService 가 한다
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@Controller('app-passwords')
|
||||
export class AppPasswordsController {
|
||||
constructor(private readonly appPasswords: AppPasswordsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '앱 비밀번호 목록 — 최근에 만든 것이 위 (한 쪽)',
|
||||
description:
|
||||
'마지막 사용(lastUsed)은 서버가 엔진의 로그인 이벤트(웹훅)로 만든 기록이다 — 같은 번호 · 같은 만든 때인 것만 잇는다. 아직 쓰지 않았으면 null.',
|
||||
})
|
||||
@ApiStandardResponse(AppPasswordPageDto, { description: '목록' })
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async list(@CurrentUser() user: AuthenticatedUser) {
|
||||
const page = await this.appPasswords.list(user.email);
|
||||
return { items: page.items.map(toDto), pageInfo: page.pageInfo };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '앱 비밀번호 만들기 — 값은 이 응답에만',
|
||||
description:
|
||||
'권한은 메일 프로그램만(로그인 · IMAP · 메일 보내기) — 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 끄기에는 쓰이지 않는다. 만료 · 허용 IP 는 두지 않는다. ' +
|
||||
'값은 엔진이 만들어 이 응답에만 싣는다(서버 · 로그에 남지 않는다 — Cache-Control: private, no-store). 2단계 인증을 켜지 않은 직원도 만든다.',
|
||||
})
|
||||
@ApiStandardResponse(CreatedAppPasswordDto, {
|
||||
status: 201,
|
||||
description: '만든 앱 비밀번호 — password 는 다시 볼 수 없다',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'이름이 비었거나 40자를 넘음 · 이미 있는 이름(앞뒤 공백을 지운 값이 같음) · 형식 오류',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_009',
|
||||
'엔진 상한(기본 5개)을 넘음 — 쓰지 않는 것을 지운 뒤 만든다',
|
||||
)
|
||||
@ApiErrorResponse('REQ_001', '같은 직원의 만들기가 처리 중(한 번에 하나)')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 결과를 알 수 없음(값을 받지 못했다 — 목록에 남은 것은 지우고 다시 만든다)',
|
||||
)
|
||||
async create(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: CreateAppPasswordDto,
|
||||
): Promise<CreatedAppPasswordDto> {
|
||||
const created = await this.appPasswords.create(user.email, dto.name);
|
||||
return { ...toDto(created), password: created.password };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '앱 비밀번호 지우기 — 이미 없으면 200',
|
||||
description:
|
||||
'이 비밀번호로 새로 연결하는 메일 프로그램은 바로 막힌다. 이미 열린 IMAP 연결은 엔진이 끊지 않는다(다음 연결부터 — 알려진 한계).',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '앱 비밀번호 id', example: 'b' })
|
||||
@ApiStandardResponse(undefined, { description: '지웠다 (data: null)' })
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async remove(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id') id: string,
|
||||
): Promise<null> {
|
||||
await this.appPasswords.remove(user.email, id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type {
|
||||
AccountSecurityProvider,
|
||||
AppPasswordRecord,
|
||||
} from './account-security.provider';
|
||||
import type { AppPasswordUsageEntity } from './app-password-usage.entity';
|
||||
import { AppPasswordsService } from './app-passwords.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const VALUE = 'app_abcdefghijklmnopqrstuvwxyz0123791203';
|
||||
|
||||
async function failureOf(
|
||||
promise: Promise<unknown>,
|
||||
): Promise<BusinessException | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) return error;
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('AppPasswordsService (앱 비밀번호 — 계약 5절)', () => {
|
||||
let records: AppPasswordRecord[];
|
||||
let usageRows: AppPasswordUsageEntity[];
|
||||
let provider: jest.Mocked<
|
||||
Pick<
|
||||
AccountSecurityProvider,
|
||||
| 'listAppPasswords'
|
||||
| 'createAppPassword'
|
||||
| 'deleteAppPassword'
|
||||
| 'deleteAllAppPasswords'
|
||||
>
|
||||
>;
|
||||
let usages: {
|
||||
findBy: jest.Mock;
|
||||
findOneBy: jest.Mock;
|
||||
upsert: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let service: AppPasswordsService;
|
||||
let logs: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
records = [];
|
||||
usageRows = [];
|
||||
provider = {
|
||||
listAppPasswords: jest.fn<Promise<AppPasswordRecord[]>, [string]>(() =>
|
||||
Promise.resolve([...records]),
|
||||
),
|
||||
createAppPassword: jest.fn((_user: string, name: string) => {
|
||||
const created = {
|
||||
id: 'c',
|
||||
name,
|
||||
createdAt: '2026-10-31T00:20:00.000Z',
|
||||
password: VALUE,
|
||||
};
|
||||
records.push({ id: 'c', name, createdAt: created.createdAt });
|
||||
return Promise.resolve(created);
|
||||
}),
|
||||
deleteAppPassword: jest.fn<Promise<void>, [string, string]>(() =>
|
||||
Promise.resolve(),
|
||||
),
|
||||
deleteAllAppPasswords: jest.fn<Promise<number>, [string]>(() =>
|
||||
Promise.resolve(records.length),
|
||||
),
|
||||
};
|
||||
usages = {
|
||||
findBy: jest.fn(() => Promise.resolve([...usageRows])),
|
||||
findOneBy: jest.fn(({ appPasswordId }: { appPasswordId: string }) =>
|
||||
Promise.resolve(
|
||||
usageRows.find((row) => row.appPasswordId === appPasswordId) ?? null,
|
||||
),
|
||||
),
|
||||
upsert: jest.fn(
|
||||
(rows: AppPasswordUsageEntity | AppPasswordUsageEntity[]) => {
|
||||
for (const row of Array.isArray(rows) ? rows : [rows]) {
|
||||
usageRows = usageRows.filter(
|
||||
(existing) => existing.appPasswordId !== row.appPasswordId,
|
||||
);
|
||||
usageRows.push({ ...row });
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
createQueryBuilder: jest.fn(() => {
|
||||
const builder = {
|
||||
update: () => builder,
|
||||
set: () => builder,
|
||||
where: () => builder,
|
||||
execute: () => Promise.resolve({ affected: 1 }),
|
||||
};
|
||||
return builder;
|
||||
}),
|
||||
};
|
||||
service = new AppPasswordsService(
|
||||
provider as unknown as AccountSecurityProvider,
|
||||
usages as unknown as Repository<AppPasswordUsageEntity>,
|
||||
);
|
||||
logs = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation((message: unknown) => {
|
||||
logs.push(String(message));
|
||||
});
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
logs.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('만들기 — 앞뒤 공백을 지운 이름으로 만들고 값은 응답에만 준다, 기록을 새로 둔다 (로그에는 가린 주소 + id 만)', async () => {
|
||||
const created = await service.create(ARA, ' 회사 PC 아웃룩 ');
|
||||
|
||||
expect(provider.createAppPassword).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'회사 PC 아웃룩',
|
||||
);
|
||||
expect(created).toEqual({
|
||||
id: 'c',
|
||||
name: '회사 PC 아웃룩',
|
||||
createdAt: new Date('2026-10-31T00:20:00.000Z'),
|
||||
lastUsed: null,
|
||||
password: VALUE,
|
||||
});
|
||||
expect(usageRows).toEqual([
|
||||
expect.objectContaining({
|
||||
appPasswordId: 'c',
|
||||
name: '회사 PC 아웃룩',
|
||||
createdAt: new Date('2026-10-31T00:20:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
}),
|
||||
]);
|
||||
const everything = logs.join('\n');
|
||||
expect(everything).toContain('a***@arasystem.test, id=c');
|
||||
expect(everything).not.toContain(VALUE);
|
||||
expect(everything).not.toContain('회사 PC 아웃룩');
|
||||
});
|
||||
|
||||
it('같은 이름(앞뒤 공백을 지운 값)이 있으면 400 VAL_001 — 엔진에 만들기를 보내지 않는다', async () => {
|
||||
records = [
|
||||
{
|
||||
id: 'b',
|
||||
name: '회사 PC 아웃룩 ',
|
||||
createdAt: '2026-10-30T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
expect((await failureOf(service.create(ARA, '회사 PC 아웃룩')))?.code).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(provider.createAppPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('엔진 상한의 거절(AUTH_009)은 그대로 던진다', async () => {
|
||||
provider.createAppPassword.mockRejectedValue(
|
||||
new BusinessException('AUTH_009'),
|
||||
);
|
||||
expect((await failureOf(service.create(ARA, '다섯째')))?.code).toBe(
|
||||
'AUTH_009',
|
||||
);
|
||||
});
|
||||
|
||||
it('목록 — 최근에 만든 것이 위, 같은 번호 · 같은 만든 때의 기록만 마지막 사용으로 잇는다', async () => {
|
||||
records = [
|
||||
{ id: 'b', name: '집 PC', createdAt: '2026-10-30T00:00:00.000Z' },
|
||||
{ id: 'c', name: '휴대폰', createdAt: '2026-10-31T00:00:00.000Z' },
|
||||
];
|
||||
usageRows = [
|
||||
{
|
||||
userEmail: ARA,
|
||||
appPasswordId: 'b',
|
||||
name: '집 PC',
|
||||
createdAt: new Date('2026-10-30T00:00:00.000Z'),
|
||||
lastUsedAt: new Date('2026-10-31T00:41:00.000Z'),
|
||||
lastUsedMethod: 'imap',
|
||||
lastUsedIp: '211.186.95.252',
|
||||
},
|
||||
// 엔진이 번호 c 를 다시 썼다 — 옛 기록(만든 때가 다르다)은 잇지 않는다
|
||||
{
|
||||
userEmail: ARA,
|
||||
appPasswordId: 'c',
|
||||
name: '지운 옛것',
|
||||
createdAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||
lastUsedAt: new Date('2026-10-02T00:00:00.000Z'),
|
||||
lastUsedMethod: 'smtp',
|
||||
lastUsedIp: null,
|
||||
},
|
||||
];
|
||||
|
||||
const page = await service.list(ARA);
|
||||
|
||||
expect(page.items.map(({ id }) => id)).toEqual(['c', 'b']);
|
||||
expect(page.items[0].lastUsed).toBeNull();
|
||||
expect(page.items[1].lastUsed).toEqual({
|
||||
at: new Date('2026-10-31T00:41:00.000Z'),
|
||||
method: 'imap',
|
||||
ip: '211.186.95.252',
|
||||
});
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
});
|
||||
// 다른 앱 비밀번호의 기록은 새로 둔다(만든 때 · 이름 · 마지막 사용 없음)
|
||||
expect(usageRows.find((row) => row.appPasswordId === 'c')).toMatchObject({
|
||||
name: '휴대폰',
|
||||
createdAt: new Date('2026-10-31T00:00:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('지우기 — 모양이 아닌 id 는 엔진에 묻지 않고 그대로 끝난다', async () => {
|
||||
await service.remove(ARA, 'b');
|
||||
expect(provider.deleteAppPassword).toHaveBeenCalledWith(ARA, 'b');
|
||||
|
||||
provider.deleteAppPassword.mockClear();
|
||||
await service.remove(ARA, '../etc/passwd');
|
||||
await service.remove(ARA, 'B');
|
||||
expect(provider.deleteAppPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('비밀번호를 바꾼 뒤 모두 지운다 — 실패해도 던지지 않고 error 로그(가린 주소)', async () => {
|
||||
records = [
|
||||
{ id: 'b', name: 'x', createdAt: '2026-10-30T00:00:00.000Z' },
|
||||
{ id: 'c', name: 'y', createdAt: '2026-10-30T00:00:00.000Z' },
|
||||
];
|
||||
await expect(service.removeAllAfterPasswordChange(ARA)).resolves.toBe(2);
|
||||
expect(usages.delete).toHaveBeenCalledWith({ userEmail: ARA });
|
||||
|
||||
provider.deleteAllAppPasswords.mockRejectedValue(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
await expect(service.removeAllAfterPasswordChange(ARA)).resolves.toBe(0);
|
||||
expect(logs.join('\n')).toMatch(
|
||||
/\[SYS_001\].*앱 비밀번호를 지우지 못했습니다.*a\*\*\*@arasystem\.test/,
|
||||
);
|
||||
});
|
||||
|
||||
it('웹훅의 사용 — 기록이 있으면 마지막 사용을 고치고 그때의 이름을 돌려준다, 없으면 null', async () => {
|
||||
usageRows = [
|
||||
{
|
||||
userEmail: ARA,
|
||||
appPasswordId: 'b',
|
||||
name: '집 PC',
|
||||
createdAt: new Date('2026-10-30T00:00:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
lastUsedMethod: null,
|
||||
lastUsedIp: null,
|
||||
},
|
||||
];
|
||||
const use = { at: new Date(), method: 'imap' as const, ip: '1.2.3.4' };
|
||||
await expect(service.recordUse(ARA, 'b', use)).resolves.toBe('집 PC');
|
||||
expect(usages.createQueryBuilder).toHaveBeenCalledTimes(1);
|
||||
await expect(service.recordUse(ARA, 'z', use)).resolves.toBeNull();
|
||||
expect(usages.createQueryBuilder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppPasswordsService.create — 같은 직원의 만들기는 한 번에 하나 (같은 이름 검사가 동시 요청에서 겹치지 않게)', () => {
|
||||
it('앞 만들기가 끝나기 전에 온 만들기는 429 REQ_001 — 끝나면 다시 된다, 다른 직원은 따로', async () => {
|
||||
let finish!: () => void;
|
||||
const provider = {
|
||||
listAppPasswords: jest.fn(() => Promise.resolve([])),
|
||||
createAppPassword: jest.fn(
|
||||
(_user: string, name: string) =>
|
||||
new Promise((resolve) => {
|
||||
finish = () =>
|
||||
resolve({
|
||||
id: 'b',
|
||||
name,
|
||||
createdAt: '2026-10-31T00:00:00.000Z',
|
||||
password: `app_${'a'.repeat(36)}`,
|
||||
});
|
||||
}),
|
||||
),
|
||||
};
|
||||
const usages = { upsert: jest.fn(() => Promise.resolve()) };
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
const service = new AppPasswordsService(
|
||||
provider as unknown as AccountSecurityProvider,
|
||||
usages as unknown as Repository<AppPasswordUsageEntity>,
|
||||
);
|
||||
|
||||
const first = service.create('ara.kim@arasystem.test', '회사 PC');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await service.create('ara.kim@arasystem.test', '회사 PC');
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(BusinessException);
|
||||
expect((failure as BusinessException).code).toBe('REQ_001');
|
||||
expect(provider.createAppPassword).toHaveBeenCalledTimes(1);
|
||||
|
||||
finish();
|
||||
await expect(first).resolves.toMatchObject({ id: 'b' });
|
||||
|
||||
provider.listAppPasswords.mockResolvedValueOnce([]);
|
||||
const again = service.create('ara.kim@arasystem.test', '휴대폰');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
finish();
|
||||
await expect(again).resolves.toMatchObject({ name: '휴대폰' });
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Not, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { normalizeEmail } from '@/modules/auth/employee-address';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants';
|
||||
import {
|
||||
ACCOUNT_SECURITY_PROVIDER,
|
||||
type AccountSecurityProvider,
|
||||
type AppPasswordRecord,
|
||||
} from './account-security.provider';
|
||||
import { APP_PASSWORD_ID_PATTERN } from './app-password';
|
||||
import {
|
||||
AppPasswordUsageEntity,
|
||||
type AppPasswordUseMethod,
|
||||
} from './app-password-usage.entity';
|
||||
|
||||
/** 앱 비밀번호의 마지막 사용 — 서버 기록 */
|
||||
export interface AppPasswordLastUse {
|
||||
readonly at: Date;
|
||||
readonly method: AppPasswordUseMethod;
|
||||
readonly ip: string | null;
|
||||
}
|
||||
|
||||
/** 목록의 앱 비밀번호 하나 */
|
||||
export interface AppPasswordView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly createdAt: Date;
|
||||
readonly lastUsed: AppPasswordLastUse | null;
|
||||
}
|
||||
|
||||
/** 방금 만든 앱 비밀번호 — 값은 이 응답에만 */
|
||||
export interface CreatedAppPasswordView extends AppPasswordView {
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** 화면에 둘 이름 — 앞뒤 공백을 지운 값 (견줄 때도 이것으로) */
|
||||
function displayName(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 비밀번호 (계약 5절 "앱 비밀번호") — 엔진의 x:AppPassword (대리 접근).
|
||||
*
|
||||
* - 값(password)은 엔진이 만들어 만들 때 한 번만 준다 — 서버는 응답에 실어 넘기기만 한다(로그 · DB · 캐시 없음)
|
||||
* - 권한은 메일 프로그램만(로그인 · IMAP · 메일 보내기 — 어댑터가 늘 좁힌다). 만료 · 허용 IP 는 두지 않는다
|
||||
* - 같은 이름(앞뒤 공백을 지운 값이 같음)은 400 VAL_001, 엔진 상한(기본 5개)을 넘으면 409 AUTH_009.
|
||||
* 같은 직원의 만들기는 한 번에 하나다 — 처리 중이면 429 REQ_001 (동시 요청이 같은 이름 검사를 함께 지나가지 않게 — 2단계 잠금과 같은 결)
|
||||
* - 목록은 최근에 만든 것이 위다. 마지막 사용은 서버 기록(AppPasswordUsageEntity)에서 — 같은 번호 · 같은 createdAt 일 때만 잇는다
|
||||
* - 웹메일 비밀번호를 바꾸면 모두 지운다(removeAllAfterPasswordChange — 실패해도 던지지 않고 error 로그)
|
||||
* 이름(사용자 글) · 값은 로그에 남기지 않는다 — 만들기 · 지우기는 info 에 가린 주소 + id
|
||||
*
|
||||
* ⚠️ 만들기 잠금은 프로세스 메모리다 — 인스턴스를 늘리면 공유 잠금(SET NX PX)으로 옮긴다 (nestjs 스킬 "확장 때 옮길 것").
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppPasswordsService {
|
||||
private readonly logger = new Logger(AppPasswordsService.name);
|
||||
/** 만들기가 진행 중인 직원 주소 */
|
||||
private readonly creating = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@Inject(ACCOUNT_SECURITY_PROVIDER)
|
||||
private readonly provider: AccountSecurityProvider,
|
||||
@InjectRepository(AppPasswordUsageEntity)
|
||||
private readonly usages: Repository<AppPasswordUsageEntity>,
|
||||
) {}
|
||||
|
||||
/** GET /api/app-passwords — 한 쪽 (엔진 상한이 작다), total 포함 */
|
||||
async list(user: string): Promise<PaginatedResult<AppPasswordView>> {
|
||||
const address = normalizeEmail(user);
|
||||
const records = await this.provider.listAppPasswords(address);
|
||||
const items = await this.withUsage(address, records);
|
||||
return toPaginatedResult(items, {
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: items.length,
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/app-passwords — 같은 직원의 만들기가 처리 중이면 REQ_001 (확인과 차지 사이에 await 가 없다) */
|
||||
async create(user: string, name: string): Promise<CreatedAppPasswordView> {
|
||||
const address = normalizeEmail(user);
|
||||
if (this.creating.has(address)) {
|
||||
throw new BusinessException('REQ_001', {
|
||||
context: {
|
||||
reason: 'app-password-create-in-progress',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
this.creating.add(address);
|
||||
try {
|
||||
return await this.createOne(address, displayName(name));
|
||||
} finally {
|
||||
this.creating.delete(address);
|
||||
}
|
||||
}
|
||||
|
||||
private async createOne(
|
||||
address: string,
|
||||
trimmed: string,
|
||||
): Promise<CreatedAppPasswordView> {
|
||||
const existing = await this.provider.listAppPasswords(address);
|
||||
if (existing.some((record) => displayName(record.name) === trimmed)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'app-password-name-taken',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const created = await this.provider.createAppPassword(address, trimmed);
|
||||
const createdAt = new Date(created.createdAt);
|
||||
try {
|
||||
// 만들 때 기록을 새로 둔다 — 엔진이 지운 번호를 다시 썼으면 옛 기록(그때의 마지막 사용)을 덮는다
|
||||
await this.usages.upsert(
|
||||
{
|
||||
userEmail: address,
|
||||
appPasswordId: created.id,
|
||||
name: trimmed.slice(0, APP_PASSWORD_NAME_MAX_LENGTH),
|
||||
createdAt,
|
||||
lastUsedAt: null,
|
||||
lastUsedMethod: null,
|
||||
lastUsedIp: null,
|
||||
},
|
||||
['userEmail', 'appPasswordId'],
|
||||
);
|
||||
} catch (error) {
|
||||
// 값은 이미 만들어졌다 — 실패로 답하면 직원이 값을 받지 못한다. 기록은 목록을 볼 때 다시 둔다
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 앱 비밀번호 기록을 두지 못했습니다(앱 비밀번호는 만들었다): user=${maskEmail(address)}, id=${created.id}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`앱 비밀번호를 만들었습니다: ${maskEmail(address)}, id=${created.id}`,
|
||||
);
|
||||
return {
|
||||
id: created.id,
|
||||
name: trimmed,
|
||||
createdAt,
|
||||
lastUsed: null,
|
||||
password: created.password,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/app-passwords/{id} — 이미 없으면 그대로 끝난다.
|
||||
* 모양이 아닌 id 는 있을 수 없는 앱 비밀번호다 — 엔진에 묻지 않고 "이미 없음"과 같게 끝낸다
|
||||
*/
|
||||
async remove(user: string, id: string): Promise<void> {
|
||||
if (!APP_PASSWORD_ID_PATTERN.test(id)) return;
|
||||
const address = normalizeEmail(user);
|
||||
await this.provider.deleteAppPassword(address, id);
|
||||
await this.usages.delete({ userEmail: address, appPasswordId: id });
|
||||
this.logger.log(
|
||||
`앱 비밀번호를 지웠습니다: ${maskEmail(address)}, id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹메일 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 앱 비밀번호를 모두 지운다 (계약 5절 "비밀번호 바꾸기") → 지운 수.
|
||||
* 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있다. 실패하면 error 로그(남은 앱 비밀번호가 있다 — 운영자가 지울 수 있게 가린 주소와 함께)
|
||||
*/
|
||||
async removeAllAfterPasswordChange(user: string): Promise<number> {
|
||||
const address = normalizeEmail(user);
|
||||
try {
|
||||
const removed = await this.provider.deleteAllAppPasswords(address);
|
||||
await this.usages.delete({ userEmail: address });
|
||||
return removed;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 비밀번호 바꾸기 뒤 앱 비밀번호를 지우지 못했습니다 — 남은 앱 비밀번호는 메일 프로그램에서 계속 쓰입니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 웹훅의 앱 비밀번호 로그인 — 그 번호의 기록에 마지막 사용을 남긴다(지금 남은 것보다 늦을 때만 — 늦게 온 묶음이 되돌리지 않게) →
|
||||
* 그 기록의 이름(로그인 기록에 남긴다). 기록이 없으면(웹메일 밖에서 만들고 목록도 보지 않음) null
|
||||
*/
|
||||
async recordUse(
|
||||
user: string,
|
||||
id: string,
|
||||
use: AppPasswordLastUse,
|
||||
): Promise<string | null> {
|
||||
const address = normalizeEmail(user);
|
||||
const row = await this.usages.findOneBy({
|
||||
userEmail: address,
|
||||
appPasswordId: id,
|
||||
});
|
||||
if (!row) return null;
|
||||
await this.usages
|
||||
.createQueryBuilder()
|
||||
.update(AppPasswordUsageEntity)
|
||||
.set({
|
||||
lastUsedAt: use.at,
|
||||
lastUsedMethod: use.method,
|
||||
lastUsedIp: use.ip,
|
||||
})
|
||||
.where(
|
||||
'user_email = :address AND app_password_id = :id AND (last_used_at IS NULL OR last_used_at < :at)',
|
||||
{ address, id, at: use.at },
|
||||
)
|
||||
.execute();
|
||||
return row.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 목록 + 서버 기록 → 최근에 만든 것이 위. 같은 번호 · 같은 createdAt 인 기록만 잇고,
|
||||
* 기록이 없거나 다른 앱 비밀번호의 것이면(엔진이 번호를 다시 씀) 새로 둔다. 엔진에 없는 번호의 기록은 지운다
|
||||
*/
|
||||
private async withUsage(
|
||||
address: string,
|
||||
records: readonly AppPasswordRecord[],
|
||||
): Promise<AppPasswordView[]> {
|
||||
const rows = await this.usages.findBy({ userEmail: address });
|
||||
const byId = new Map(rows.map((row) => [row.appPasswordId, row]));
|
||||
const fresh: AppPasswordUsageEntity[] = [];
|
||||
|
||||
const items = records.map((record): AppPasswordView => {
|
||||
const createdAt = new Date(record.createdAt);
|
||||
const row = byId.get(record.id);
|
||||
const same =
|
||||
row !== undefined && row.createdAt.getTime() === createdAt.getTime();
|
||||
if (!same) {
|
||||
fresh.push(
|
||||
Object.assign(new AppPasswordUsageEntity(), {
|
||||
userEmail: address,
|
||||
appPasswordId: record.id,
|
||||
name: displayName(record.name).slice(
|
||||
0,
|
||||
APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
),
|
||||
createdAt,
|
||||
lastUsedAt: null,
|
||||
lastUsedMethod: null,
|
||||
lastUsedIp: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
name: displayName(record.name),
|
||||
createdAt,
|
||||
lastUsed:
|
||||
same && row.lastUsedAt && row.lastUsedMethod
|
||||
? {
|
||||
at: row.lastUsedAt,
|
||||
method: row.lastUsedMethod,
|
||||
ip: row.lastUsedIp,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
await this.saveUsageRows(address, fresh, records);
|
||||
return items.sort(
|
||||
(a, b) =>
|
||||
b.createdAt.getTime() - a.createdAt.getTime() ||
|
||||
b.id.localeCompare(a.id),
|
||||
);
|
||||
}
|
||||
|
||||
/** 목록을 본 김에 기록을 엔진에 맞춘다 — 실패해도 목록은 그대로 준다(마지막 사용이 빠질 뿐) */
|
||||
private async saveUsageRows(
|
||||
address: string,
|
||||
fresh: readonly AppPasswordUsageEntity[],
|
||||
records: readonly AppPasswordRecord[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (fresh.length > 0) {
|
||||
await this.usages.upsert([...fresh], ['userEmail', 'appPasswordId']);
|
||||
}
|
||||
const ids = records.map((record) => record.id);
|
||||
await this.usages.delete(
|
||||
ids.length > 0
|
||||
? { userEmail: address, appPasswordId: Not(In(ids)) }
|
||||
: { userEmail: address },
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 앱 비밀번호 기록을 엔진 목록에 맞추지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
import {
|
||||
MaxCodeUnits,
|
||||
MinCodeUnits,
|
||||
} from '@/common/decorators/code-units.decorator';
|
||||
import { TrimString } from '@/common/decorators/trim-string.decorator';
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from '@/modules/account-security/account-security.constants';
|
||||
|
||||
/** 앱 비밀번호의 마지막 사용 — 서버가 엔진 웹훅으로 만든 기록 */
|
||||
export class AppPasswordLastUseDto {
|
||||
@ApiProperty({
|
||||
description: '마지막으로 쓴 때 (ISO 8601 UTC)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:41:00.000Z',
|
||||
})
|
||||
at!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '쓴 곳 — imap(메일 받기) · smtp(메일 보내기)',
|
||||
enum: ['imap', 'smtp'],
|
||||
example: 'imap',
|
||||
})
|
||||
method!: 'imap' | 'smtp';
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 프로그램의 IP — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '211.186.95.252',
|
||||
})
|
||||
ip!: string | null;
|
||||
}
|
||||
|
||||
export class AppPasswordDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'앱 비밀번호 id — 지우기에 쓴다 (엔진이 지운 번호를 다시 쓴다)',
|
||||
example: 'b',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '이름', example: '회사 PC 아웃룩' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '만든 때 (ISO 8601 UTC)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:20:00.000Z',
|
||||
})
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막 사용 — 아직 쓰지 않았으면 null',
|
||||
type: AppPasswordLastUseDto,
|
||||
nullable: true,
|
||||
})
|
||||
lastUsed!: AppPasswordLastUseDto | null;
|
||||
}
|
||||
|
||||
/** POST /api/app-passwords → 201 — 값은 이 응답에만 */
|
||||
export class CreatedAppPasswordDto extends AppPasswordDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'앱 비밀번호 값 — 엔진이 만든 40자(`app_` + 36자). 이 응답에만 있고 다시 볼 수 없다(서버는 두지 않는다)',
|
||||
example: 'app_abcdefghijklmnopqrstuvwxyz0123791203',
|
||||
})
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class AppPasswordPageDto {
|
||||
@ApiProperty({
|
||||
type: [AppPasswordDto],
|
||||
description: '최근에 만든 것이 위 — 한 쪽에 모두 (엔진 상한이 작다)',
|
||||
})
|
||||
items!: AppPasswordDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description: 'hasNext false · nextCursor null · total 개수',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
/** POST /api/app-passwords */
|
||||
export class CreateAppPasswordDto {
|
||||
@ApiProperty({
|
||||
description: `이름 — 1~${APP_PASSWORD_NAME_MAX_LENGTH}자(앞뒤 공백을 지운 값, JS 문자열 길이). 이미 있는 이름이면 400 VAL_001`,
|
||||
minLength: 1,
|
||||
maxLength: APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
example: '회사 PC 아웃룩',
|
||||
})
|
||||
@applyDecorators(
|
||||
TrimString(),
|
||||
IsString({ message: '이름을 넣어 주세요.' }),
|
||||
MinCodeUnits(1, { message: '이름을 넣어 주세요.' }),
|
||||
MaxCodeUnits(APP_PASSWORD_NAME_MAX_LENGTH, {
|
||||
message: `이름은 ${APP_PASSWORD_NAME_MAX_LENGTH}자 이하로 넣어 주세요.`,
|
||||
}),
|
||||
)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, type TransformFnParams } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
import {
|
||||
CursorPaginationQueryDto,
|
||||
PageInfoDto,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
|
||||
/** 쿼리의 'true' · 'false' → 불리언 (그 밖의 값은 그대로 두어 IsBoolean 이 VAL_001 로 막는다) */
|
||||
function toBooleanQuery({ value }: TransformFnParams): unknown {
|
||||
const raw: unknown = value;
|
||||
if (raw === 'true') return true;
|
||||
if (raw === 'false') return false;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** GET /api/login-events 의 쿼리 — 커서 · 개수(기본 20 · 최대 100) · 성공만/실패만 */
|
||||
export class LoginEventQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'false 면 실패만, true 면 성공만 — 없으면 전체',
|
||||
enum: ['true', 'false'],
|
||||
example: 'false',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toBooleanQuery)
|
||||
@IsBoolean({ message: 'isSuccess 는 true 또는 false 여야 합니다.' })
|
||||
isSuccess?: boolean;
|
||||
}
|
||||
|
||||
/** 브라우저 · 운영체제의 짧은 이름 (shared/utils/user-agent.ts) — 모르면 null */
|
||||
export class DeviceDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Edge · Chrome · Firefox · Safari · Whale · Samsung Internet · ARA SYSTEM 앱(모바일 앱 — 계약 1절 "앱 로그인") — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'Chrome',
|
||||
})
|
||||
browser!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Windows · macOS · iOS · Android · Linux · ChromeOS — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'Windows',
|
||||
})
|
||||
os!: string | null;
|
||||
}
|
||||
|
||||
export class LoginEventAppPasswordDto {
|
||||
@ApiProperty({ description: '앱 비밀번호 id', example: 'b' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '그때의 이름 (지운 뒤에도 남는다) — 서버 기록이 없으면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '회사 PC 아웃룩',
|
||||
})
|
||||
name!: string | null;
|
||||
}
|
||||
|
||||
export class LoginEventDto {
|
||||
@ApiProperty({ description: '기록 id', example: '1042' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '일어난 때 (ISO 8601 UTC)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:41:00.000Z',
|
||||
})
|
||||
occurredAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'webmail · app(모바일 앱 — 계약 1절 "앱 로그인") · imap(메일 프로그램 받기) · smtp(메일 프로그램 보내기)',
|
||||
enum: ['webmail', 'app', 'imap', 'smtp'],
|
||||
example: 'webmail',
|
||||
})
|
||||
method!: 'webmail' | 'app' | 'imap' | 'smtp';
|
||||
|
||||
@ApiProperty({ description: '성공했는가', example: true })
|
||||
isSuccess!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일 · 앱 성공이 2단계 인증 코드까지 거쳤는가 (메일 프로그램은 false)',
|
||||
example: true,
|
||||
})
|
||||
isTwoFactor!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'실패 까닭 — wrongPassword(비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함) · wrongCode(코드 틀림 · 다시 쓴 코드) · ' +
|
||||
'appPasswordRequired(2단계를 켠 계정에 메일 프로그램이 비밀번호로). 성공이면 null',
|
||||
type: String,
|
||||
enum: ['wrongPassword', 'wrongCode', 'appPasswordRequired'],
|
||||
nullable: true,
|
||||
example: null,
|
||||
})
|
||||
failureReason!: 'wrongPassword' | 'wrongCode' | 'appPasswordRequired' | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '클라이언트 IP — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '211.186.95.252',
|
||||
})
|
||||
ip!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일 · 앱의 브라우저(앱은 ARA SYSTEM 앱) · 운영체제 — 메일 프로그램이거나 모르면 null',
|
||||
type: DeviceDto,
|
||||
nullable: true,
|
||||
})
|
||||
device!: DeviceDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'메일 프로그램이 앱 비밀번호로 들어왔으면 그 앱 비밀번호 — 아니면 null',
|
||||
type: LoginEventAppPasswordDto,
|
||||
nullable: true,
|
||||
})
|
||||
appPassword!: LoginEventAppPasswordDto | null;
|
||||
}
|
||||
|
||||
export class LoginEventPageDto {
|
||||
@ApiProperty({ type: [LoginEventDto], description: '최근 차례' })
|
||||
items!: LoginEventDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description: 'total — 최근 90일 · 거른 개수',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
CurrentPasswordField,
|
||||
OtpCodeField,
|
||||
} from '@/modules/auth/dto/security-fields';
|
||||
import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants';
|
||||
|
||||
/** GET /api/two-factor — 켜기 · 끄기의 응답도 같다 (계약 5절 "2단계 인증") */
|
||||
export class TwoFactorStatusDto {
|
||||
@ApiProperty({
|
||||
description: '엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다',
|
||||
example: true,
|
||||
})
|
||||
isEnabled!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일에서 켠 때 (ISO 8601 UTC) — 꺼져 있거나 엔진 관리 화면에서 켰으면 null',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
example: '2026-10-31T00:12:00.000Z',
|
||||
})
|
||||
enabledAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이 직원에게 2단계 인증이 필수인가 (13단계 — 회사 정책, 시작일 전에도 true). 필수인 직원은 끄지 못한다(끄기는 400 VAL_001)',
|
||||
example: false,
|
||||
})
|
||||
isRequired!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'필수의 유예 중이고 아직 켜지 않았으면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 켰거나 시작했거나 필수가 아니면 null(로그인 · me 의 twoFactorDeadline 과 같은 규칙). 화면은 그 1ms 앞 날짜(시작일 전날)까지로 안내한다',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
example: '2026-11-30T15:00:00.000Z',
|
||||
})
|
||||
deadline!: string | null;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments → 201 (Cache-Control: private, no-store) */
|
||||
export class TwoFactorEnrollmentDto {
|
||||
@ApiProperty({
|
||||
description: '등록 id — 켜기(activation)에 그대로 보낸다',
|
||||
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
|
||||
})
|
||||
enrollmentId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'비밀 — 160비트를 base32(RFC 4648, 채움 없음)로 적은 32자. QR 을 찍을 수 없을 때 직원이 직접 넣는다. 이 응답에만 있다(서버 · 로그에 남지 않는다)',
|
||||
example: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP',
|
||||
})
|
||||
secret!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'인증 앱에 넣을 otpauth 주소 — 화면이 이 주소로 QR 을 그린다. 발급자 「ARA SYSTEM」 · SHA1 · 6자리 · 30초',
|
||||
example:
|
||||
'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30',
|
||||
})
|
||||
otpauthUrl!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '이때까지 켠다 (등록을 시작하고 10분) — 지나면 409 AUTH_008',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:22:00.000Z',
|
||||
})
|
||||
expiresAt!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/activation */
|
||||
export class TwoFactorActivationDto {
|
||||
@ApiProperty({
|
||||
description: '등록 응답의 enrollmentId',
|
||||
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
|
||||
})
|
||||
@IsUUID('all', { message: '2단계 인증 등록 정보가 올바르지 않습니다.' })
|
||||
enrollmentId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'인증 앱에 보이는 숫자 6자리 — 등록의 비밀로 서버가 먼저 확인한다(앞뒤 30초까지)',
|
||||
example: '123456',
|
||||
})
|
||||
@OtpCodeField()
|
||||
otpCode!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 — 엔진이 2단계 인증을 켤 때 요구한다 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
|
||||
format: 'password',
|
||||
example: 'current-password',
|
||||
})
|
||||
@CurrentPasswordField()
|
||||
currentPassword!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments/{id}/verification (16단계 — 휴대폰 바꾸기 ①) */
|
||||
export class TwoFactorVerificationDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'새 휴대폰 인증 앱의 숫자 6자리 — 등록의 비밀로 서버가 확인한다(앞뒤 30초까지)',
|
||||
example: '123456',
|
||||
})
|
||||
@OtpCodeField()
|
||||
otpCode!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments/{id}/verification → 200 */
|
||||
export class TwoFactorVerifiedDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'늘 true — 등록에 "확인됨"을 달았다(10분 안에 바꾸기를 보낸다)',
|
||||
example: true,
|
||||
})
|
||||
verified!: true;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/replacements (16단계 — 휴대폰 바꾸기 ②) */
|
||||
export class TwoFactorReplacementDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'확인한 등록의 enrollmentId (POST /api/two-factor/enrollments → …/verification)',
|
||||
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
|
||||
})
|
||||
@IsUUID('all', { message: '2단계 인증 등록 정보가 올바르지 않습니다.' })
|
||||
enrollmentId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '지금 휴대폰 인증 앱의 숫자 6자리 — 엔진이 확인한다',
|
||||
example: '654321',
|
||||
})
|
||||
@OtpCodeField()
|
||||
currentOtpCode!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
|
||||
format: 'password',
|
||||
example: 'current-password',
|
||||
})
|
||||
@CurrentPasswordField()
|
||||
currentPassword!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/deactivation */
|
||||
export class TwoFactorDeactivationDto {
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
|
||||
format: 'password',
|
||||
example: 'current-password',
|
||||
})
|
||||
@CurrentPasswordField()
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '지금 휴대폰 인증 앱의 숫자 6자리 — 엔진이 확인한다',
|
||||
example: '123456',
|
||||
})
|
||||
@OtpCodeField()
|
||||
otpCode!: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
decodeLoginEventCursor,
|
||||
encodeLoginEventCursor,
|
||||
loginEventScope,
|
||||
} from './login-event-cursor';
|
||||
|
||||
describe('로그인 기록의 커서 (자리 — 일어난 때 · 차례 번호)', () => {
|
||||
const position = {
|
||||
occurredAt: new Date('2026-10-31T00:41:00.000Z'),
|
||||
id: '1042',
|
||||
};
|
||||
|
||||
it('같은 거르기로 읽으면 자리가 그대로 나온다', () => {
|
||||
const scope = loginEventScope(false);
|
||||
const cursor = encodeLoginEventCursor(position, scope);
|
||||
expect(decodeLoginEventCursor(cursor, scope)).toEqual({ position });
|
||||
});
|
||||
|
||||
it('다른 거르기(전체 · 성공만 · 실패만)의 커서는 받지 않는다', () => {
|
||||
const cursor = encodeLoginEventCursor(position, loginEventScope(undefined));
|
||||
expect(decodeLoginEventCursor(cursor, loginEventScope(false))).toEqual({
|
||||
problem: 'other-query',
|
||||
});
|
||||
expect(decodeLoginEventCursor(cursor, loginEventScope(true))).toEqual({
|
||||
problem: 'other-query',
|
||||
});
|
||||
});
|
||||
|
||||
it('모양이 틀리면 까닭과 함께 거절한다', () => {
|
||||
const scope = loginEventScope(undefined);
|
||||
const encode = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
expect(decodeLoginEventCursor('a'.repeat(300), scope)).toEqual({
|
||||
problem: 'too-long',
|
||||
});
|
||||
expect(decodeLoginEventCursor('@@@', scope)).toEqual({
|
||||
problem: 'not-json',
|
||||
});
|
||||
for (const bad of [
|
||||
{ v: 2, t: position.occurredAt.toISOString(), i: '1', s: 'x' },
|
||||
{ v: 1, t: 'not-a-date', i: '1', s: 'x' },
|
||||
{ v: 1, t: '2026-10-31T00:41:00Z', i: '1', s: 'x' },
|
||||
{ v: 1, t: position.occurredAt.toISOString(), i: '0', s: 'x' },
|
||||
{ v: 1, t: position.occurredAt.toISOString(), i: '1; DROP', s: 'x' },
|
||||
]) {
|
||||
expect(decodeLoginEventCursor(encode(bad), scope)).toEqual({
|
||||
problem: 'malformed',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/**
|
||||
* 로그인 기록의 커서 (순수 함수) — 최근 차례(일어난 때 · 차례 번호의 내림차순)에서 마지막으로 준 기록의 자리.
|
||||
* 위치(offset)가 아니라 자리(keyset)를 적는다 — 새 기록은 맨 위에 쌓이므로 위치 커서면 다음 쪽이 밀려 겹친다.
|
||||
* 다른 거르기(isSuccess)의 커서를 섞어 쓰지 못하게 범위의 해시를 함께 넣는다 (shared/utils/offset-cursor.ts 와 같은 결).
|
||||
*/
|
||||
const CURSOR_VERSION = 1;
|
||||
const MAX_CURSOR_LENGTH = 256;
|
||||
const SCOPE_HASH_LENGTH = 16;
|
||||
/** 차례 번호 — bigint 의 10진 표기 */
|
||||
const ID_PATTERN = /^[1-9]\d{0,18}$/;
|
||||
|
||||
/** 마지막으로 준 기록의 자리 */
|
||||
export interface LoginEventPosition {
|
||||
readonly occurredAt: Date;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export type LoginEventCursorReading =
|
||||
| { readonly position: LoginEventPosition }
|
||||
| { readonly problem: 'too-long' | 'not-json' | 'malformed' | 'other-query' };
|
||||
|
||||
interface EncodedCursor {
|
||||
v: number;
|
||||
t: string;
|
||||
i: string;
|
||||
s: string;
|
||||
}
|
||||
|
||||
/** 커서의 범위 — 거르기 값 (전체 · 성공만 · 실패만) */
|
||||
export function loginEventScope(isSuccess: boolean | undefined): string {
|
||||
return isSuccess === undefined ? 'all' : String(isSuccess);
|
||||
}
|
||||
|
||||
function scopeHash(scope: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`login-events:${scope}`)
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
export function encodeLoginEventCursor(
|
||||
position: LoginEventPosition,
|
||||
scope: string,
|
||||
): string {
|
||||
const payload: EncodedCursor = {
|
||||
v: CURSOR_VERSION,
|
||||
t: position.occurredAt.toISOString(),
|
||||
i: position.id,
|
||||
s: scopeHash(scope),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
/** 커서를 읽는다 — 형식이 틀리거나 다른 범위의 커서면 그 까닭 (부르는 쪽이 VAL_001 로 바꾼다) */
|
||||
export function decodeLoginEventCursor(
|
||||
cursor: string,
|
||||
scope: string,
|
||||
): LoginEventCursorReading {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) return { problem: 'too-long' };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return { problem: 'not-json' };
|
||||
}
|
||||
const { v, t, i, s } = (parsed ?? {}) as Partial<EncodedCursor>;
|
||||
if (
|
||||
v !== CURSOR_VERSION ||
|
||||
typeof t !== 'string' ||
|
||||
typeof i !== 'string' ||
|
||||
typeof s !== 'string' ||
|
||||
!ID_PATTERN.test(i)
|
||||
) {
|
||||
return { problem: 'malformed' };
|
||||
}
|
||||
const occurredAt = new Date(t);
|
||||
if (Number.isNaN(occurredAt.getTime()) || occurredAt.toISOString() !== t) {
|
||||
return { problem: 'malformed' };
|
||||
}
|
||||
if (s !== scopeHash(scope)) return { problem: 'other-query' };
|
||||
return { position: { occurredAt, id: i } };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 방식 · 실패 까닭 같은 짧은 낱말 */
|
||||
const WORD_COLUMN_LENGTH = 32;
|
||||
/** IP 문자열 — IPv6 최대 45자에 여유 */
|
||||
const IP_COLUMN_LENGTH = 64;
|
||||
/** User-Agent 원문 (계약 5절 — 512자까지) — shared/utils/user-agent.ts 의 USER_AGENT_MAX_LENGTH 와 같다 */
|
||||
const USER_AGENT_COLUMN_LENGTH = 512;
|
||||
/** 엔진의 JMAP id — 13자까지 */
|
||||
const APP_PASSWORD_ID_COLUMN_LENGTH = 16;
|
||||
/** 겹침 거르기 키 — SHA-256 의 base64url (43자) */
|
||||
const DEDUPE_KEY_LENGTH = 43;
|
||||
|
||||
/** 로그인 방식 — 웹메일 · 앱(모바일 — 계약 1절 "앱 로그인") · 메일 프로그램 받기(IMAP) · 메일 프로그램 보내기(SMTP) */
|
||||
export type LoginEventMethod = 'webmail' | 'app' | 'imap' | 'smtp';
|
||||
|
||||
/** 서버가 판정한 로그인의 방식 — 웹메일 · 앱 (메일 프로그램은 엔진 웹훅이 알려 준다) */
|
||||
export type SignInMethod = Extract<LoginEventMethod, 'webmail' | 'app'>;
|
||||
|
||||
/**
|
||||
* 실패 까닭 (계약 5절 "로그인 기록"):
|
||||
* - wrongPassword: 비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함
|
||||
* - wrongCode: 2단계 인증 코드 틀림 · 다시 쓴 코드
|
||||
* - appPasswordRequired: 2단계를 켠 계정에 메일 프로그램이 비밀번호로 들어오려 함 (엔진 auth.mfa-required)
|
||||
*/
|
||||
export type LoginFailureReason =
|
||||
| 'wrongPassword'
|
||||
| 'wrongCode'
|
||||
| 'appPasswordRequired';
|
||||
|
||||
/**
|
||||
* 로그인 기록 (계약 5절 "로그인 기록") — 90일 보존(날마다 정리 — login-events.cleanup.ts).
|
||||
* - 웹메일 · 앱: 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006) — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
|
||||
* - 메일 프로그램: 엔진 웹훅의 IMAP · SMTP 이벤트 (mail-engine-events.service.ts) — `dedupe_key` 로 엔진이 다시 보낸 묶음을 거른다
|
||||
* 비밀번호 · 코드 · 앱 비밀번호 값은 없다. 앱 비밀번호 이름은 그때의 이름이다(지운 뒤에도 남는다)
|
||||
*/
|
||||
@Entity({ name: 'login_events' })
|
||||
@Index('IDX_login_events_user_occurred', ['userEmail', 'occurredAt', 'id'])
|
||||
export class LoginEventEntity {
|
||||
/** 차례 번호 — 응답에서는 문자열 (pg 는 bigint 를 문자열로 준다) */
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
userEmail!: string;
|
||||
|
||||
/** 일어난 때 — 웹메일은 서버가 판정한 때, 메일 프로그램은 엔진 이벤트의 createdAt(초 단위) */
|
||||
@Index('IDX_login_events_occurred_at')
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ type: 'varchar', length: WORD_COLUMN_LENGTH })
|
||||
method!: LoginEventMethod;
|
||||
|
||||
@Column({ name: 'is_success', type: 'boolean' })
|
||||
isSuccess!: boolean;
|
||||
|
||||
/** 웹메일 · 앱 성공이 코드까지 거쳤는가 (메일 프로그램은 늘 false) */
|
||||
@Column({ name: 'is_two_factor', type: 'boolean' })
|
||||
isTwoFactor!: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'failure_reason',
|
||||
type: 'varchar',
|
||||
length: WORD_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
failureReason!: LoginFailureReason | null;
|
||||
|
||||
@Column({ type: 'varchar', length: IP_COLUMN_LENGTH, nullable: true })
|
||||
ip!: string | null;
|
||||
|
||||
/** 웹메일 · 앱의 User-Agent 원문(앞 512자) — 화면에는 짧은 이름만. 메일 프로그램은 null */
|
||||
@Column({
|
||||
name: 'user_agent',
|
||||
type: 'varchar',
|
||||
length: USER_AGENT_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
userAgent!: string | null;
|
||||
|
||||
/** 메일 프로그램이 앱 비밀번호로 들어왔으면 그 id */
|
||||
@Column({
|
||||
name: 'app_password_id',
|
||||
type: 'varchar',
|
||||
length: APP_PASSWORD_ID_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
appPasswordId!: string | null;
|
||||
|
||||
/** 그때의 앱 비밀번호 이름 — 서버 기록이 없으면 null */
|
||||
@Column({
|
||||
name: 'app_password_name',
|
||||
type: 'varchar',
|
||||
length: APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
appPasswordName!: string | null;
|
||||
|
||||
/** 엔진 이벤트의 겹침 거르기 키 — 이벤트 내용의 지문 (웹메일 기록은 null). 엔진이 다시 보내면 이 키가 같다 */
|
||||
@Index('UQ_login_events_dedupe_key', { unique: true })
|
||||
@Column({
|
||||
name: 'dedupe_key',
|
||||
type: 'char',
|
||||
length: DEDUPE_KEY_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
dedupeKey!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
|
||||
/**
|
||||
* 로그인 기록 정리 — 90일이 지난 기록을 날마다 지운다 (계약 5절 "로그인 기록").
|
||||
*
|
||||
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 DELETE 를 실행한다 (결과는 같지만 낭비).
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginEventsCleanup {
|
||||
private readonly logger = new Logger(LoginEventsCleanup.name);
|
||||
|
||||
constructor(private readonly loginEvents: LoginEventsService) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM, { name: 'login-events-cleanup' })
|
||||
async purgeOldEvents(now: Date = new Date()): Promise<number> {
|
||||
try {
|
||||
const removed = await this.loginEvents.purge(now);
|
||||
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
|
||||
if (removed > 0) {
|
||||
this.logger.debug(`90일이 지난 로그인 기록 ${removed}건을 지웠습니다.`);
|
||||
}
|
||||
return removed;
|
||||
} catch (error) {
|
||||
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 날 다시 시도한다
|
||||
this.logger.error(
|
||||
`[SYS_001] 로그인 기록 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
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 {
|
||||
describeAppUserAgent,
|
||||
describeUserAgent,
|
||||
} from '@/shared/utils/user-agent';
|
||||
import { LOGIN_EVENT_RETENTION_DAYS } from './account-security.constants';
|
||||
import {
|
||||
LoginEventPageDto,
|
||||
LoginEventQueryDto,
|
||||
type LoginEventDto,
|
||||
} from './dto/login-event.dto';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
|
||||
function toDto(event: LoginEventEntity): LoginEventDto {
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
method: event.method,
|
||||
isSuccess: event.isSuccess,
|
||||
isTwoFactor: event.isTwoFactor,
|
||||
failureReason: event.failureReason,
|
||||
ip: event.ip,
|
||||
// 웹메일 · 앱만 기기를 안다(User-Agent) — 메일 프로그램은 엔진이 알려 주지 않는다.
|
||||
// 앱 이름(ARA SYSTEM 앱)은 서버가 앱 경로로 판정한 기록에만 — 웹메일 기록의 User-Agent 가 앱 꼴이어도 붙이지 않는다
|
||||
device:
|
||||
event.method === 'app'
|
||||
? describeAppUserAgent(event.userAgent)
|
||||
: event.method === 'webmail'
|
||||
? describeUserAgent(event.userAgent)
|
||||
: null,
|
||||
appPassword: event.appPasswordId
|
||||
? { id: event.appPasswordId, name: event.appPasswordName }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 · 앱과 메일 프로그램(IMAP · SMTP)의 로그인
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@Controller('login-events')
|
||||
export class LoginEventsController {
|
||||
constructor(private readonly loginEvents: LoginEventsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: `로그인 기록 — 최근 ${LOGIN_EVENT_RETENTION_DAYS}일 · 최근 차례 · 커서`,
|
||||
description:
|
||||
'웹메일 · 앱: 서버가 판정한 로그인 시도(성공 · 비밀번호 틀림 · 코드 틀림). 한도로 막은 요청 · 갱신은 남기지 않는다. ' +
|
||||
'메일 프로그램: 엔진이 알려 준 IMAP · SMTP 로그인(엔진이 다시 뜨거나 서버가 5분 넘게 받지 못한 동안의 기록은 빠진다). ' +
|
||||
`기록은 ${LOGIN_EVENT_RETENTION_DAYS}일 보존. 커서는 같은 isSuccess 로만 쓴다(다르면 400 VAL_001).`,
|
||||
})
|
||||
@ApiStandardResponse(LoginEventPageDto, { description: '한 쪽' })
|
||||
@ApiErrorResponse('VAL_001', '쿼리 형식 오류 · 다른 거르기의 커서')
|
||||
async list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: LoginEventQueryDto,
|
||||
): Promise<LoginEventPageDto> {
|
||||
const page = await this.loginEvents.list(user.email, {
|
||||
cursor: query.cursor,
|
||||
limit: query.limit,
|
||||
isSuccess: query.isSuccess,
|
||||
});
|
||||
return { items: page.items.map(toDto), pageInfo: page.pageInfo };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { DirectoryService } from '@/modules/contacts/directory.service';
|
||||
import { LOGIN_EVENT_INSERT_CHUNK } from './account-security.constants';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsService, retentionStart } from './login-events.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const NOW = new Date('2026-10-31T03:00:00.000Z');
|
||||
|
||||
describe('LoginEventsService.recordSignIn (웹메일 · 앱 로그인 기록 — 계약 5절)', () => {
|
||||
let events: { insert: jest.Mock };
|
||||
let directory: { hasEmail: jest.Mock };
|
||||
let service: LoginEventsService;
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
events = { insert: jest.fn().mockResolvedValue(undefined) };
|
||||
directory = { hasEmail: jest.fn().mockResolvedValue(true) };
|
||||
service = new LoginEventsService(
|
||||
events as unknown as Repository<LoginEventEntity>,
|
||||
directory as unknown as DirectoryService,
|
||||
);
|
||||
errors = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
errors.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const attempt = (
|
||||
overrides: Partial<Parameters<LoginEventsService['recordSignIn']>[0]>,
|
||||
) => ({
|
||||
method: 'webmail' as const,
|
||||
email: ARA,
|
||||
isSuccess: false,
|
||||
isTwoFactor: false,
|
||||
failureReason: 'wrongPassword' as const,
|
||||
ip: '211.186.95.252',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('성공 · 코드 틀림은 사내 목록을 묻지 않고 남긴다 — User-Agent 원문은 512자까지', async () => {
|
||||
await service.recordSignIn(
|
||||
attempt({
|
||||
isSuccess: true,
|
||||
isTwoFactor: true,
|
||||
failureReason: null,
|
||||
userAgent: `UA ${'x'.repeat(600)}`,
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
await service.recordSignIn(attempt({ failureReason: 'wrongCode' }), NOW);
|
||||
|
||||
expect(directory.hasEmail).not.toHaveBeenCalled();
|
||||
expect(events.insert).toHaveBeenCalledTimes(2);
|
||||
const [first] = events.insert.mock.calls[0] as [LoginEventEntity];
|
||||
expect(first).toMatchObject({
|
||||
userEmail: ARA,
|
||||
occurredAt: NOW,
|
||||
method: 'webmail',
|
||||
isSuccess: true,
|
||||
isTwoFactor: true,
|
||||
failureReason: null,
|
||||
ip: '211.186.95.252',
|
||||
dedupeKey: null,
|
||||
});
|
||||
expect(first.userAgent).toHaveLength(512);
|
||||
});
|
||||
|
||||
it('비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 없는 주소의 시도는 버린다, 읽지 못하면 남긴다', async () => {
|
||||
directory.hasEmail.mockResolvedValueOnce(false);
|
||||
await service.recordSignIn(
|
||||
attempt({ email: 'nobody@arasystem.test' }),
|
||||
NOW,
|
||||
);
|
||||
expect(events.insert).not.toHaveBeenCalled();
|
||||
|
||||
directory.hasEmail.mockRejectedValueOnce(new BusinessException('MAIL_001'));
|
||||
await service.recordSignIn(attempt({}), NOW);
|
||||
expect(events.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('남기지 못해도 던지지 않는다 — error 로그(가린 주소)', async () => {
|
||||
events.insert.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
service.recordSignIn(
|
||||
attempt({ isSuccess: true, failureReason: null }),
|
||||
NOW,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(errors.join('\n')).toMatch(/\[SYS_001\].*a\*\*\*@arasystem\.test/);
|
||||
expect(errors.join('\n')).not.toContain(ARA);
|
||||
});
|
||||
|
||||
it('앱 로그인은 방식 app 으로 남긴다 — User-Agent 원문도 둔다 (계약 1절 "앱 로그인")', async () => {
|
||||
await service.recordSignIn(
|
||||
attempt({
|
||||
method: 'app',
|
||||
isSuccess: true,
|
||||
failureReason: null,
|
||||
userAgent: 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)',
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
expect(events.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'app',
|
||||
isSuccess: true,
|
||||
userAgent: 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('보존 기간은 90일', () => {
|
||||
expect(retentionStart(NOW)).toEqual(new Date('2026-08-02T03:00:00.000Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginEventsService.insertEngineEvents (엔진 이벤트 넣기 — 문장마다 1,000행)', () => {
|
||||
/** createQueryBuilder().insert().into().values().orIgnore().returning().execute() 대역 — 문장마다 넣은 행을 모은다 */
|
||||
function fakeEvents(existing: ReadonlySet<string> = new Set()) {
|
||||
const statements: { dedupeKey: string | null }[][] = [];
|
||||
const builder = {
|
||||
rows: [] as { dedupeKey: string | null }[],
|
||||
insert: () => builder,
|
||||
into: () => builder,
|
||||
values: (rows: { dedupeKey: string | null }[]) => {
|
||||
builder.rows = rows;
|
||||
return builder;
|
||||
},
|
||||
orIgnore: () => builder,
|
||||
returning: () => builder,
|
||||
execute: () => {
|
||||
statements.push(builder.rows);
|
||||
// ON CONFLICT DO NOTHING — 이미 있는 키는 돌려주지 않는다
|
||||
return Promise.resolve({
|
||||
raw: builder.rows
|
||||
.filter((row) => row.dedupeKey && !existing.has(row.dedupeKey))
|
||||
.map((row) => ({ dedupe_key: row.dedupeKey })),
|
||||
});
|
||||
},
|
||||
};
|
||||
return {
|
||||
statements,
|
||||
repository: { createQueryBuilder: () => builder },
|
||||
};
|
||||
}
|
||||
|
||||
const rowsOf = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
userEmail: ARA,
|
||||
occurredAt: NOW,
|
||||
method: 'imap' as const,
|
||||
isSuccess: true,
|
||||
isTwoFactor: false,
|
||||
failureReason: null,
|
||||
ip: '211.186.95.252',
|
||||
userAgent: null,
|
||||
appPasswordId: null,
|
||||
appPasswordName: null,
|
||||
dedupeKey: `key-${index}`,
|
||||
}));
|
||||
|
||||
it(`한 문장에 ${LOGIN_EVENT_INSERT_CHUNK}행까지 — 6,000행(행마다 11칸이면 한 문장의 매개변수 상한 65,535 를 넘는다)은 6문장으로 나눠 넣고 새 키를 모두 돌려준다`, async () => {
|
||||
const { statements, repository } = fakeEvents(
|
||||
new Set(['key-10', 'key-5999']),
|
||||
);
|
||||
const service = new LoginEventsService(
|
||||
repository as unknown as Repository<LoginEventEntity>,
|
||||
{} as DirectoryService,
|
||||
);
|
||||
|
||||
const inserted = await service.insertEngineEvents(rowsOf(6_000));
|
||||
|
||||
expect(statements.map((rows) => rows.length)).toEqual([
|
||||
1000, 1000, 1000, 1000, 1000, 1000,
|
||||
]);
|
||||
expect(
|
||||
Math.max(...statements.map((rows) => rows.length * 11)),
|
||||
).toBeLessThan(65_535);
|
||||
expect(inserted.size).toBe(5_998);
|
||||
expect(inserted.has('key-10')).toBe(false);
|
||||
expect(inserted.has('key-1000')).toBe(true);
|
||||
});
|
||||
|
||||
it('남은 행만큼 마지막 문장 · 빈 묶음은 DB 에 묻지 않는다', async () => {
|
||||
const { statements, repository } = fakeEvents();
|
||||
const service = new LoginEventsService(
|
||||
repository as unknown as Repository<LoginEventEntity>,
|
||||
{} as DirectoryService,
|
||||
);
|
||||
await service.insertEngineEvents(rowsOf(2_500));
|
||||
expect(statements.map((rows) => rows.length)).toEqual([1000, 1000, 500]);
|
||||
|
||||
statements.length = 0;
|
||||
await expect(service.insertEngineEvents([])).resolves.toEqual(new Set());
|
||||
expect(statements).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThan, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { DirectoryService } from '@/modules/contacts/directory.service';
|
||||
import { normalizeEmail } from '@/modules/auth/employee-address';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { truncateUserAgent } from '@/shared/utils/user-agent';
|
||||
import {
|
||||
LOGIN_EVENT_INSERT_CHUNK,
|
||||
LOGIN_EVENT_RETENTION_DAYS,
|
||||
} from './account-security.constants';
|
||||
import {
|
||||
LoginEventEntity,
|
||||
type LoginFailureReason,
|
||||
type SignInMethod,
|
||||
} from './login-event.entity';
|
||||
import {
|
||||
decodeLoginEventCursor,
|
||||
encodeLoginEventCursor,
|
||||
loginEventScope,
|
||||
} from './login-event-cursor';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 웹메일 · 앱 로그인 시도 하나 — 서버가 판정한 것 */
|
||||
export interface SignInAttempt {
|
||||
/** webmail(웹 — 쿠키) · app(모바일 앱 — 계약 1절 "앱 로그인") */
|
||||
readonly method: SignInMethod;
|
||||
readonly email: string;
|
||||
readonly isSuccess: boolean;
|
||||
/** 성공이 코드까지 거쳤는가 */
|
||||
readonly isTwoFactor: boolean;
|
||||
readonly failureReason: LoginFailureReason | null;
|
||||
readonly ip: string | null;
|
||||
readonly userAgent: string | null;
|
||||
}
|
||||
|
||||
/** 로그인 기록 조회 — 커서 · 개수 · 성공만/실패만 */
|
||||
export interface LoginEventQuery {
|
||||
readonly cursor?: string;
|
||||
readonly limit: number;
|
||||
readonly isSuccess?: boolean;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** 보존 기간의 시작 — 지금부터 90일 전 */
|
||||
export function retentionStart(now: Date): Date {
|
||||
return new Date(now.getTime() - LOGIN_EVENT_RETENTION_DAYS * DAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 · 앱 로그인(AuthService)과 메일 프로그램 로그인(엔진 웹훅)을 한 표에 둔다.
|
||||
*
|
||||
* - 남기는 실패는 사내 목록에 있는 주소만 — 없는 주소의 실패는 버린다(공격자가 아무 주소나 뿌려 표를 채우지 못하게, 계정이 나중에 생겨도
|
||||
* 그 전의 시도가 보이지 않게). 사내 목록을 읽지 못하면 남긴다(보안 기록을 잃는 쪽보다 낫다). 코드 틀림 · 성공은 늘 진짜 계정이라 바로 남긴다
|
||||
* - 기록에 실패해도 로그인은 그대로 간다 (error 로그만)
|
||||
* - 목록은 최근 90일 · 최근 차례 · 커서(자리) — 성공만 · 실패만으로 거를 수 있다
|
||||
* 비밀번호 · 코드 · 앱 비밀번호 값은 기록에 없다. User-Agent 원문은 512자까지 둔다(화면에는 짧은 이름만)
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginEventsService {
|
||||
private readonly logger = new Logger(LoginEventsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LoginEventEntity)
|
||||
private readonly events: Repository<LoginEventEntity>,
|
||||
private readonly directory: DirectoryService,
|
||||
) {}
|
||||
|
||||
/** 웹메일 · 앱 로그인 시도를 남긴다 — 던지지 않는다 */
|
||||
async recordSignIn(
|
||||
attempt: SignInAttempt,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
const address = normalizeEmail(attempt.email);
|
||||
try {
|
||||
const keep =
|
||||
attempt.isSuccess ||
|
||||
attempt.failureReason === 'wrongCode' ||
|
||||
(await this.isKnownEmployee(address));
|
||||
if (!keep) return;
|
||||
await this.events.insert({
|
||||
userEmail: address,
|
||||
occurredAt: now,
|
||||
method: attempt.method,
|
||||
isSuccess: attempt.isSuccess,
|
||||
isTwoFactor: attempt.isTwoFactor,
|
||||
failureReason: attempt.failureReason,
|
||||
ip: attempt.ip,
|
||||
userAgent: truncateUserAgent(attempt.userAgent),
|
||||
appPasswordId: null,
|
||||
appPasswordName: null,
|
||||
dedupeKey: null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} ${attempt.method === 'app' ? '앱' : '웹메일'} 로그인 기록을 남기지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 이벤트의 기록들을 넣는다 — 같은 겹침 거르기 키가 이미 있으면 건너뛴다(ON CONFLICT DO NOTHING) → 새로 넣은 키.
|
||||
* 한 문장의 매개변수 상한 때문에 1,000행씩 나눠 넣는다(LOGIN_EVENT_INSERT_CHUNK) — 문장마다 따로 끝나도 다시 받으면 키가 같아 겹치지 않는다.
|
||||
* DB 실패는 던진다 — 웹훅이 5xx 로 답해 엔진이 다시 보낸다
|
||||
*/
|
||||
async insertEngineEvents(
|
||||
rows: readonly Omit<LoginEventEntity, 'id'>[],
|
||||
): Promise<Set<string>> {
|
||||
const inserted = new Set<string>();
|
||||
for (
|
||||
let start = 0;
|
||||
start < rows.length;
|
||||
start += LOGIN_EVENT_INSERT_CHUNK
|
||||
) {
|
||||
const result = await this.events
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(LoginEventEntity)
|
||||
.values(rows.slice(start, start + LOGIN_EVENT_INSERT_CHUNK))
|
||||
.orIgnore()
|
||||
.returning(['dedupe_key'])
|
||||
.execute();
|
||||
const raw: unknown = result.raw;
|
||||
if (!Array.isArray(raw)) continue;
|
||||
for (const row of raw as { dedupe_key?: unknown }[]) {
|
||||
if (typeof row.dedupe_key === 'string') inserted.add(row.dedupe_key);
|
||||
}
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/** 실패를 남길 주소인가 — 사내 목록에 있는 주소. 사내 목록을 읽지 못하면 남긴다 */
|
||||
async isKnownEmployee(address: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.directory.hasEmail(address);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/login-events — 최근 90일 · 최근 차례 */
|
||||
async list(
|
||||
user: string,
|
||||
query: LoginEventQuery,
|
||||
now: Date = new Date(),
|
||||
): Promise<PaginatedResult<LoginEventEntity>> {
|
||||
const address = normalizeEmail(user);
|
||||
const scope = loginEventScope(query.isSuccess);
|
||||
const position = query.cursor ? this.readCursor(query.cursor, scope) : null;
|
||||
|
||||
const base = this.events
|
||||
.createQueryBuilder('event')
|
||||
.where('event.user_email = :address', { address })
|
||||
.andWhere('event.occurred_at >= :since', { since: retentionStart(now) });
|
||||
if (query.isSuccess !== undefined) {
|
||||
base.andWhere('event.is_success = :isSuccess', {
|
||||
isSuccess: query.isSuccess,
|
||||
});
|
||||
}
|
||||
const total = await base.clone().getCount();
|
||||
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy('event.occurred_at', 'DESC')
|
||||
.addOrderBy('event.id', 'DESC')
|
||||
.limit(query.limit + 1);
|
||||
if (position) {
|
||||
page.andWhere(
|
||||
'(event.occurred_at < :at OR (event.occurred_at = :at AND event.id < :id))',
|
||||
{ at: position.occurredAt, id: position.id },
|
||||
);
|
||||
}
|
||||
const rows = await page.getMany();
|
||||
const hasNext = rows.length > query.limit;
|
||||
const items = hasNext ? rows.slice(0, query.limit) : rows;
|
||||
const last = items[items.length - 1];
|
||||
return toPaginatedResult(items, {
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeLoginEventCursor(
|
||||
{ occurredAt: last.occurredAt, id: last.id },
|
||||
scope,
|
||||
)
|
||||
: null,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
/** 90일이 지난 기록을 지운다 → 지운 수 */
|
||||
async purge(now: Date = new Date()): Promise<number> {
|
||||
const result = await this.events.delete({
|
||||
occurredAt: LessThan(retentionStart(now)),
|
||||
});
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
private readCursor(cursor: string, scope: string) {
|
||||
const reading = decodeLoginEventCursor(cursor, scope);
|
||||
if ('problem' in reading) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-login-event-cursor:${reading.problem}` },
|
||||
});
|
||||
}
|
||||
return reading.position;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
addressOfAccountName,
|
||||
engineEventFingerprint,
|
||||
failureReasonOf,
|
||||
methodOfListener,
|
||||
readEngineEvents,
|
||||
} from './mail-engine-event';
|
||||
|
||||
const RECEIVED = new Date('2026-10-31T00:00:10.000Z');
|
||||
|
||||
/** 엔진이 보낸 모양 그대로 (2026-10-31 개발 엔진 웹훅 — 값은 바꿈) */
|
||||
const imapSuccess = {
|
||||
id: '17619051050001auth.success',
|
||||
createdAt: '2026-10-31T00:00:05Z',
|
||||
type: 'auth.success',
|
||||
data: {
|
||||
listenerId: 'imaps',
|
||||
localPort: 993,
|
||||
remoteIp: '211.186.95.252',
|
||||
remotePort: 51234,
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
accountId: 4,
|
||||
spanId: 12345,
|
||||
},
|
||||
};
|
||||
|
||||
describe('readEngineEvents (엔진 웹훅 본문 읽기)', () => {
|
||||
it('IMAP · SMTP 의 성공 · 실패 · 2단계 필요를 읽는다', () => {
|
||||
const reading = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
imapSuccess,
|
||||
{
|
||||
...imapSuccess,
|
||||
type: 'auth.failed',
|
||||
data: {
|
||||
...imapSuccess.data,
|
||||
listenerId: 'submissions',
|
||||
id: 'a1',
|
||||
reason: 'Authentication failed',
|
||||
},
|
||||
},
|
||||
{
|
||||
...imapSuccess,
|
||||
type: 'auth.mfa-required',
|
||||
data: { ...imapSuccess.data, listenerId: 'smtp' },
|
||||
},
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
);
|
||||
|
||||
expect(reading.malformed).toBe(false);
|
||||
expect(reading.skipped).toBe(0);
|
||||
expect(
|
||||
reading.events.map(({ method, outcome }) => [method, outcome]),
|
||||
).toEqual([
|
||||
['imap', 'success'],
|
||||
['smtp', 'failed'],
|
||||
['smtp', 'mfa-required'],
|
||||
]);
|
||||
expect(reading.events[0]).toMatchObject({
|
||||
occurredAt: new Date('2026-10-31T00:00:05Z'),
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
remoteIp: '211.186.95.252',
|
||||
appPasswordId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('빈 묶음 {"events":[]} 은 정상이다 (setup 이 서명한 빈 묶음으로 받는 쪽을 확인한다)', () => {
|
||||
expect(readEngineEvents({ events: [] }, RECEIVED)).toEqual({
|
||||
events: [],
|
||||
skipped: 0,
|
||||
malformed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('앱 비밀번호로 들어온 성공은 자격 번호를 앱 비밀번호 id 로 — details 가 앱 비밀번호 표시일 때만 (IMAP 실패의 id 는 명령 태그다)', () => {
|
||||
const appPassword = {
|
||||
...imapSuccess,
|
||||
data: {
|
||||
...imapSuccess.data,
|
||||
accountName: 'doyun.lee',
|
||||
id: 1,
|
||||
details: 'Authenticated with app password',
|
||||
},
|
||||
};
|
||||
const failedWithTag = {
|
||||
...imapSuccess,
|
||||
type: 'auth.failed',
|
||||
data: { ...imapSuccess.data, id: 'a1' },
|
||||
};
|
||||
const [success, failed] = readEngineEvents(
|
||||
{ events: [appPassword, failedWithTag] },
|
||||
RECEIVED,
|
||||
).events;
|
||||
|
||||
expect(success).toMatchObject({
|
||||
accountName: 'doyun.lee',
|
||||
appPasswordId: 'b',
|
||||
});
|
||||
expect(failed.appPasswordId).toBeNull();
|
||||
});
|
||||
|
||||
it('다른 종류 · http 리스너(웹메일 — 서버가 따로 남긴다) · 모양이 틀린 이벤트는 버린다', () => {
|
||||
const reading = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{ ...imapSuccess, type: 'auth.too-many-attempts' },
|
||||
{
|
||||
...imapSuccess,
|
||||
data: {
|
||||
...imapSuccess.data,
|
||||
listenerId: 'http',
|
||||
details: 'mail-gateway@arasystem.test',
|
||||
},
|
||||
},
|
||||
{ ...imapSuccess, data: { ...imapSuccess.data, accountName: '' } },
|
||||
{ ...imapSuccess, data: 'oops' },
|
||||
'not-an-object',
|
||||
null,
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
);
|
||||
expect(reading.events).toEqual([]);
|
||||
expect(reading.skipped).toBe(6);
|
||||
});
|
||||
|
||||
it('본문이 {"events":[…]} 모양이 아니면 malformed', () => {
|
||||
for (const body of [null, [], {}, { events: 'x' }, 'text']) {
|
||||
expect(readEngineEvents(body, RECEIVED).malformed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('createdAt 을 읽지 못하면 받은 때로 · IPv4 를 담은 IPv6 는 IPv4 로 · IP 가 아니면 null', () => {
|
||||
const [event] = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{
|
||||
...imapSuccess,
|
||||
createdAt: 'yesterday',
|
||||
data: { ...imapSuccess.data, remoteIp: '::ffff:211.186.95.252' },
|
||||
},
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
).events;
|
||||
expect(event.occurredAt).toEqual(RECEIVED);
|
||||
expect(event.remoteIp).toBe('211.186.95.252');
|
||||
|
||||
const [noIp] = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{ ...imapSuccess, data: { ...imapSuccess.data, remoteIp: 'nope' } },
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
).events;
|
||||
expect(noIp.remoteIp).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('engineEventFingerprint (겹침 거르기 — 엔진은 다시 보낼 때 id 를 새로 만든다)', () => {
|
||||
it('엔진 이벤트 id 가 달라도 내용이 같으면 같은 지문 — 키 차례는 상관없다', () => {
|
||||
const [first] = readEngineEvents(
|
||||
{ events: [imapSuccess] },
|
||||
RECEIVED,
|
||||
).events;
|
||||
const [retried] = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{
|
||||
...imapSuccess,
|
||||
id: '17619051050099auth.success',
|
||||
data: Object.fromEntries(
|
||||
Object.entries(imapSuccess.data).reverse(),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
).events;
|
||||
expect(retried.dedupeKey).toBe(first.dedupeKey);
|
||||
expect(first.dedupeKey).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
});
|
||||
|
||||
it('내용이 다르면(다른 연결의 spanId · 다른 때) 다른 지문', () => {
|
||||
const base = engineEventFingerprint(
|
||||
'auth.success',
|
||||
'2026-10-31T00:00:05Z',
|
||||
imapSuccess.data,
|
||||
);
|
||||
expect(
|
||||
engineEventFingerprint('auth.success', '2026-10-31T00:00:05Z', {
|
||||
...imapSuccess.data,
|
||||
spanId: 99,
|
||||
}),
|
||||
).not.toBe(base);
|
||||
expect(
|
||||
engineEventFingerprint(
|
||||
'auth.success',
|
||||
'2026-10-31T00:00:06Z',
|
||||
imapSuccess.data,
|
||||
),
|
||||
).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('작은 판정', () => {
|
||||
it('리스너 → 방식', () => {
|
||||
expect(methodOfListener('imaps')).toBe('imap');
|
||||
expect(methodOfListener('imap')).toBe('imap');
|
||||
expect(methodOfListener('submissions')).toBe('smtp');
|
||||
expect(methodOfListener('submission')).toBe('smtp');
|
||||
expect(methodOfListener('smtp')).toBe('smtp');
|
||||
expect(methodOfListener('http')).toBeNull();
|
||||
expect(methodOfListener('pop3s')).toBeNull();
|
||||
expect(methodOfListener(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('계정 이름 → 주소 (앱 비밀번호의 로컬 이름에는 사내 도메인을 붙인다)', () => {
|
||||
expect(addressOfAccountName('doyun.lee', 'arasystem.test')).toBe(
|
||||
'doyun.lee@arasystem.test',
|
||||
);
|
||||
expect(
|
||||
addressOfAccountName(' Doyun.Lee@AraSystem.test ', 'arasystem.test'),
|
||||
).toBe('doyun.lee@arasystem.test');
|
||||
expect(addressOfAccountName('', 'arasystem.test')).toBeNull();
|
||||
});
|
||||
|
||||
it('결과 → 실패 까닭', () => {
|
||||
expect(failureReasonOf('success')).toBeNull();
|
||||
expect(failureReasonOf('failed')).toBe('wrongPassword');
|
||||
expect(failureReasonOf('mfa-required')).toBe('appPasswordRequired');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { isIP, isIPv4 } from 'net';
|
||||
|
||||
import { credentialNumberToId } from './app-password';
|
||||
import type {
|
||||
LoginEventMethod,
|
||||
LoginFailureReason,
|
||||
SignInMethod,
|
||||
} from './login-event.entity';
|
||||
|
||||
/**
|
||||
* 엔진 웹훅 본문 읽기 (순수 함수) — 계약 5절 "엔진 웹훅 받기".
|
||||
* 본문은 `{"events":[{ "id", "createdAt", "type", "data": {…} }]}` 이다 (Stalwart 0.16.27 — `trc::serializers::json`).
|
||||
* 쓰는 것: type(auth.success · auth.failed · auth.mfa-required), data 의 listenerId · remoteIp · accountName · details · id.
|
||||
*
|
||||
* 겹침 거르기는 엔진 이벤트 id 가 아니라 **이벤트 내용의 지문**으로 한다 — 엔진은 id 를 보낼 때마다 새로 만든다
|
||||
* (`타임스탬프 + 프로세스 카운터 + 종류` 를 직렬화할 때 붙인다 → 실패해 다시 보낸 묶음은 같은 이벤트도 id 가 다르다, 0.16.27 소스 확인).
|
||||
* 내용(type · createdAt · data 전체 — 세션의 spanId · remotePort 가 든다)은 다시 보내도 같다.
|
||||
*/
|
||||
|
||||
/** 엔진의 앱 비밀번호 로그인 표시 (`details`) — Stalwart `auth/authentication.rs` 의 "Authenticated with app password" */
|
||||
const APP_PASSWORD_DETAILS = /\bapp password\b/i;
|
||||
/** 남기는 이벤트 종류 → 결과 */
|
||||
const OUTCOMES: ReadonlyMap<string, EngineLoginOutcome> = new Map([
|
||||
['auth.success', 'success'],
|
||||
['auth.failed', 'failed'],
|
||||
['auth.mfa-required', 'mfa-required'],
|
||||
]);
|
||||
/** IP 문자열 상한 (표의 열 길이) */
|
||||
const MAX_IP_LENGTH = 64;
|
||||
/** 계정 이름 상한 — 주소 최대 길이 (그보다 길면 사내 주소가 아니다) */
|
||||
const MAX_ACCOUNT_NAME_LENGTH = 254;
|
||||
const IPV4_MAPPED_PREFIX = '::ffff:';
|
||||
|
||||
export type EngineLoginOutcome = 'success' | 'failed' | 'mfa-required';
|
||||
|
||||
/** 남길 만한 엔진 로그인 이벤트 하나 */
|
||||
export interface EngineLoginEvent {
|
||||
readonly dedupeKey: string;
|
||||
readonly occurredAt: Date;
|
||||
/** 메일 프로그램의 방식 — imap · smtp (웹메일 · 앱은 서버가 판정해 남긴다) */
|
||||
readonly method: Exclude<LoginEventMethod, SignInMethod>;
|
||||
readonly outcome: EngineLoginOutcome;
|
||||
/** 엔진이 적은 계정 이름 — 주소, 앱 비밀번호면 로컬 이름 (소문자) */
|
||||
readonly accountName: string;
|
||||
readonly remoteIp: string | null;
|
||||
/** 앱 비밀번호로 들어온 성공이면 그 id — `details` 가 앱 비밀번호 표시일 때만 (IMAP 실패의 `id` 는 명령 태그다) */
|
||||
readonly appPasswordId: string | null;
|
||||
}
|
||||
|
||||
export interface EngineEventsReading {
|
||||
readonly events: EngineLoginEvent[];
|
||||
/** 버린 수 — 다른 종류 · 다른 리스너(http 등) · 모양이 틀림 */
|
||||
readonly skipped: number;
|
||||
/** 본문이 `{events: [...]}` 모양이 아니다 — 엔진 형식이 바뀌었을 수 있다 */
|
||||
readonly malformed: boolean;
|
||||
}
|
||||
|
||||
/** 결과 → 실패 까닭 (성공이면 null) */
|
||||
export function failureReasonOf(
|
||||
outcome: EngineLoginOutcome,
|
||||
): LoginFailureReason | null {
|
||||
if (outcome === 'failed') return 'wrongPassword';
|
||||
if (outcome === 'mfa-required') return 'appPasswordRequired';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 리스너 → 방식 — imap… → imap, submission… · smtp… → smtp, 그 밖(http 등 — 웹메일은 서버가 따로 남긴다)은 null */
|
||||
export function methodOfListener(
|
||||
listenerId: unknown,
|
||||
): EngineLoginEvent['method'] | null {
|
||||
if (typeof listenerId !== 'string') return null;
|
||||
const id = listenerId.toLowerCase();
|
||||
if (id.startsWith('imap')) return 'imap';
|
||||
if (id.startsWith('submission') || id.startsWith('smtp')) return 'smtp';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 엔진의 계정 이름 → 주소 (로컬 이름이면 사내 도메인을 붙인다) — 소문자, 모양이 아니면 null. 직원인지는 부르는 쪽이 본다 */
|
||||
export function addressOfAccountName(
|
||||
accountName: string,
|
||||
domain: string,
|
||||
): string | null {
|
||||
const name = accountName.trim().toLowerCase();
|
||||
if (name === '' || name.length > MAX_ACCOUNT_NAME_LENGTH) return null;
|
||||
return name.includes('@') ? name : `${name}@${domain}`;
|
||||
}
|
||||
|
||||
/** 사람이 읽을 IP — IPv4 를 담은 IPv6 는 IPv4 로, IP 가 아니면 null */
|
||||
function normalizedIp(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.length > MAX_IP_LENGTH) return null;
|
||||
const trimmed = value.trim();
|
||||
const mapped = trimmed.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)
|
||||
? trimmed.slice(IPV4_MAPPED_PREFIX.length)
|
||||
: null;
|
||||
if (mapped !== null && isIPv4(mapped)) return mapped;
|
||||
return isIP(trimmed) === 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
/** 지문용 정규화 — 객체의 키를 차례로 (값은 그대로). 깊이는 본문 모양 한도가 이미 막았다 */
|
||||
function canonical(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => [key, canonical(record[key])]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 이벤트 내용의 지문 — 종류 · 일어난 때(글 그대로) · data 전체 */
|
||||
export function engineEventFingerprint(
|
||||
type: string,
|
||||
createdAt: unknown,
|
||||
data: Record<string, unknown>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([type, createdAt ?? null, canonical(data)]))
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** 웹훅 본문 → 남길 만한 로그인 이벤트 (사내 직원인지 · 사내 목록에 있는지는 부르는 쪽이 본다) */
|
||||
export function readEngineEvents(
|
||||
body: unknown,
|
||||
receivedAt: Date,
|
||||
): EngineEventsReading {
|
||||
const list = isRecord(body) ? body.events : undefined;
|
||||
if (!Array.isArray(list)) {
|
||||
return { events: [], skipped: 0, malformed: true };
|
||||
}
|
||||
|
||||
const events: EngineLoginEvent[] = [];
|
||||
let skipped = 0;
|
||||
for (const item of list as unknown[]) {
|
||||
const event = readEvent(item, receivedAt);
|
||||
if (event) events.push(event);
|
||||
else skipped += 1;
|
||||
}
|
||||
return { events, skipped, malformed: false };
|
||||
}
|
||||
|
||||
function readEvent(item: unknown, receivedAt: Date): EngineLoginEvent | null {
|
||||
if (!isRecord(item) || typeof item.type !== 'string') return null;
|
||||
const outcome = OUTCOMES.get(item.type);
|
||||
const data = isRecord(item.data) ? item.data : null;
|
||||
if (!outcome || !data) return null;
|
||||
|
||||
const method = methodOfListener(data.listenerId);
|
||||
if (method === null) return null;
|
||||
if (typeof data.accountName !== 'string') return null;
|
||||
const accountName = data.accountName.trim().toLowerCase();
|
||||
if (accountName === '' || accountName.length > MAX_ACCOUNT_NAME_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const created =
|
||||
typeof item.createdAt === 'string' ? new Date(item.createdAt) : null;
|
||||
const occurredAt =
|
||||
created && !Number.isNaN(created.getTime()) ? created : receivedAt;
|
||||
const isAppPassword =
|
||||
outcome === 'success' &&
|
||||
typeof data.details === 'string' &&
|
||||
APP_PASSWORD_DETAILS.test(data.details);
|
||||
|
||||
return {
|
||||
dedupeKey: engineEventFingerprint(item.type, item.createdAt, data),
|
||||
occurredAt,
|
||||
method,
|
||||
outcome,
|
||||
accountName,
|
||||
remoteIp: normalizedIp(data.remoteIp),
|
||||
appPasswordId: isAppPassword ? credentialNumberToId(data.id) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { MAIL_ENGINE_EVENTS_ROUTE } from './account-security.constants';
|
||||
import { MailEngineEventsGuard } from './mail-engine-events.guard';
|
||||
import { MailEngineEventsService } from './mail-engine-events.service';
|
||||
|
||||
// 엔진만 부르는 길 (계약 5절 "엔진 웹훅 받기") — 앞단 nginx 가 /api/internal/ 을 404 로 닫고, 본문 파서가 소켓 주소(앱 네트워크) ·
|
||||
// 서명(HMAC-SHA256)을 본문을 읽기 전에 확인한다(mail-engine-events.parser.ts — 아니면 401 AUTH_001). 요청 수 한도가 없다 —
|
||||
// 엔진이 1초에 한 번까지 묶어 보낸다. 출처 검사(OriginGuard)는 엔진 요청에 Origin 이 없어 통과한다
|
||||
@ApiTags('Internal')
|
||||
@SkipThrottle()
|
||||
@UseGuards(MailEngineEventsGuard)
|
||||
@Controller(MAIL_ENGINE_EVENTS_ROUTE)
|
||||
export class MailEngineEventsController {
|
||||
constructor(private readonly engineEvents: MailEngineEventsService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'메일 엔진의 로그인 이벤트 받기 (엔진 웹훅 — 브라우저 · 앱은 부르지 않는다)',
|
||||
description:
|
||||
'본문 {"events":[{id, createdAt, type, data}]} — auth.success · auth.failed · auth.mfa-required 의 IMAP · SMTP 이벤트만 로그인 기록에 남긴다. ' +
|
||||
'사내 직원만(사내 목록에 없는 주소의 실패 · 막은 계정은 버린다), 겹침은 이벤트 내용의 지문으로 거른다. ' +
|
||||
'본문 4MB · 배열 하나 10,000 · 값 200,000 까지. DB 에 남기지 못하면 5xx — 엔진이 다시 보낸다.',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'X-Signature',
|
||||
description: '본문 원문의 HMAC-SHA256 (MAIL_WEBHOOK_SECRET), base64',
|
||||
required: true,
|
||||
})
|
||||
@ApiBody({
|
||||
description: '엔진 웹훅 본문',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
type: { type: 'string', example: 'auth.success' },
|
||||
data: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiStandardResponse(undefined, { description: '받았다 (data: null)' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'앱 네트워크 밖의 접속 · 서명이 없거나 맞지 않음 · 서명 키가 설정되지 않음(개발) · JSON 이 아닌 본문',
|
||||
)
|
||||
@ApiErrorResponse('VAL_001', '본문이 JSON 이 아니거나 모양 한도를 넘음')
|
||||
async receive(@Body() body: unknown): Promise<null> {
|
||||
await this.engineEvents.accept(body);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isVerifiedMailEngineEventsRequest } from './mail-engine-events.parser';
|
||||
|
||||
/**
|
||||
* 엔진 웹훅 처리기 앞의 가드 — 본문 파서(mail-engine-events.parser.ts)가 접속 주소 · 서명을 확인한 요청만 들인다.
|
||||
* 파서를 건너뛴 요청(JSON 이 아닌 본문 · 빈 본문 등)은 확인을 거치지 않았으므로 401 AUTH_001 — 본문을 쓰지 않는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailEngineEventsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
if (isVerifiedMailEngineEventsRequest(request)) return true;
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'mail-engine-events-not-verified',
|
||||
remoteAddress: request.socket?.remoteAddress ?? 'unknown',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import express from 'express';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { connect } from 'net';
|
||||
import request from 'supertest';
|
||||
|
||||
import { parseCidr } from '@/shared/utils/cidr';
|
||||
import type { AccountSecuritySettings } from './account-security.settings';
|
||||
import {
|
||||
MailEngineEventsRejection,
|
||||
MailEngineRejectionLog,
|
||||
createMailEngineEventsBodyParser,
|
||||
hasValidEngineSignature,
|
||||
isMailEngineEventsRequest,
|
||||
isVerifiedMailEngineEventsRequest,
|
||||
} from './mail-engine-events.parser';
|
||||
|
||||
const SECRET = 'Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a';
|
||||
const sign = (body: string, secret = SECRET) =>
|
||||
createHmac('sha256', Buffer.from(secret, 'utf8'))
|
||||
.update(body)
|
||||
.digest('base64');
|
||||
|
||||
describe('엔진 웹훅 서명 (X-Signature — 본문 원문의 HMAC-SHA256, base64)', () => {
|
||||
const body = '{"events":[]}';
|
||||
|
||||
it('같은 키로 서명한 본문이면 true (엔진은 키를 UTF-8 바이트로 쓴다)', () => {
|
||||
expect(hasValidEngineSignature(Buffer.from(body), sign(body), SECRET)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('다른 키 · 바뀐 본문 · 없거나 깨진 서명 · 길이가 다른 서명은 false', () => {
|
||||
expect(
|
||||
hasValidEngineSignature(
|
||||
Buffer.from(body),
|
||||
sign(body, `${SECRET}x`),
|
||||
SECRET,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasValidEngineSignature(
|
||||
Buffer.from('{"events":[1]}'),
|
||||
sign(body),
|
||||
SECRET,
|
||||
),
|
||||
).toBe(false);
|
||||
for (const signature of [
|
||||
undefined,
|
||||
'',
|
||||
'not-base64!!',
|
||||
'AAAA',
|
||||
['a', 'b'],
|
||||
]) {
|
||||
expect(
|
||||
hasValidEngineSignature(Buffer.from(body), signature, SECRET),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('엔진 웹훅 경로 판정', () => {
|
||||
it('POST /api/internal/mail-engine-events 만 (쿼리 · 끝 / 무관, 대소문자 변형은 넓게 — 라우터에서 404)', () => {
|
||||
for (const url of [
|
||||
'/api/internal/mail-engine-events',
|
||||
'/api/internal/mail-engine-events/',
|
||||
'/api/internal/mail-engine-events?x=1',
|
||||
'/API/Internal/Mail-Engine-Events',
|
||||
]) {
|
||||
expect(isMailEngineEventsRequest({ method: 'POST', url })).toBe(true);
|
||||
}
|
||||
expect(
|
||||
isMailEngineEventsRequest({
|
||||
method: 'GET',
|
||||
url: '/api/internal/mail-engine-events',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isMailEngineEventsRequest({ method: 'POST', url: '/api/messages' }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('엔진 웹훅 거절의 warn (MailEngineRejectionLog — 같은 까닭은 1분에 한 번)', () => {
|
||||
const rejection = (reason: string) =>
|
||||
new MailEngineEventsRejection(reason, '172.16.71.2');
|
||||
|
||||
it('처음 거절은 바로 남기고, 구간 안의 같은 까닭은 세기만 하다가 구간이 지나면 그 수를 붙여 다시 남긴다', () => {
|
||||
const lines: string[] = [];
|
||||
const log = new MailEngineRejectionLog((line) => lines.push(line), 60_000);
|
||||
const secretMissing = rejection('mail-engine-events-secret-missing');
|
||||
|
||||
log.note(secretMissing, 'trace-1', 0);
|
||||
for (let second = 1; second < 60; second += 1) {
|
||||
log.note(secretMissing, `trace-${second}`, second * 1000);
|
||||
}
|
||||
expect(lines).toEqual([
|
||||
'[AUTH_001][trace-1] POST /api/internal/mail-engine-events {"reason":"mail-engine-events-secret-missing","remoteAddress":"172.16.71.2"} (같은 까닭은 60초에 한 번만 남긴다)',
|
||||
]);
|
||||
|
||||
log.note(secretMissing, 'trace-60', 60_000);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[1]).toContain('[trace-60]');
|
||||
expect(lines[1]).toContain('앞 구간에 같은 까닭으로 59번 더 거절했다');
|
||||
});
|
||||
|
||||
it('까닭이 다르면 따로 센다 — 서명 틀림은 서명 키 없음에 묻히지 않는다', () => {
|
||||
const lines: string[] = [];
|
||||
const log = new MailEngineRejectionLog((line) => lines.push(line), 60_000);
|
||||
log.note(rejection('mail-engine-events-secret-missing'), undefined, 0);
|
||||
log.note(rejection('mail-engine-events-bad-signature'), undefined, 1000);
|
||||
log.note(rejection('mail-engine-events-bad-signature'), undefined, 2000);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[1]).toContain('mail-engine-events-bad-signature');
|
||||
expect(lines[1]).not.toContain('[undefined]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('엔진 웹훅 본문 파서 (createMailEngineEventsBodyParser)', () => {
|
||||
const PATH = '/api/internal/mail-engine-events';
|
||||
const settings = (
|
||||
overrides: Partial<AccountSecuritySettings> = {},
|
||||
): AccountSecuritySettings => ({
|
||||
webhookSecret: SECRET,
|
||||
appNetwork: parseCidr('127.0.0.0/8')!,
|
||||
isAppNetworkAddress: () => true,
|
||||
...overrides,
|
||||
});
|
||||
const appWith = (config: AccountSecuritySettings, lines: string[]) => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.headers['x-request-id'] = 'trace-e';
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
createMailEngineEventsBodyParser(
|
||||
config,
|
||||
new MailEngineRejectionLog((line) => lines.push(line)),
|
||||
),
|
||||
);
|
||||
app.post(PATH, (req, res) => {
|
||||
res.json({
|
||||
verified: isVerifiedMailEngineEventsRequest(req),
|
||||
body: req.body as unknown,
|
||||
});
|
||||
});
|
||||
return app;
|
||||
};
|
||||
const AUTH_001_BODY = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'AUTH_001',
|
||||
message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
|
||||
traceId: 'trace-e',
|
||||
},
|
||||
};
|
||||
|
||||
it('서명이 맞으면 본문을 읽고 표시를 남긴다 — 빈 묶음도 (setup 의 확인)', async () => {
|
||||
const lines: string[] = [];
|
||||
const raw = '{"events":[]}';
|
||||
const res = await request(appWith(settings(), lines))
|
||||
.post(PATH)
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Signature', sign(raw))
|
||||
.set('X-Arasystem-Setup', '[setup.mjs] key=abc')
|
||||
.send(raw)
|
||||
.expect(200);
|
||||
expect(res.body).toEqual({ verified: true, body: { events: [] } });
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
it('서명 키가 없거나 · 서명이 틀리거나 · 앱 네트워크 밖이면 표준 실패 형식의 401 AUTH_001 — warn 은 까닭마다 한 번', async () => {
|
||||
const raw = '{"events":[]}';
|
||||
const cases: [AccountSecuritySettings, string | undefined, string][] = [
|
||||
[settings({ webhookSecret: null }), sign(raw), 'secret-missing'],
|
||||
[settings(), sign(raw, `${SECRET}x`), 'bad-signature'],
|
||||
[settings(), undefined, 'bad-signature'],
|
||||
[
|
||||
settings({ isAppNetworkAddress: () => false }),
|
||||
sign(raw),
|
||||
'outside-app-network',
|
||||
],
|
||||
];
|
||||
for (const [config, signature, reason] of cases) {
|
||||
const lines: string[] = [];
|
||||
const app = appWith(config, lines);
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const call = request(app)
|
||||
.post(PATH)
|
||||
.set('Content-Type', 'application/json');
|
||||
if (signature !== undefined) call.set('X-Signature', signature);
|
||||
const res = await call.send(raw).expect(401);
|
||||
expect(res.body).toEqual(AUTH_001_BODY);
|
||||
}
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain(`mail-engine-events-${reason}`);
|
||||
expect(lines[0]).not.toContain(SECRET);
|
||||
}
|
||||
});
|
||||
|
||||
describe('본문을 받기 전에 — 접속 주소 · 서명 키 (2026-10-31 리뷰: 앱 네트워크 밖도 4MB 까지 받았다)', () => {
|
||||
/** 머리글만 보내고(본문은 보내지 않는다) 응답을 기다린다 — 본문을 기다리는 파서라면 답하지 않는다 */
|
||||
async function headersOnly(
|
||||
config: AccountSecuritySettings,
|
||||
lines: string[],
|
||||
): Promise<string> {
|
||||
const server: Server = createServer(appWith(config, lines));
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
try {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const socket = connect(port, '127.0.0.1');
|
||||
let text = '';
|
||||
// 답하지 않는 것을 보는 기한 — 일찍 답하는 경로는 곧바로 끝나므로 이 기한에 기대지 않는다
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve('NO-ANSWER');
|
||||
}, 1_000);
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
text += chunk.toString('utf8');
|
||||
});
|
||||
socket.on('end', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(text);
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
socket.write(
|
||||
`POST ${PATH} HTTP/1.1\r\nHost: backend\r\nContent-Type: application/json\r\n` +
|
||||
`X-Signature: ${sign('{"events":[]}')}\r\nContent-Length: 3000000\r\n\r\n`,
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
'앱 네트워크 밖',
|
||||
settings({ isAppNetworkAddress: () => false }),
|
||||
'outside-app-network',
|
||||
],
|
||||
[
|
||||
'서명 키 없음(개발)',
|
||||
settings({ webhookSecret: null }),
|
||||
'secret-missing',
|
||||
],
|
||||
])(
|
||||
'%s — 본문을 기다리지 않고 바로 401 AUTH_001 을 쓰고 연결을 닫는다',
|
||||
async (_label, config, reason) => {
|
||||
const lines: string[] = [];
|
||||
const answer = await headersOnly(config, lines);
|
||||
|
||||
expect(answer).toMatch(/^HTTP\/1\.1 401 /);
|
||||
expect(answer.toLowerCase()).toContain('connection: close');
|
||||
expect(answer).toContain('"code":"AUTH_001"');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain(`mail-engine-events-${reason}`);
|
||||
},
|
||||
);
|
||||
|
||||
it('주소 · 서명 키가 맞으면 본문을 받는다 — 서명은 원문을 다 받은 뒤에 본다(본문이 오지 않으면 답하지 않는다)', async () => {
|
||||
const lines: string[] = [];
|
||||
await expect(headersOnly(settings(), lines)).resolves.toBe('NO-ANSWER');
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON 이 아닌 본문은 읽지 않고 지나간다 — 처리기 앞의 가드가 표시 없는 요청을 막는다', async () => {
|
||||
const lines: string[] = [];
|
||||
const res = await request(appWith(settings(), lines))
|
||||
.post(PATH)
|
||||
.set('Content-Type', 'text/plain')
|
||||
.send('{"events":[]}')
|
||||
.expect(200);
|
||||
expect(res.body).toMatchObject({ verified: false });
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { HttpStatus, Logger } from '@nestjs/common';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { json, type RequestHandler } from 'express';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { getErrorDefinition } from '@/common/errors/error-codes';
|
||||
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
|
||||
import { findJsonTextShapeProblem } from '@/shared/utils/json-shape';
|
||||
import {
|
||||
MAIL_ENGINE_EVENTS_JSON_LIMIT,
|
||||
MAIL_ENGINE_EVENTS_ROUTE,
|
||||
MAIL_ENGINE_EVENTS_SHAPE_LIMITS,
|
||||
MAIL_ENGINE_REJECTION_LOG_WINDOW_MS,
|
||||
MAIL_ENGINE_SIGNATURE_HEADER,
|
||||
} from './account-security.constants';
|
||||
import type { AccountSecuritySettings } from './account-security.settings';
|
||||
|
||||
/**
|
||||
* 엔진 웹훅의 본문 파서 (계약 5절 "엔진 웹훅 받기") — 서버 몫의 두 겹:
|
||||
* 1. 접속 주소 — `X-Forwarded-For` 가 아니라 소켓 주소가 앱 네트워크(`APP_NETWORK_SUBNET`) 안이어야 한다.
|
||||
* 서명 키가 없는지(개발)와 함께 **본문을 받기 전에** 본다 — 아니면 본문을 받지 않고 401 을 쓰고 연결을 닫는다
|
||||
* (앱 네트워크 밖의 접속이 4MB 까지 보내게 두지 않는다 — 2026-10-31 리뷰)
|
||||
* 2. 서명 — 본문 원문의 HMAC-SHA256(`X-Signature`, base64)이 `MAIL_WEBHOOK_SECRET` 으로 맞아야 한다 (상수 시간 비교).
|
||||
* 원문이 있어야 하므로 본문을 받은 뒤 · JSON 으로 읽기 전에(body-parser 의 verify) 본다
|
||||
* 하나라도 아니면 401 AUTH_001(표준 실패 형식)을 이 파서가 바로 쓴다. warn 에는 까닭과 접속 주소만 남기고,
|
||||
* 같은 까닭은 1분에 한 번만 남긴다(MailEngineRejectionLog — 서명 키가 어긋나면 엔진이 1초마다 보낸다).
|
||||
* (세 번째 겹 — 앞단 nginx 가 /api/internal/ 을 404 로 닫는 것은 release-ops 몫이다)
|
||||
*
|
||||
* 이 경로만 JSON 한도를 따로 둔다(본문 4MB · 배열 하나 10,000 · 값 200,000 — 공격이 오면 엔진의 1초 묶음이 커진다).
|
||||
* 통과한 요청에는 표시를 남긴다 — 처리기 앞의 가드(MailEngineEventsGuard)가 표시 없는 요청(JSON 이 아니라 이 파서를 건너뛴 본문 등)을 막는다.
|
||||
* 서명 키 · 본문은 어떤 로그에도 남기지 않는다.
|
||||
*/
|
||||
|
||||
/** 이 파서를 지난(주소 · 서명이 맞은) 요청의 표시 */
|
||||
const VERIFIED = Symbol('mail-engine-events-verified');
|
||||
|
||||
type MarkedRequest = IncomingMessage & { [VERIFIED]?: true };
|
||||
|
||||
const MAIL_ENGINE_EVENTS_PATH = `/api/${MAIL_ENGINE_EVENTS_ROUTE}`;
|
||||
/** UTF-8 의 비교용 이름 — app.setup.ts 의 verifyJsonBody 와 같은 정규화 (body-parser 가 UTF-8 로 읽는 이름들) */
|
||||
const UTF8_CHARSET = 'utf8';
|
||||
/** SHA-256 의 길이(바이트) */
|
||||
const SHA256_BYTES = 32;
|
||||
|
||||
/** 엔진 웹훅 요청인가 — POST /api/internal/mail-engine-events (쿼리 · 끝 `/` 는 가리지 않고, 대소문자는 넓게 — 변형은 라우터에서 404) */
|
||||
export function isMailEngineEventsRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
): boolean {
|
||||
if (req.method !== 'POST') return false;
|
||||
const path = (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
return path === MAIL_ENGINE_EVENTS_PATH;
|
||||
}
|
||||
|
||||
/** 파서가 주소 · 서명을 확인한 요청인가 (가드가 본다) */
|
||||
export function isVerifiedMailEngineEventsRequest(req: object): boolean {
|
||||
return (req as MarkedRequest)[VERIFIED] === true;
|
||||
}
|
||||
|
||||
/** 주소 · 서명 확인에서 거절한 요청 — 파서가 401 AUTH_001 을 바로 쓴다 (전역 필터로 보내지 않는다) */
|
||||
export class MailEngineEventsRejection extends Error {
|
||||
constructor(
|
||||
readonly reason: string,
|
||||
readonly remoteAddress: string,
|
||||
) {
|
||||
super(reason);
|
||||
this.name = 'MailEngineEventsRejection';
|
||||
}
|
||||
}
|
||||
|
||||
function rejected(
|
||||
reason: string,
|
||||
req: IncomingMessage,
|
||||
): MailEngineEventsRejection {
|
||||
return new MailEngineEventsRejection(
|
||||
reason,
|
||||
req.socket?.remoteAddress ?? 'unknown',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹훅 거절의 warn — 같은 까닭은 구간(1분)에 한 번만 남기고, 그사이 거절한 수는 다음 줄에 붙인다.
|
||||
* 까닭은 셋뿐이라(앱 네트워크 밖 · 서명 키 없음 · 서명 틀림) 기억이 커지지 않는다. 서명 · 본문은 받지 않는다
|
||||
*/
|
||||
export class MailEngineRejectionLog {
|
||||
private readonly windows = new Map<
|
||||
string,
|
||||
{ endsAt: number; suppressed: number }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly write: (message: string) => void,
|
||||
private readonly windowMs: number = MAIL_ENGINE_REJECTION_LOG_WINDOW_MS,
|
||||
) {}
|
||||
|
||||
note(
|
||||
rejection: MailEngineEventsRejection,
|
||||
traceId: string | undefined,
|
||||
now: number = Date.now(),
|
||||
): void {
|
||||
const current = this.windows.get(rejection.reason);
|
||||
if (current && now < current.endsAt) {
|
||||
current.suppressed += 1;
|
||||
return;
|
||||
}
|
||||
this.windows.set(rejection.reason, {
|
||||
endsAt: now + this.windowMs,
|
||||
suppressed: 0,
|
||||
});
|
||||
const context = JSON.stringify({
|
||||
reason: rejection.reason,
|
||||
remoteAddress: rejection.remoteAddress,
|
||||
});
|
||||
const earlier =
|
||||
current && current.suppressed > 0
|
||||
? ` — 앞 구간에 같은 까닭으로 ${current.suppressed}번 더 거절했다`
|
||||
: '';
|
||||
this.write(
|
||||
`[AUTH_001]${traceId ? `[${traceId}]` : ''} POST ${MAIL_ENGINE_EVENTS_PATH} ${context}${earlier} (같은 까닭은 ${Math.round(this.windowMs / 1000)}초에 한 번만 남긴다)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** `X-Signature`(base64) 가 본문의 HMAC-SHA256 과 같은가 — 길이가 다르면 바로 false, 같으면 상수 시간 비교 */
|
||||
export function hasValidEngineSignature(
|
||||
body: Buffer,
|
||||
signature: string | string[] | undefined,
|
||||
secret: string,
|
||||
): boolean {
|
||||
if (typeof signature !== 'string' || signature === '') return false;
|
||||
const presented = Buffer.from(signature.trim(), 'base64');
|
||||
if (presented.length !== SHA256_BYTES) return false;
|
||||
const expected = createHmac('sha256', Buffer.from(secret, 'utf8'))
|
||||
.update(body)
|
||||
.digest();
|
||||
return timingSafeEqual(expected, presented);
|
||||
}
|
||||
|
||||
/** 본문을 읽기 전의 확인 — body-parser 의 verify (던지면 그 상태 코드로 끝난다 — BusinessException 의 status) */
|
||||
function verifier(settings: AccountSecuritySettings) {
|
||||
return (
|
||||
req: IncomingMessage,
|
||||
_res: ServerResponse,
|
||||
buffer: Buffer,
|
||||
encoding: string,
|
||||
): void => {
|
||||
// 접속 주소 · 서명 키 유무는 본문을 받기 전에 이미 봤다(createMailEngineEventsBodyParser) — 키는 타입을 좁히려고 한 번 더
|
||||
if (settings.webhookSecret === null) {
|
||||
throw rejected('mail-engine-events-secret-missing', req);
|
||||
}
|
||||
if (
|
||||
!hasValidEngineSignature(
|
||||
buffer,
|
||||
req.headers[MAIL_ENGINE_SIGNATURE_HEADER],
|
||||
settings.webhookSecret,
|
||||
)
|
||||
) {
|
||||
throw rejected('mail-engine-events-bad-signature', req);
|
||||
}
|
||||
if (encoding.toLowerCase().replace(/[^0-9a-z]/g, '') !== UTF8_CHARSET) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'json-not-utf8' },
|
||||
});
|
||||
}
|
||||
const problem = findJsonTextShapeProblem(
|
||||
buffer,
|
||||
MAIL_ENGINE_EVENTS_SHAPE_LIMITS,
|
||||
);
|
||||
if (problem !== null) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'json-shape-limit', problem, stage: 'before-parse' },
|
||||
});
|
||||
}
|
||||
(req as MarkedRequest)[VERIFIED] = true;
|
||||
};
|
||||
}
|
||||
|
||||
const logger = new Logger('MailEngineEvents');
|
||||
|
||||
/**
|
||||
* 엔진 웹훅의 JSON 파서 — configureApp 이 **다른 JSON 파서보다 먼저** 붙인다(이 경로의 본문을 1MB 파서가 413 으로 끊지 않게).
|
||||
* 이 경로의 JSON 본문만 읽는다 — 그 밖의 요청은 건너뛴다.
|
||||
* - 이 경로면 본문을 받기 전에 접속 주소(앱 네트워크) · 서명 키 유무를 본다 — 아니면 본문을 받지 않고 401 을 쓰고 연결을 닫는다(`Connection: close`)
|
||||
* - 서명은 원문을 받은 뒤 JSON 으로 읽기 전에 본다
|
||||
* 거절은 여기서 표준 실패 형식의 401 AUTH_001 을 쓴다(전역 필터와 같은 모양 — 요청마다 warn 을 남기지 않으려고).
|
||||
* 서명이 맞은 뒤의 실패(UTF-8 아님 · 모양 한도 — 엔진이 보낸 본문)는 전역 필터로 간다
|
||||
*/
|
||||
export function createMailEngineEventsBodyParser(
|
||||
settings: AccountSecuritySettings,
|
||||
rejections: MailEngineRejectionLog = new MailEngineRejectionLog((message) =>
|
||||
logger.warn(message),
|
||||
),
|
||||
): RequestHandler {
|
||||
const parse = json({
|
||||
limit: MAIL_ENGINE_EVENTS_JSON_LIMIT,
|
||||
inflate: false,
|
||||
type: (req: IncomingMessage) =>
|
||||
isMailEngineEventsRequest(req) &&
|
||||
(req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() ===
|
||||
'application/json',
|
||||
verify: verifier(settings),
|
||||
});
|
||||
return (req, res, next) => {
|
||||
const reject = (
|
||||
rejection: MailEngineEventsRejection,
|
||||
bodyUnread: boolean,
|
||||
) => {
|
||||
const traceId = req.header(REQUEST_ID_HEADER) ?? undefined;
|
||||
rejections.note(rejection, traceId);
|
||||
// 본문을 받지 않고 끝낸 응답 — 연결을 닫아 남은 본문도 받지 않는다
|
||||
if (bodyUnread) res.setHeader('Connection', 'close');
|
||||
res.status(HttpStatus.UNAUTHORIZED).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'AUTH_001',
|
||||
message: getErrorDefinition('AUTH_001').message,
|
||||
...(traceId ? { traceId } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isMailEngineEventsRequest(req)) {
|
||||
if (!settings.isAppNetworkAddress(req.socket?.remoteAddress)) {
|
||||
reject(rejected('mail-engine-events-outside-app-network', req), true);
|
||||
return;
|
||||
}
|
||||
if (settings.webhookSecret === null) {
|
||||
reject(rejected('mail-engine-events-secret-missing', req), true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
parse(req, res, (error?: unknown) => {
|
||||
if (error instanceof MailEngineEventsRejection) {
|
||||
reject(error, false);
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { AppPasswordsService } from './app-passwords.service';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import type { LoginEventsService } from './login-events.service';
|
||||
import { MailEngineEventsService } from './mail-engine-events.service';
|
||||
|
||||
const settings: MailSettings = {
|
||||
jmapBaseUrl: 'http://mail:8080',
|
||||
domain: 'arasystem.test',
|
||||
gatewayUser: 'mail-gateway@arasystem.test',
|
||||
gatewayPassword: 'gateway-secret',
|
||||
attachmentMaxBytes: 20 * 1024 * 1024,
|
||||
attachmentTotalMaxBytes: 25 * 1024 * 1024,
|
||||
blockedAccounts: new Set([
|
||||
'admin@arasystem.test',
|
||||
'mail-directory@arasystem.test',
|
||||
]),
|
||||
directoryAccount: 'mail-directory@arasystem.test',
|
||||
};
|
||||
|
||||
const event = (
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
createdAt = '2026-10-31T00:00:05Z',
|
||||
) => ({
|
||||
id: `${Math.random()}`,
|
||||
createdAt,
|
||||
type,
|
||||
data: {
|
||||
listenerId: 'imaps',
|
||||
remoteIp: '211.186.95.252',
|
||||
remotePort: 51234,
|
||||
spanId: 1,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
describe('MailEngineEventsService (엔진 웹훅 — 메일 프로그램의 로그인 기록)', () => {
|
||||
let inserted: Omit<LoginEventEntity, 'id'>[];
|
||||
let existingKeys: Set<string>;
|
||||
let loginEvents: {
|
||||
insertEngineEvents: jest.Mock;
|
||||
isKnownEmployee: jest.Mock;
|
||||
};
|
||||
let appPasswords: { recordUse: jest.Mock };
|
||||
let service: MailEngineEventsService;
|
||||
|
||||
beforeEach(() => {
|
||||
inserted = [];
|
||||
existingKeys = new Set();
|
||||
loginEvents = {
|
||||
insertEngineEvents: jest.fn((rows: Omit<LoginEventEntity, 'id'>[]) => {
|
||||
const fresh = rows.filter(
|
||||
(row) => !existingKeys.has(row.dedupeKey ?? ''),
|
||||
);
|
||||
inserted.push(...fresh);
|
||||
for (const row of fresh) existingKeys.add(row.dedupeKey ?? '');
|
||||
return Promise.resolve(
|
||||
new Set(fresh.map((row) => row.dedupeKey ?? '')),
|
||||
);
|
||||
}),
|
||||
isKnownEmployee: jest.fn((address: string) =>
|
||||
Promise.resolve(address !== 'nobody@arasystem.test'),
|
||||
),
|
||||
};
|
||||
appPasswords = { recordUse: jest.fn().mockResolvedValue('회사 PC 아웃룩') };
|
||||
service = new MailEngineEventsService(
|
||||
settings,
|
||||
loginEvents as unknown as LoginEventsService,
|
||||
appPasswords as unknown as AppPasswordsService,
|
||||
);
|
||||
});
|
||||
|
||||
it('빈 묶음은 아무것도 남기지 않고 끝난다 (setup 의 서명한 빈 묶음 — 200)', async () => {
|
||||
await expect(service.accept({ events: [] })).resolves.toEqual({
|
||||
kept: 0,
|
||||
duplicates: 0,
|
||||
dropped: 0,
|
||||
});
|
||||
expect(inserted).toEqual([]);
|
||||
});
|
||||
|
||||
it('IMAP · SMTP 의 성공 · 실패 · 2단계 필요를 남긴다 — 실패 까닭 · IP · 방식', async () => {
|
||||
const result = await service.accept({
|
||||
events: [
|
||||
event('auth.success', { accountName: 'doyun.lee@arasystem.test' }),
|
||||
event('auth.failed', {
|
||||
listenerId: 'submissions',
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
spanId: 2,
|
||||
}),
|
||||
event('auth.mfa-required', {
|
||||
accountName: 'ara.kim@arasystem.test',
|
||||
spanId: 3,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kept: 3, duplicates: 0, dropped: 0 });
|
||||
expect(
|
||||
inserted.map(({ userEmail, method, isSuccess, failureReason }) => ({
|
||||
userEmail,
|
||||
method,
|
||||
isSuccess,
|
||||
failureReason,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
userEmail: 'doyun.lee@arasystem.test',
|
||||
method: 'imap',
|
||||
isSuccess: true,
|
||||
failureReason: null,
|
||||
},
|
||||
{
|
||||
userEmail: 'doyun.lee@arasystem.test',
|
||||
method: 'smtp',
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongPassword',
|
||||
},
|
||||
{
|
||||
userEmail: 'ara.kim@arasystem.test',
|
||||
method: 'imap',
|
||||
isSuccess: false,
|
||||
failureReason: 'appPasswordRequired',
|
||||
},
|
||||
]);
|
||||
expect(inserted[0]).toMatchObject({
|
||||
ip: '211.186.95.252',
|
||||
isTwoFactor: false,
|
||||
userAgent: null,
|
||||
appPasswordId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('사내 직원만 — 다른 도메인 · 막은 계정(관리자 · 사내 목록 계정 · 게이트웨이)은 버린다, 사내 목록에 없는 주소의 실패도 버린다', async () => {
|
||||
const result = await service.accept({
|
||||
events: [
|
||||
event('auth.failed', { accountName: 'someone@gmail.com' }),
|
||||
event('auth.success', { accountName: 'admin@arasystem.test' }),
|
||||
event('auth.success', { accountName: 'mail-gateway@arasystem.test' }),
|
||||
event('auth.failed', { accountName: 'nobody@arasystem.test' }),
|
||||
event('auth.failed', {
|
||||
accountName: 'nobody@arasystem.test',
|
||||
spanId: 9,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kept: 0, duplicates: 0, dropped: 5 });
|
||||
// 같은 묶음의 같은 주소는 사내 목록을 한 번만 묻는다
|
||||
expect(loginEvents.isKnownEmployee).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('앱 비밀번호로 들어온 성공은 그 앱 비밀번호의 마지막 사용을 고치고, 기록에 그때의 이름을 남긴다', async () => {
|
||||
await service.accept({
|
||||
events: [
|
||||
event('auth.success', {
|
||||
accountName: 'doyun.lee',
|
||||
id: 1,
|
||||
details: 'Authenticated with app password',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(appPasswords.recordUse).toHaveBeenCalledWith(
|
||||
'doyun.lee@arasystem.test',
|
||||
'b',
|
||||
{
|
||||
at: new Date('2026-10-31T00:00:05Z'),
|
||||
method: 'imap',
|
||||
ip: '211.186.95.252',
|
||||
},
|
||||
);
|
||||
expect(inserted[0]).toMatchObject({
|
||||
userEmail: 'doyun.lee@arasystem.test',
|
||||
appPasswordId: 'b',
|
||||
appPasswordName: '회사 PC 아웃룩',
|
||||
});
|
||||
});
|
||||
|
||||
it('엔진이 다시 보낸 묶음(이벤트 id 만 다르다)은 겹치지 않는다 · 한 묶음 안의 같은 이벤트도', async () => {
|
||||
const original = event('auth.success', {
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
});
|
||||
await service.accept({ events: [original, { ...original, id: 'again' }] });
|
||||
const retried = await service.accept({
|
||||
events: [{ ...original, id: 'retried-with-new-id' }],
|
||||
});
|
||||
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(retried).toEqual({ kept: 0, duplicates: 1, dropped: 0 });
|
||||
});
|
||||
|
||||
it('본문 모양이 다르면 아무것도 남기지 않고 warn 을 한 번만 남긴다 (본문은 남기지 않는다)', async () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
await service.accept({ nope: true });
|
||||
await service.accept('text');
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0][0])).not.toContain('nope');
|
||||
expect(loginEvents.insertEngineEvents).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('DB 에 남기지 못하면 던진다 — 5xx 로 답해 엔진이 다시 보낸다', async () => {
|
||||
loginEvents.insertEngineEvents.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
service.accept({
|
||||
events: [
|
||||
event('auth.success', { accountName: 'doyun.lee@arasystem.test' }),
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isEmployeeAddress } from '@/modules/auth/employee-address';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { AppPasswordsService } from './app-passwords.service';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
import {
|
||||
addressOfAccountName,
|
||||
failureReasonOf,
|
||||
readEngineEvents,
|
||||
} from './mail-engine-event';
|
||||
|
||||
/** 웹훅 한 번의 처리 결과 (debug 로그 · 테스트) */
|
||||
export interface EngineEventsAccepted {
|
||||
/** 새로 남긴 수 */
|
||||
readonly kept: number;
|
||||
/** 이미 남긴 것(엔진이 다시 보낸 묶음) */
|
||||
readonly duplicates: number;
|
||||
/** 버린 수 — 다른 종류 · 리스너 · 사내 직원이 아님 · 사내 목록에 없는 주소의 실패 · 막은 계정 */
|
||||
readonly dropped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 웹훅 받기 (계약 5절 "엔진 웹훅 받기") — 메일 프로그램(IMAP · SMTP)의 로그인을 로그인 기록에 남긴다.
|
||||
* 접속 주소 · 서명은 본문 파서가 이미 확인했다 (mail-engine-events.parser.ts).
|
||||
*
|
||||
* - 사내 도메인의 직원만 — 막은 계정(관리자 · 게이트웨이 · 사내 목록 계정)과 사내 목록에 없는 주소의 실패는 버린다(엔진은 서버 전체의 이벤트를 보낸다)
|
||||
* - 겹침은 이벤트 내용의 지문으로 거른다(엔진이 다시 보낸 묶음 — 엔진의 이벤트 id 는 보낼 때마다 바뀐다)
|
||||
* - 앱 비밀번호로 들어온 성공은 그 앱 비밀번호의 마지막 사용을 고치고, 기록에 그때의 이름을 남긴다
|
||||
* - 처리는 기다린 뒤 답한다(DB 한 번 — 수 ms). DB 가 실패하면 5xx 로 답해 엔진이 다시 보낸다(5분까지 — 지문이 같아 겹치지 않는다)
|
||||
* 웹훅 수신은 로그에 남기지 않는다(반복되는 정상 흐름) — 남긴 수 · 버린 수는 debug
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailEngineEventsService {
|
||||
private readonly logger = new Logger(MailEngineEventsService.name);
|
||||
/** 본문 모양이 다르다는 warn 을 이미 남겼는가 (엔진 판이 바뀐 경우 — 한 번만) */
|
||||
private malformedReported = false;
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
|
||||
private readonly loginEvents: LoginEventsService,
|
||||
private readonly appPasswords: AppPasswordsService,
|
||||
) {}
|
||||
|
||||
async accept(
|
||||
body: unknown,
|
||||
receivedAt: Date = new Date(),
|
||||
): Promise<EngineEventsAccepted> {
|
||||
const reading = readEngineEvents(body, receivedAt);
|
||||
if (reading.malformed) {
|
||||
if (!this.malformedReported) {
|
||||
this.malformedReported = true;
|
||||
this.logger.warn(
|
||||
'[MAIL_001] 엔진 웹훅 본문이 {"events":[…]} 모양이 아닙니다 — 메일 엔진 판이 바뀌었는지 확인하세요. (본문은 남기지 않는다)',
|
||||
);
|
||||
}
|
||||
return { kept: 0, duplicates: 0, dropped: 0 };
|
||||
}
|
||||
|
||||
const { domain, gatewayUser, blockedAccounts } = this.mailSettings;
|
||||
if (!domain) {
|
||||
return { kept: 0, duplicates: 0, dropped: reading.events.length };
|
||||
}
|
||||
|
||||
const rows = new Map<string, Omit<LoginEventEntity, 'id'>>();
|
||||
const known = new Map<string, boolean>();
|
||||
let dropped = reading.skipped;
|
||||
for (const event of reading.events) {
|
||||
const address = addressOfAccountName(event.accountName, domain);
|
||||
if (
|
||||
address === null ||
|
||||
!isEmployeeAddress(address, domain, gatewayUser, blockedAccounts) ||
|
||||
rows.has(event.dedupeKey)
|
||||
) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
if (event.outcome !== 'success') {
|
||||
let isKnown = known.get(address);
|
||||
if (isKnown === undefined) {
|
||||
isKnown = await this.loginEvents.isKnownEmployee(address);
|
||||
known.set(address, isKnown);
|
||||
}
|
||||
if (!isKnown) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const appPasswordName = event.appPasswordId
|
||||
? await this.appPasswords.recordUse(address, event.appPasswordId, {
|
||||
at: event.occurredAt,
|
||||
method: event.method,
|
||||
ip: event.remoteIp,
|
||||
})
|
||||
: null;
|
||||
rows.set(event.dedupeKey, {
|
||||
userEmail: address,
|
||||
occurredAt: event.occurredAt,
|
||||
method: event.method,
|
||||
isSuccess: event.outcome === 'success',
|
||||
isTwoFactor: false,
|
||||
failureReason: failureReasonOf(event.outcome),
|
||||
ip: event.remoteIp,
|
||||
userAgent: null,
|
||||
appPasswordId: event.appPasswordId,
|
||||
appPasswordName,
|
||||
dedupeKey: event.dedupeKey,
|
||||
});
|
||||
}
|
||||
|
||||
const inserted = await this.loginEvents.insertEngineEvents([
|
||||
...rows.values(),
|
||||
]);
|
||||
const result = {
|
||||
kept: inserted.size,
|
||||
duplicates: rows.size - inserted.size,
|
||||
dropped,
|
||||
};
|
||||
if (result.kept + result.duplicates + result.dropped > 0) {
|
||||
this.logger.debug(
|
||||
`엔진 로그인 이벤트 — 남김 ${result.kept} · 이미 있음 ${result.duplicates} · 버림 ${result.dropped}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { TWO_FACTOR_CODE_ADDRESS_LIMIT } from '@/modules/auth/auth.constants';
|
||||
import { LoginAttemptService } from '@/modules/auth/login-attempt.service';
|
||||
import { TWO_FACTOR_CODE_REUSE_MS } from './account-security.constants';
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import { TwoFactorEnrollments } from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const IP = '203.0.113.10';
|
||||
const accepted = () =>
|
||||
Promise.resolve({ kind: 'accepted' as const, value: 'ok' });
|
||||
const rejected = () => Promise.resolve({ kind: 'rejected' as const });
|
||||
|
||||
/** 손으로 푸는 약속 — 확인(run)이 걸려 있는 동안 다른 요청을 보낸다 */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function codeOf(promise: Promise<unknown>): Promise<string | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('TwoFactorCodeGuard (코드 한도 · 다시 쓰기 막기 — 계약 5절)', () => {
|
||||
let now: number;
|
||||
let guard: TwoFactorCodeGuard;
|
||||
|
||||
beforeEach(() => {
|
||||
now = 1_800_000_000_000;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
guard = new TwoFactorCodeGuard(new LoginAttemptService());
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('받으면 값을 돌려준다 · 받지 않으면 400 AUTH_006 (context 에 코드는 없다)', async () => {
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await guard.check({ email: ARA, ip: IP }, '654321', rejected);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(BusinessException);
|
||||
expect((failure as BusinessException).code).toBe('AUTH_006');
|
||||
expect((failure as BusinessException).getStatus()).toBe(400);
|
||||
expect(
|
||||
JSON.stringify((failure as BusinessException).context),
|
||||
).not.toContain('654321');
|
||||
});
|
||||
|
||||
it(`성공에 쓴 코드는 ${TWO_FACTOR_CODE_REUSE_MS / 60_000}분 동안 다시 받지 않는다 — 확인(run) 없이 AUTH_006 · 실패로 센다, 다른 직원 · 다른 코드는 그대로`, async () => {
|
||||
guard.remember(ARA, '123456');
|
||||
const run = jest.fn(accepted);
|
||||
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', run)),
|
||||
).toBe('AUTH_006');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
guard.check({ email: 'doyun.lee@arasystem.test', ip: IP }, '123456', run),
|
||||
).resolves.toBe('ok');
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '111111', run),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
now += TWO_FACTOR_CODE_REUSE_MS;
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', run),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it(`실패가 ${TWO_FACTOR_CODE_ADDRESS_LIMIT}번 쌓이면 맞는 코드도 REQ_001 — 다시 쓴 코드도 실패로 센다`, async () => {
|
||||
guard.remember(ARA, '999999');
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '999999', accepted)),
|
||||
).toBe('AUTH_006');
|
||||
for (let i = 1; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) {
|
||||
expect(
|
||||
await codeOf(
|
||||
guard.check(
|
||||
{ email: ARA, ip: `198.51.100.${i}` },
|
||||
'000000',
|
||||
rejected,
|
||||
),
|
||||
),
|
||||
).toBe('AUTH_006');
|
||||
}
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', accepted)),
|
||||
).toBe('REQ_001');
|
||||
});
|
||||
|
||||
it('확인이 던지면(엔진 장애) 자리를 돌려주고 그대로 던진다 — 실패로 세지 않는다', async () => {
|
||||
const down = () => Promise.reject(new BusinessException('MAIL_001'));
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) {
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', down)),
|
||||
).toBe('MAIL_001');
|
||||
}
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
describe('같은 코드의 동시 요청 — 확인을 시작할 때 "확인 중"을 차지한다 (2026-10-31 리뷰)', () => {
|
||||
it('앞 요청이 확인하는 동안 같은 코드는 확인(run) 없이 400 AUTH_006 · 실패로 센다 — 앞이 받으면 그 코드는 쓴 코드다', async () => {
|
||||
const gate = deferred<{ kind: 'accepted'; value: string }>();
|
||||
const first = guard.check(
|
||||
{ email: ARA, ip: IP },
|
||||
'123456',
|
||||
() => gate.promise,
|
||||
);
|
||||
expect(guard.isPending(ARA, '123456')).toBe(true);
|
||||
|
||||
const run = jest.fn(accepted);
|
||||
const second = await codeOf(
|
||||
guard.check({ email: ARA, ip: '198.51.100.7' }, '123456', run),
|
||||
);
|
||||
expect(second).toBe('AUTH_006');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
// 다른 코드 · 다른 직원은 그대로 확인한다
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '654321', accepted),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
gate.resolve({ kind: 'accepted', value: 'first' });
|
||||
await expect(first).resolves.toBe('first');
|
||||
expect(guard.isPending(ARA, '123456')).toBe(false);
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(true);
|
||||
});
|
||||
|
||||
it('받으면 일이 끝나기 전에(돌려주는 그 자리에서) 기억한다 — 뒤의 일(세션 저장 등)이 걸려 있어도 같은 코드는 막힌다', async () => {
|
||||
await guard.check({ email: ARA, ip: IP }, '123456', accepted);
|
||||
// 부른 쪽이 아직 세션을 만드는 중이라고 해도 같은 코드는 이미 쓴 코드다
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', accepted)),
|
||||
).toBe('AUTH_006');
|
||||
});
|
||||
|
||||
it('앞 요청이 받지 않았거나 던졌으면 차지를 풀어 같은 코드로 다시 확인할 수 있다', async () => {
|
||||
const rejectedGate = deferred<{ kind: 'rejected' }>();
|
||||
const first = guard.check(
|
||||
{ email: ARA, ip: IP },
|
||||
'123456',
|
||||
() => rejectedGate.promise,
|
||||
);
|
||||
rejectedGate.resolve({ kind: 'rejected' });
|
||||
expect(await codeOf(first)).toBe('AUTH_006');
|
||||
expect(guard.isPending(ARA, '123456')).toBe(false);
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(false);
|
||||
|
||||
const down = deferred<never>();
|
||||
const second = guard.check(
|
||||
{ email: ARA, ip: IP },
|
||||
'123456',
|
||||
() => down.promise,
|
||||
);
|
||||
down.reject(new BusinessException('MAIL_001'));
|
||||
expect(await codeOf(second)).toBe('MAIL_001');
|
||||
expect(guard.isPending(ARA, '123456')).toBe(false);
|
||||
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
it('쓰지 않고 끝남(unused) — 값을 돌려주고 세지도 기억하지도 않는다 (예: 엔진이 새 비밀번호를 거절)', async () => {
|
||||
const unused = () =>
|
||||
Promise.resolve({ kind: 'unused' as const, value: 'nothing-changed' });
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) {
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', unused),
|
||||
).resolves.toBe('nothing-changed');
|
||||
}
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(false);
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('코드가 글이 아니어도(검사를 비켜 온 null) 차지한 실패 자리를 끝낸다 — 여러 번 와도 맞는 코드가 막히지 않는다', async () => {
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) {
|
||||
expect(
|
||||
await codeOf(
|
||||
guard.check(
|
||||
{ email: ARA, ip: `198.51.100.${i}` },
|
||||
null as unknown as string,
|
||||
accepted,
|
||||
),
|
||||
),
|
||||
).toBe('NOT_BUSINESS');
|
||||
}
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('정리 작업이 지난 기억을 지운다', () => {
|
||||
guard.remember(ARA, '123456');
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(true);
|
||||
guard.sweep(now + TWO_FACTOR_CODE_REUSE_MS);
|
||||
expect(guard.isUsed(ARA, '123456', now + TWO_FACTOR_CODE_REUSE_MS)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TwoFactorLocks (같은 직원의 2단계 인증 바꾸기는 한 번에 하나)', () => {
|
||||
it('잡혀 있으면 기다리지 않고 REQ_001 — 끝나면 풀린다, 다른 직원은 따로', async () => {
|
||||
const locks = new TwoFactorLocks();
|
||||
let release!: () => void;
|
||||
const first = locks.run(
|
||||
ARA,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await codeOf(locks.run(ARA, () => Promise.resolve()))).toBe(
|
||||
'REQ_001',
|
||||
);
|
||||
await expect(
|
||||
locks.run('doyun.lee@arasystem.test', () => Promise.resolve('other')),
|
||||
).resolves.toBe('other');
|
||||
|
||||
release();
|
||||
await first;
|
||||
await expect(locks.run(ARA, () => Promise.resolve('next'))).resolves.toBe(
|
||||
'next',
|
||||
);
|
||||
});
|
||||
|
||||
it('일이 던져도 풀린다 · 푸는 함수는 몇 번 불러도 한 번만', async () => {
|
||||
const locks = new TwoFactorLocks();
|
||||
expect(
|
||||
await codeOf(
|
||||
locks.run(ARA, () => Promise.reject(new BusinessException('MAIL_001'))),
|
||||
),
|
||||
).toBe('MAIL_001');
|
||||
const release = locks.acquire(ARA);
|
||||
release();
|
||||
release();
|
||||
expect(() => locks.acquire(ARA)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TwoFactorEnrollments (기다리는 등록 — 직원마다 하나 · 10분)', () => {
|
||||
const secret = () => Buffer.alloc(20, 7);
|
||||
|
||||
it('새 등록이 앞의 것을 바꾼다 — 앞의 id 로는 찾지 못하고 그 비밀 바이트는 지운다', () => {
|
||||
const enrollments = new TwoFactorEnrollments();
|
||||
const first = { id: 'first', secret: secret(), expiresAt: 2_000 };
|
||||
enrollments.put(ARA, first);
|
||||
enrollments.put(ARA, { id: 'second', secret: secret(), expiresAt: 2_000 });
|
||||
|
||||
expect(enrollments.find(ARA, 'first', 1_000)).toBeNull();
|
||||
expect(enrollments.find(ARA, 'second', 1_000)?.id).toBe('second');
|
||||
expect(first.secret.every((byte) => byte === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('시간이 지나면 찾지 못하고 버린다 · 정리 작업도 버린다', () => {
|
||||
const enrollments = new TwoFactorEnrollments();
|
||||
enrollments.put(ARA, { id: 'e', secret: secret(), expiresAt: 2_000 });
|
||||
expect(enrollments.find(ARA, 'e', 2_000)).toBeNull();
|
||||
expect(enrollments.size).toBe(0);
|
||||
|
||||
enrollments.put(ARA, { id: 'e', secret: secret(), expiresAt: 2_000 });
|
||||
enrollments.sweep(2_000);
|
||||
expect(enrollments.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { LoginAttemptService } from '@/modules/auth/login-attempt.service';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { TWO_FACTOR_CODE_REUSE_MS } from './account-security.constants';
|
||||
|
||||
/** 지난 기억을 지우는 주기 */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 코드 확인 한 번(`run`)의 결과
|
||||
* - `accepted`: 코드를 받았다 — 쓴 코드로 기억한다(2분). 뒤의 일(세션 · 기록 저장)이 실패해도 그 코드는 쓴 것으로 둔다
|
||||
* - `rejected`: 코드 틀림 — 실패로 센다(400 AUTH_006)
|
||||
* - `unused`: 코드와 상관없이 아무것도 바뀌지 않고 끝났다(엔진이 새 비밀번호를 거절하는 등) — 세지도 기억하지도 않는다
|
||||
*/
|
||||
export type CodeCheck<T> =
|
||||
| { readonly kind: 'accepted'; readonly value: T }
|
||||
| { readonly kind: 'rejected' }
|
||||
| { readonly kind: 'unused'; readonly value: T };
|
||||
|
||||
/** 코드를 확인하는 사람 — 정규화한 주소 · 클라이언트 IP */
|
||||
export interface CodeSource {
|
||||
readonly email: string;
|
||||
readonly ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드의 한도와 다시 쓰기 막기 (계약 5절 "한도 · 규칙") — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기가 함께 쓴다.
|
||||
*
|
||||
* - 코드 실패: 같은 주소 15분 5회 · 24시간 20회, 같은 IP 는 로그인 실패 한도에 함께 센다 (LoginAttemptService.beginCode) — 넘으면 REQ_001.
|
||||
* 확인하기 **전에** 실패 한 번을 차지하고, 맞으면 돌려준다 (로그인 실패 한도와 같은 결 — 동시 요청이 한도를 넘지 못하게)
|
||||
* - 다시 쓴 코드: 같은 직원이 성공에 쓴 코드는 2분 동안 다시 받지 않는다 → 400 AUTH_006 (코드 실패로 센다).
|
||||
* 엔진은 같은 코드를 다시 받으므로 서버가 막는다. 확인을 시작할 때 (주소, 코드)를 "확인 중"으로 차지하고(확인과 차지 사이에 await 가 없다)
|
||||
* 같은 코드가 겹치면 AUTH_006 이다 — 동시에 보낸 두 요청이 둘 다 지나가지 못하게 (2026-10-31 리뷰: 로그인 두 번이 모두 signedIn 이었다).
|
||||
* 받으면 그 자리에서 기억으로 바꾸고(뒤의 일이 실패해도), 받지 않았거나 던졌거나 쓰지 않고 끝났으면 차지를 푼다
|
||||
* - 코드 · 기억의 키에 원문을 두지 않는다 (주소 + 코드의 SHA-256). 코드는 로그 · 오류 context 어디에도 남기지 않는다
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다 (nestjs 스킬 "확장 때 옮길 것").
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorCodeGuard {
|
||||
/** 키(주소 + 코드의 해시) → 다시 받는 때(epoch ms) */
|
||||
private readonly usedCodes = new Map<string, number>();
|
||||
/** 지금 확인 중인 키 — 같은 코드의 동시 요청을 막는다 (확인이 끝나면 기억으로 바뀌거나 지워진다) */
|
||||
private readonly pendingCodes = new Set<string>();
|
||||
|
||||
constructor(private readonly attempts: LoginAttemptService) {}
|
||||
|
||||
/**
|
||||
* 코드 한 번을 확인한다 — 한도(REQ_001) → 다시 쓴 코드 · 확인 중인 같은 코드(AUTH_006) → `run`(엔진 · 등록의 비밀로 실제 확인).
|
||||
* 여기까지(실패 자리 차지 · 다시 쓴 코드 확인 · 확인 중 차지)는 await 없이 한 번에 일어난다.
|
||||
* - `accepted`: 차지한 자리를 돌려주고 코드를 기억한 뒤 값을 돌려준다
|
||||
* - `rejected`: 실패로 남기고 400 AUTH_006
|
||||
* - `unused`: 자리를 돌려주고(세지 않음) 기억하지 않고 값을 돌려준다
|
||||
* - `run` 이 던지면(엔진 장애 · 지금 비밀번호 틀림 등) 자리를 돌려주고 그대로 던진다 — 코드는 쓰지 않은 것으로 둔다
|
||||
*/
|
||||
async check<T>(
|
||||
source: CodeSource,
|
||||
code: string,
|
||||
run: () => Promise<CodeCheck<T>>,
|
||||
): Promise<T> {
|
||||
const ticket = this.attempts.beginCode(source);
|
||||
let key: string;
|
||||
try {
|
||||
key = codeKey(source.email, code);
|
||||
if (this.isUsedKey(key) || this.pendingCodes.has(key)) {
|
||||
ticket.fail();
|
||||
throw this.rejected(source.email, 'two-factor-code-reused');
|
||||
}
|
||||
} catch (error) {
|
||||
// 차지한 실패 자리를 반드시 끝낸다 (이미 fail 했으면 두 번째 부름은 무시된다) — 코드가 글이 아닌 경우 등
|
||||
ticket.abort();
|
||||
throw error;
|
||||
}
|
||||
this.pendingCodes.add(key);
|
||||
|
||||
let result: CodeCheck<T>;
|
||||
try {
|
||||
result = await run();
|
||||
} catch (error) {
|
||||
this.pendingCodes.delete(key);
|
||||
ticket.abort();
|
||||
throw error;
|
||||
}
|
||||
this.pendingCodes.delete(key);
|
||||
switch (result.kind) {
|
||||
case 'accepted':
|
||||
this.rememberKey(key);
|
||||
ticket.succeed();
|
||||
return result.value;
|
||||
case 'unused':
|
||||
ticket.abort();
|
||||
return result.value;
|
||||
case 'rejected':
|
||||
ticket.fail();
|
||||
throw this.rejected(source.email, 'two-factor-code-rejected');
|
||||
}
|
||||
}
|
||||
|
||||
/** 성공에 쓴 코드를 기억한다 (check 가 받은 코드를 스스로 기억한다 — 테스트 · 바깥에서 미리 막을 때) */
|
||||
remember(email: string, code: string, now: number = Date.now()): void {
|
||||
this.rememberKey(codeKey(email, code), now);
|
||||
}
|
||||
|
||||
/** 2분 안에 성공에 쓴 코드인가 */
|
||||
isUsed(email: string, code: string, now: number = Date.now()): boolean {
|
||||
return this.isUsedKey(codeKey(email, code), now);
|
||||
}
|
||||
|
||||
/** 지금 확인 중인 코드인가 (테스트) */
|
||||
isPending(email: string, code: string): boolean {
|
||||
return this.pendingCodes.has(codeKey(email, code));
|
||||
}
|
||||
|
||||
private rememberKey(key: string, now: number = Date.now()): void {
|
||||
// 다시 넣으면 맨 뒤로 (sweep 이 앞에서부터 본다)
|
||||
this.usedCodes.delete(key);
|
||||
this.usedCodes.set(key, now + TWO_FACTOR_CODE_REUSE_MS);
|
||||
}
|
||||
|
||||
private isUsedKey(key: string, now: number = Date.now()): boolean {
|
||||
const until = this.usedCodes.get(key);
|
||||
if (until === undefined) return false;
|
||||
if (until <= now) {
|
||||
this.usedCodes.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 지난 기억을 지운다 — 넣은 차례 = 끝나는 차례(기억 시간이 모두 같다) */
|
||||
@Interval(SWEEP_INTERVAL_MS)
|
||||
sweep(now: number = Date.now()): void {
|
||||
for (const [key, until] of this.usedCodes) {
|
||||
if (until > now) break;
|
||||
this.usedCodes.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private rejected(email: string, reason: string): BusinessException {
|
||||
return new BusinessException('AUTH_006', {
|
||||
context: { reason, email: maskEmail(email) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function codeKey(email: string, code: string): string {
|
||||
return createHash('sha256')
|
||||
.update(email)
|
||||
.update('\0')
|
||||
.update(code)
|
||||
.digest('base64url');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable, PipeTransform } from '@nestjs/common';
|
||||
import { isUUID } from 'class-validator';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/**
|
||||
* 경로의 2단계 인증 등록 id (16단계 — `/api/two-factor/enrollments/{id}/verification`) — UUID 가 아니면 400 VAL_001.
|
||||
* 켜기 · 바꾸기 본문의 enrollmentId(IsUUID)와 같은 규칙이다 (모양은 맞는데 없는 등록은 서비스가 409 AUTH_008)
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorEnrollmentIdPipe implements PipeTransform<
|
||||
unknown,
|
||||
string
|
||||
> {
|
||||
transform(value: unknown): string {
|
||||
if (typeof value === 'string' && isUUID(value, 'all')) return value;
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'malformed-two-factor-enrollment-id' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
|
||||
import { TWO_FACTOR_ENROLLMENT_TTL_MS } from './account-security.constants';
|
||||
|
||||
/** 지난 등록을 지우는 주기 */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/** 기다리는 등록 하나 — 비밀은 켤 때까지 여기에만 있다 */
|
||||
export interface TwoFactorEnrollment {
|
||||
readonly id: string;
|
||||
readonly secret: Buffer;
|
||||
/** 끝나는 때 (epoch ms) */
|
||||
readonly expiresAt: number;
|
||||
/** 새 휴대폰의 코드로 확인했다 — 휴대폰 바꾸기(16단계)는 확인한 등록으로만 한다 */
|
||||
readonly verified?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기다리는 2단계 인증 등록 (계약 5절 "2단계 인증") — 직원마다 하나, 10분.
|
||||
* 새로 시작하면 앞의 것을 버린다. 켜거나 휴대폰을 바꾸면(또는 시간이 지나면) 지운다 — 그 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
|
||||
* 켜기와 휴대폰 바꾸기(16단계 — 켜져 있을 때의 등록)가 같은 자리를 쓴다.
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기 · 바꾸기가 409 AUTH_008 이다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorEnrollments {
|
||||
private readonly pending = new Map<string, TwoFactorEnrollment>();
|
||||
|
||||
/** 등록을 둔다 — 앞의 등록은 버린다 */
|
||||
put(email: string, enrollment: TwoFactorEnrollment): void {
|
||||
this.forget(email);
|
||||
this.pending.set(email, enrollment);
|
||||
}
|
||||
|
||||
/** 그 id 의 기다리는 등록 — 없거나 · 시간이 지났거나 · 더 새 등록으로 바뀌었으면 null */
|
||||
find(
|
||||
email: string,
|
||||
id: string,
|
||||
now: number = Date.now(),
|
||||
): TwoFactorEnrollment | null {
|
||||
const enrollment = this.pending.get(email);
|
||||
if (!enrollment) return null;
|
||||
if (enrollment.expiresAt <= now) {
|
||||
this.forget(email);
|
||||
return null;
|
||||
}
|
||||
return enrollment.id === id ? enrollment : null;
|
||||
}
|
||||
|
||||
/** 그 id 의 등록에 "확인됨"을 단다 (새 휴대폰의 코드가 맞았다) — 등록이 없거나 지났거나 바뀌었으면 false */
|
||||
markVerified(email: string, id: string, now: number = Date.now()): boolean {
|
||||
const enrollment = this.find(email, id, now);
|
||||
if (!enrollment) return false;
|
||||
this.pending.set(email, { ...enrollment, verified: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 등록을 버린다 — 비밀 바이트도 지운다 */
|
||||
forget(email: string): void {
|
||||
const enrollment = this.pending.get(email);
|
||||
if (!enrollment) return;
|
||||
enrollment.secret.fill(0);
|
||||
this.pending.delete(email);
|
||||
}
|
||||
|
||||
@Interval(SWEEP_INTERVAL_MS)
|
||||
sweep(now: number = Date.now()): void {
|
||||
for (const [email, enrollment] of this.pending) {
|
||||
if (enrollment.expiresAt <= now) this.forget(email);
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
}
|
||||
|
||||
/** 등록이 끝나는 때 — 지금부터 10분 */
|
||||
export function enrollmentExpiry(now: number): number {
|
||||
return now + TWO_FACTOR_ENROLLMENT_TTL_MS;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user