docs(rules): 11단계 부품 — 공개 링크 보안 · 대용량 업로드 예외 · 스킬 표 · env
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m57s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m47s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 12m52s
CI / Infra (compose · Dockerfile · nginx) (pull_request) Successful in 1m53s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m28s
CI (Flutter) / Flutter (analyze · test) (push) Has been cancelled
CI / Backend (lint · type-check · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test · build) (push) Has been cancelled
CI / Infra (compose · Dockerfile · nginx) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled

- security: 대용량 첨부는 확장자 허용 목록만 보는 예외와 그 세 겹, 공개 링크(토큰 해시 · 공개 호스트 분리 · 조건부 UPDATE ·
  경로마다 전체 상한 · 앞단과 백엔드가 경로를 같게 읽기 — 대소문자 두 겹).
- nestjs: 파일 저장 · 조각 올리기 / 공개 링크 · 내려받기 행, 라우팅은 대소문자를 가린다(createHttpAdapter), 경로 숫자 함정,
  확장 때 옮길 것(ChunkGate · 검사 차례 · cron · FILE_BLOB_STORE).
- vue3: 대용량 첨부 행. docker: 두 server · 스니펫 · 받기 location · limit_conn · 머리글 버퍼 · ClamAV 절 · 이미지 표 · 볼륨 · CI ·
  Git Bash 경로 변환 함정. env-structure · checklist: SHARE_PUBLIC_URL(포털과 다른 호스트 — CORS_ORIGIN 에 넣지 않음) · CLAMAV_HOST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-10-31 00:38:42 +09:00
parent 3461c361f8
commit 8f0a6da83c
6 changed files with 135 additions and 12 deletions
+11 -2
View File
@@ -22,7 +22,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]). `configureApp()` **맨 앞**의 express 미들웨어라 본문 파서 오류(413·400·415)에도 붙는다. 들어온 값은 `^[A-Za-z0-9._-]{1,128}$` 일 때만 이어받고 아니면 새로 발급(로그 위조 방지) |
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
| 전역 HTTP 설정 | `src/app.setup.ts` 의 `configureApp()` | prefix · helmet · cookie-parser · JSON 본문 파서(1mb — 발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4mb(`SEND_MESSAGE_JSON_BODY_LIMIT`, 앞단 nginx 와 같은 값), JSON 만 · **UTF-8 만**. `express.json` 두 개를 직접 붙인다 — 읽기 전 확인 `verify` 를 쓰려고(Nest 의 `useBodyParser` 는 verify 를 받지 않는다)) · 모양 한도 `rejectOversizedJsonBody`(아래 행) · ValidationPipe(`createValidationPipe()` — `whitelist/forbidNonWhitelisted/transform/stopAtFirstError`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성) |
| 전역 HTTP 설정 | `src/app.setup.ts` 의 `configureApp()` | prefix · helmet · cookie-parser · JSON 본문 파서(1mb — 발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4mb(`SEND_MESSAGE_JSON_BODY_LIMIT`, 앞단 nginx 와 같은 값), JSON 만 · **UTF-8 만**. `express.json` 두 개를 직접 붙인다 — 읽기 전 확인 `verify` 를 쓰려고(Nest 의 `useBodyParser` 는 verify 를 받지 않는다)) · 모양 한도 `rejectOversizedJsonBody`(아래 행) · ValidationPipe(`createValidationPipe()` — `whitelist/forbidNonWhitelisted/transform/stopAtFirstError`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성). **라우팅은 대소문자를 가린다** — 앱은 `createHttpAdapter()`(Express 인스턴스에 `case sensitive routing` 을 먼저 켠 ExpressAdapter — main.ts · e2e 공용)로 만든다. Nest 의 ExpressAdapter 가 생성자에서 `app.use` 를 불러 Express 5 의 라우터를 그때 만들므로 `configureApp()` 에서 켜면 늦다 — `configureApp()` 은 라우터가 대소문자를 가리지 않으면 기동을 멈춘다(2026-10-30 — `/FILES/` 같은 변형이 앞단 nginx 의 받기 상한을 비켜 갔다) |
| **JSON 본문 모양 한도** | `src/shared/utils/json-shape.ts` + `app.setup.ts` 의 `JSON_SHAPE_LIMITS` | 배열 하나 1,000 · 값(객체 · 배열 · 원시) 10,000 · 깊이 32. 읽기 전(UTF-8 바이트를 한 번 훑는다 — `verifyJsonBody`, 재귀 없이 넘는 순간 멈춘다)과 읽은 뒤(`rejectOversizedJsonBody`) 두 번 보고, 넘으면 DTO 변환 · 검사 전에 400 `VAL_001`(본문 파서는 가드보다 앞 — 로그인 전 요청도 400). DTO 의 개수 한도로는 막지 못해 둔 것이다(2026-10-28 리뷰 — 1MB 에 작은 객체 8만 개로 7.8초 · +329MB, 깊은 중첩은 500). 새 계약의 본문이 한도를 넘으면 여기부터 올린다 ([[security]]) |
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. 인증은 쿠키(`addCookieAuth('access_token')`)만 등록 — Authorization 헤더 인증은 없다 |
| 보안 미들웨어 | `app.setup.ts`(helmet · `TRUST_PROXY`) · `main.ts`(CORS 화이트리스트 — 운영은 `CORS_ORIGIN` 만) | 클라이언트 IP 는 `TRUST_PROXY`(홉 수 또는 IP·CIDR 목록, 운영 필수) 기준 — `shared/utils/trust-proxy.ts` |
@@ -39,6 +39,8 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| 보내기 취소 · 예약 발송 (9단계) | `modules/mail/jmap/pending-send.ts` · `jmap/envelope.ts` · `scheduled-message.controller.ts` · `scheduled-messages.service.ts` | 기다리는 곳은 엔진이다 — 제출의 FUTURERELEASE(`HOLDFOR` · `HOLDUNTIL`, 엔진 한도 7일), 취소는 `undoStatus: canceled` 뒤 그 메일을 임시보관함으로(`$draft` + `$ara-canceled`, 다시 취소해도 200). `pending-send.ts` 는 붙잡기 매개변수 · 기다리는 제출 찾기(`EmailSubmission/query` 의 `filter.after: 지금` — `undoStatus` 조건은 모든 제출을 훑는다, 정렬 이름 `sentAt`) · `pendingSend` 판정(pending 이면서 보낼 시각이 아직)이다. **발송은 늘 envelope 을 직접 준다**(`envelope.ts`) — 엔진이 받지 않는 주소(모르는 최상위 도메인 등)가 있으면 제출 전체를 거절하고 첫 주소 하나만 알려 주므로, 어댑터가 그 주소를 not-found 로 빼고 같은 메일을 다시 제출한다(한 차례에 하나 — 거절 글의 따옴표 토큰과 정확히 같은 주소부터, 20차례까지 · 발송 55초 안). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 한 통 · 여러 통 `PATCH`(옮기기 · 스팸) · `DELETE` · 영구 삭제 · 메일함 지우기 · 비우기 · 필터 적용이 엔진에 먼저 묻고(`pending-send.ts` 의 `readHeldEmails` — 읽지 못하거나 다 받았는지 응답의 total 로 확인하지 못하면 바꾸지 않고 `MAIL_001`, 닫힌 쪽으로), 막으면 `MAIL_015` · `failedIds` · `skippedIds`. 예약 표시 키워드 `$ara-scheduled` — 계약 2절 "보내기 취소 · 예약 발송" · "받는 사람별 결과" |
| 외부 이미지 중계 (9단계) | `modules/mail/images/` | `GET /api/mail-images/{token}`. 표 `mail-image-token.ts`(AES-256-GCM — 직원 · 원래 주소 · 1시간, 키는 프로세스가 뜰 때 무작위. 표 모양은 **짝: frontend `shared/utils/mail-html.ts` 의 `PROXIED_IMAGE_PATTERN`**), 주소 검사 `mail-image-url.ts` + `shared/utils/public-address.ts`(모든 주소가 공인일 때만 · 점 없는 이름은 묻지 않고 막는다), 받기 `mail-image-fetcher.ts`(다시 가기 3번 · 10초 · 5MB — Content-Length 가 있으면 그 크기로 한 번 잡는다), 연결 `node-image-transport.ts`(푼 주소로 고정 연결 · Host 는 원래 이름 · 쿠키 · Referer 없음, 이름 풀기는 c-ares `dns.Resolver` — 80 · 443 고정, 다른 포트 · DNS 서버는 테스트의 `forTesting()` 으로만), 형식 `mail-image-format.ts`(앞 바이트로 PNG · JPEG · GIF · WebP), 차례 `mail-image-proxy.service.ts`(서버 16 · 직원 6, 30초 · 200개까지 기다림 — 줄은 직원마다이고 받는 수가 가장 적은 직원부터 든다, 자리는 응답이 끝날 때까지 `releaseWhenDone`), `mail-image.controller.ts`(실패는 모두 404 를 직접 쓴다 — debug 로그만, IP 마다 1분 600회). 본문의 `<img src>` 를 중계 주소로 바꾸는 것은 `html/sanitize-mail-html.ts`(`externalImageSrc`) — 계약 2절 "외부 이미지 보기" |
| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `modules/mail/mail-block.controller.ts` · `mail-block.service.ts` · `mail-blocks.ts` · `modules/mail-user-settings` | 차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞(`jmap/sieve-script.ts`, 원본 판 2 는 차단이 있을 때만 — `jmap/filter-source.ts`). 검사 `mail-blocks.ts`(`@` 앞은 dot-atom · 64자, 여러 사람이 쓰는 메일 서비스 30개는 `mail.constants.ts` — **짝: frontend `shared/constants/mail-block.ts`**, 같은 글자 · 같은 차례 — 한쪽을 고치면 둘 다). 이미 있는 주소의 `POST` 는 꺼진 규칙을 켠다. 설정은 DB(표 `mail_settings` — 마이그레이션 `AddMailSettings`, 직원 주소마다 한 줄 · 없으면 처음 값), `MailUserSettingsService` 가 프로세스마다 60초 기억하고 DB 장애면 처음 값(10초 · `ask`)으로 — 메일 모듈이 발송(`undoSendSeconds`) · 읽기(`externalImages`) 때 읽는다 — 계약 2절 "보낸 사람 차단" · "보내기 · 읽기 설정" |
| 파일 저장 · 조각 올리기 (11단계) | `modules/file-storage` | `/api/file-uploads`(만들기 · 16MiB 조각 `PUT` · 끝내기 · 상태 · 지우기) · `/api/file-quota`. 조각은 `Content-Digest`(SHA-256)를 보고 `datasync` 뒤에 적는다, 같은 조각은 `ChunkGate`(프로세스 메모리)가 하나만 들인다, 조각 번호는 `file-id.pipe.ts`(매개변수 타입 `unknown` — 아래 점검 "경로 숫자"). 저장 포트 `FILE_BLOB_STORE` — 지금은 로컬 디스크 어댑터(named volume `/app/files`, 키는 서버가 만든 UUID 만 — `assertKey`). 보관량(직원 · 서버 총량 · 디스크 여유)은 만들기를 `pg_advisory_xact_lock` 안에서 센다(인스턴스를 늘려도 맞다). 끝내기는 `completion_started_at` · 상태 전이 결과를 본다. 악성 검사 `clamav-client.ts`(INSTREAM 직접 구현 — 판정 글은 `CLAMAV_REPLY` · `classifyClamavReply` 한 곳: 암호 · 압축 안 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error) · 검사 차례 `file-scan.service.ts`(DB pending · 동시 2) · 정리 cron `file-storage.cleanup.ts`. 확장자 허용 목록만 본다 — [[security]] "파일 업로드"의 예외. 계약 4절 |
| 공개 링크 · 내려받기 (11단계) | `modules/file-sharing` · `modules/mail/mail-large-files.service.ts` · `large-file-block.ts` · `share-link.controller.ts` | 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 정화 · 지문 · 중복 확인 뒤 본문에 덩이를 넣는다(`large-file-block.ts` — 표시 `<!--ara-large-files:v1-->` · `data-ara-large-files` 는 서버만 넣고 두 정화기가 사용자 HTML 에서 지운다, 경계 `bodyQuoteStart`). 링크는 "확실한 실패"(`SendNotStartedException` — 형식으로 가른다)에서만 거둔다. 임시 저장은 머리글 `X-Ara-Large-Files`(보내는 메일에는 없다). 공개 경로 `/api/public-links/{token}`(로그인 없음) — 공개 호스트 미들웨어 `public-host.ts`(공개 호스트에서는 이 경로만 · 포털에서는 닫음, Host 를 스스로 정규화. 경로는 원문 · 라우터의 `req.path` · WHATWG URL · 퍼센트를 푼 원문 네 모양을 모두 본다 — 공개 호스트는 모두 공개 경로일 때만, 포털은 하나라도 공개 경로면 닫고, 읽지 못한 모양이 있으면 둘 다 닫는다. 라우터가 읽는 모양을 빼면 절대 주소 꼴 · `\` 요청이 막기를 지난다 — 2026-10-30), 파일 응답 `serveFile`(octet-stream · attachment · nosniff · CSP sandbox · no-store · Range `shared/utils/http-range.ts`), 받은 수는 조건부 UPDATE 한 문장, 종료 때 진행 중 응답을 `BeforeApplicationShutdown` 에서 끊는다. 읽기 화면의 `largeFiles` 는 표시 · 우리 출처 토큰 · 링크 주인 == From 이 모두 맞을 때만. 기간은 `shared/utils/kst-date.ts`(보낸 날부터 30일째 23:59:59 KST). 계약 4절 |
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
@@ -244,7 +246,10 @@ SwaggerModule.setup('api-docs', app, document);
> 주소록의 되돌리기 표(`contact-restore-store.ts` — 그대로면 다른 인스턴스로 간 [되돌리기]가 404 `RES_001` 이다 → 공유 저장소에 5분짜리 항목으로),
> 외부 이미지 표의 암호 키(`mail-image-token.ts` — 프로세스가 뜰 때 무작위라 그대로면 다른 인스턴스로 간 이미지 요청이 모두 404 → 공유 비밀(env)로),
> 외부 이미지의 동시 수(`mail-image-proxy.service.ts` — 서버 16 · 직원 6 을 인스턴스마다 따로 센다. 틀리지는 않고 바깥으로 나가는 수 · 메모리가 인스턴스 수만큼 는다 → 필요하면 공유 셈),
> 보내기 · 읽기 설정의 60초 기억(`mail-user-settings.service.ts` — 다른 인스턴스에서 바꾸면 60초까지 옛 값(보내기 취소 초 · 외부 이미지)을 쓴다 → 무효화 알림이나 공유 캐시):
> 보내기 · 읽기 설정의 60초 기억(`mail-user-settings.service.ts` — 다른 인스턴스에서 바꾸면 60초까지 옛 값(보내기 취소 초 · 외부 이미지)을 쓴다 → 무효화 알림이나 공유 캐시),
> 대용량 첨부(11단계)의 같은 조각 게이트 · 조각 동시 수(`file-storage.service.ts` 의 `ChunkGate` — 그대로면 두 인스턴스가 같은 조각을 함께 쓴다 → 잠금), 검사 차례(`file-scan.service.ts` — 행 잠금이 없어
> 두 인스턴스가 같은 파일을 두 번 검사한다 → `FOR UPDATE SKIP LOCKED`), 정리 cron 둘(인스턴스마다 돈다), 파일 바이트(로컬 볼륨 → 같은 저장 포트 `FILE_BLOB_STORE` 의 공유 저장 어댑터(S3 호환)) —
> 만들기 잠금(`pg_advisory_xact_lock`) · 받은 수 셈(조건부 UPDATE)은 DB 라 그대로 맞다:
> 0. 컨테이너 활성화 — 루트 `.env` 에 `COMPOSE_PROFILES=redis` ([[docker]])
> 1. Redis store 패키지 설치 (**컨테이너 안에서** — 위 의존성 주의 항목 참조)
> 2. `app.module.ts` 의 `CacheModule.register` 를 `registerAsync` + Redis store 로 교체
@@ -367,6 +372,8 @@ dc exec backend npm uninstall <패키지>
(`modules/contacts/dto/contact.dto.ts`). 길이 · 개수가 비싼 검사(정규식 · `IsEmail`)보다 먼저 돌아 `stopAtFirstError` 가 긴 입력을 거기서 멈춘다.
정규식은 입력 길이에 비례하게 쓴다 — 같은 글자를 나눠 가질 수 있는 `*` 둘(`^[a]*[0-9][a]*$` 꼴)은 제곱이다(2026-10-29 리뷰: 전화번호 정규식이 1MB 본문 하나로 24분).
차례 · 비용을 보는 테스트는 `stopAtFirstError` 를 빼지 않고 전역 옵션 그대로(`createValidationPipe()`) 돌린다.
**경로 숫자**: 매개변수 타입을 `number` 로 적으면 전역 ValidationPipe(`transform`)가 custom 파이프보다 먼저 `+value` 로 바꿔 `1e1`·`0x1`·`+3`·`007`·`1.0` 이 숫자로 들어온다
(2026-10-30 — 조각 번호가 늘 400 이었다가, 고친 뒤에는 느슨하게 받았다). 엄격히 보려면 타입을 `unknown` 으로 두고 파이프에서 글자로 본다(`modules/file-storage/file-id.pipe.ts`, 문서는 `@ApiParam`).
- [ ] **Swagger**: 새 엔드포인트에 `@ApiTags`/`@ApiOperation` + `@ApiStandardResponse`/`@ApiErrorResponse`.
- [ ] **경로 alias**: `@/` 사용 (`tsconfig.json` paths + jest `moduleNameMapper` 설정 완료). 깊은 상대경로 금지.
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run test` 통과. 자동 수정은 `npm run lint:fix`.
@@ -408,4 +415,6 @@ dc exec backend npm uninstall <패키지>
**붙잡힌 메일**(9단계 — 보내기 취소 · 예약을 기다리는 보낸 사본)을 옮기거나 지우는 새 경로를 만들면 `pending-send.ts` 의 `readHeldEmails` 로 먼저 묻고 막는다
(`MAIL_015` · `failedIds` · `skippedIds` — 읽지 못하면 바꾸지 않고 `MAIL_001`). 엔진은 옮기거나 지운 메일도 시간이 되면 보낸다.
**발송 제출은 envelope 을 직접 준다**(`jmap/envelope.ts`) — 주지 않으면 엔진이 받지 않는 주소(모르는 최상위 도메인 등)를 아무 말 없이 빼고 나머지에게만 보낸다(2026-10-29 확인).
**대용량 첨부의 표시**(11단계 — `data-ara-large-files` · `<!--ara-large-files…-->` · `data-ara-quote-start`)는 서버만 넣는다 — 두 정화기가 사용자 HTML 의 `data-ara-*` · 주석을 지우는지
정화를 고칠 때 함께 본다(지우지 못하면 남이 꾸민 덩이가 첨부처럼 보인다). 보내기에 실패해도 링크는 "확실한 실패"(`SendNotStartedException`)에서만 거둔다 — 결과를 모르면 남긴다(메일이 나갔을 수 있다).
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).