generated from ttipo/project-base
docs: 11단계 대용량 첨부 — 계약 4절 · 디자인 보드 다섯 · README #7
@@ -12,6 +12,8 @@
|
||||
운영은 `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]] "외부 리버스 프록시 요구사항")
|
||||
- [ ] `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 경고를 낸다**
|
||||
|
||||
@@ -17,6 +17,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .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 — 모양이 틀리면 기동 중단)
|
||||
├─▶ 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 사용)
|
||||
@@ -27,6 +28,10 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
├─▶ 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_DIRECTORY_LOCAL_PART)
|
||||
│ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포")
|
||||
@@ -70,9 +75,12 @@ 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`(악성 검사기 — 비면 검사 없이 받을 수 있게 되므로)
|
||||
- 운영 환경: **`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 에 주입한다)
|
||||
@@ -81,6 +89,8 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
|
||||
> 루트 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`, `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_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 실패)·
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
|
||||
- **확장자와 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 + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
|
||||
@@ -63,6 +66,19 @@
|
||||
> 이름이 `.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 설정").
|
||||
|
||||
## 네트워크 · 인프라
|
||||
|
||||
|
||||
@@ -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,12 +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-dns)─▶ mail-resolver ─(mail-dns-egress)─▶ 바깥 DNS
|
||||
├─▶ mail :8080 (JMAP·관리 API)
|
||||
│ └─(mail-dns)─▶ mail-resolver ─(mail-dns-egress)─▶ 바깥 DNS
|
||||
└─(clamav)─▶ clamav :3310 (악성 검사 — 대용량 첨부)
|
||||
▲ 서명 볼륨(읽기 전용)
|
||||
clamav-updater ─(clamav-egress)─▶ 서명 서버
|
||||
```
|
||||
|
||||
| 네트워크 | 붙는 컨테이너 | 비고 |
|
||||
@@ -60,6 +66,8 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
| `<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` 로만 들어온다.
|
||||
@@ -73,15 +81,39 @@ 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`·`/api/mail-events` 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` 다.
|
||||
- 공개 경로 · 오류 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 은 백엔드가 만든다.
|
||||
머리글 버퍼는 `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 리뷰).
|
||||
넘으면 429 `REQ_001`(JSON). 클라이언트 주소는 `X-Forwarded-For` 의 **마지막 칸**(외부 프록시가 이어 붙인 값 — 없으면 접속 주소)이다 — 앞 칸을 꾸며도 바뀌지 않는다.
|
||||
8080 에 프록시를 건너뛰고 바로 붙는 길이 있으면 주소마다 한도는 피한다(경로마다 서버 전체 한도는 그대로) — 그 길을 막는 것이 ACG · proxy 네트워크다. IPv6 는 /64 로 묶지 않는다.
|
||||
⚠️ **받기 location 은 `/api/` 블록 바로 안의 대소문자를 가리지 않는 정규식 하나씩이다**(공개 `~* ^/api/public-links/.+/files/`, 앱 안 `~* ^/api/messages/.+/large-files/`) —
|
||||
백엔드 라우팅이 대소문자를 가리지 않던 때 `/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).
|
||||
@@ -97,11 +129,15 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
실패(401·502 등)는 남긴다 (server 단계 `access_log … if=$log_access` 하나 — `/api/` 블록을 나누지 않는다).
|
||||
실시간 알림 `/api/mail-events` 는 성공도 남긴다 — 연결이 끝날 때 한 줄(브라우저마다 대략 1시간에 한 번)이고 `rt=` 가 이어진 시간이다.
|
||||
시작된 스트림은 어떻게 끝나도 200 이라, 끊김은 짧은 `rt=` 와 오류 로그의 `upstream prematurely closed connection` 으로 본다.
|
||||
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다.
|
||||
**대용량 첨부의 내려받기 토큰도 가린다**(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/health` 는 바깥에서 404 다** (`RES_001` JSON — 없는 경로와 같다). 실패하면 DB 오류 문자열 등 원인이 담기기 때문이다
|
||||
(대소문자·끝 `/` 도 막는다). 내부에서는 백엔드에 직접 묻는다:
|
||||
@@ -114,9 +150,12 @@ 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 를 하지 않고 호스트 포트도 열지 않는다 — **예외: 프록시가 다른 서버에 있을 때**(NCP 등 — 같은 VPC 의 Nginx Proxy Manager)는
|
||||
선택 파일 `docker-compose.remote-proxy.yml` 이 frontend 를 proxy 네트워크에서 빼고 8080 을 이 서버의 **사설 IP 에만**(`FRONTEND_PRIVATE_BIND`) 게시한다.
|
||||
@@ -153,12 +192,25 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- **접근 로그에 쿼리를 남기지 않는다** (운영 확인 항목). 화면 주소(`/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`)를 쓰지 않는 이유 —
|
||||
`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`.
|
||||
- **대용량 첨부 파일은 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 가 다시 받는다.
|
||||
|
||||
---
|
||||
|
||||
@@ -266,6 +318,10 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
|
||||
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` 을 타는지 실제 요청으로 본다.
|
||||
ClamAV 설정은 compose 의 이미지로 `clamconf` 를 돌려 한도 · 경보 값을 본다 — `clamconf` 는 틀린 항목에도 종료 코드 0 이라 출력의 `ERROR` 를 찾고,
|
||||
`-n`(기본값과 같은 항목을 숨김) 없이 한 번 더 돌려 기본값에 기대는 `SelfCheck = "600"` · `HeuristicScanPrecedence disabled` 를 줄 그대로 확인한다. 이미지 줄은 끝까지 읽는 awk 로 고른다(위 pipefail 규칙).
|
||||
|
||||
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
|
||||
|
||||
@@ -360,6 +416,30 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
|
||||
---
|
||||
|
||||
## 대용량 첨부 — 악성 검사기 (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 로.
|
||||
|
||||
---
|
||||
|
||||
## 규칙
|
||||
|
||||
- **이미지 버전 고정 — 떠 있는 태그(`stable-alpine`·`v0.16`·`24-alpine`)를 쓰지 않는다.** 그날의 최신이 들어오면 호스트마다 버전이 달라지고,
|
||||
@@ -372,6 +452,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
| 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 처음 설치·재실행과 웹메일을 다시 확인한다. 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 의 이미지를 읽어 쓴다) |
|
||||
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
|
||||
|
||||
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
|
||||
@@ -474,6 +555,10 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
- **Mailpit**: 이미지 기본이 root 라 `user: "65534:65534"` 로 내린다. dev 전용(운영 compose 에 없다).
|
||||
- **메일 리졸버**: 이미지가 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 가 실패하니 쓰지 않을 때는 주석으로 둔다).
|
||||
|
||||
@@ -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` 로 로드).
|
||||
|
||||
@@ -63,6 +63,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 보내기 취소 · 예약 발송 (9단계) | `src/components/mail/ComposeFooter.vue`([보내기 ▾] — 메뉴 자리는 `composables/usePopupPlacement.ts`, 메일함 ⋯ 과 공용) · `ComposeScheduleDialog.vue` · `shared/utils/schedule-send.ts`(고르기 · 한도 · `rejectedScheduleProblem`) · `shared/utils/pending-send.ts`(`isHeldMessage` · `isScheduledMessage` — 응답의 `pendingSend` 는 이것으로만 읽는다) · `stores/mail/scheduled.ts` · `composables/useScheduledMessageActions.ts` · `usePendingSendRefresh.ts` · `components/mail/MailboxScheduledRow.vue` · `ScheduledMessageList.vue` · `ScheduledMessageItem.vue` · `MessageReaderNotices.vue` · 마감 알림 `toast.store` 의 `expiresAt` + `components/feedback/ToastCountdown.vue` | 기다리는 곳은 엔진이다(계약 2절 "보내기 취소 · 예약 발송"). 보내기 취소 알림의 마감은 설정한 초를 알면 "지금 + 초 − 1초"(설정 스토어의 `knownUndoSendSeconds` — 메일 화면에 들어올 때 `ensureLoaded`), 모르면 min(sendAt, 지금 + 30초). 마감 알림은 멈추지 않고 넘쳐도 먼저 닫히지 않는다. 알림 단추는 `toastStore.takeAction(id)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `<main>` — `data-focus-fallback`). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 고르기 칸 `MailCheckbox` 의 `unavailable` · `note`(aria-disabled + 까닭), 고르기 · 범위 · 모두 고르기 · `checkedMessages` 가 건너뛰고, 읽기 화면 [삭제]는 확인(처음 초점 「취소」) → 취소 → 휴지통(서버도 `MAIL_015` 로 막는다). 엔진이 풀어도 상태가 바뀌지 않아 `usePendingSendRefresh` 가 가장 이른 시각 + 2초(남으면 5초 × 3)에 목록 · 열린 메일 · 「예약됨」 수를 다시 받는다. 응답을 기다린 뒤 다음 메일로 옮기는 일(`useMessageRemoval` 의 `leaveRemoved(…, messageId)`)은 같은 목록 · 같은 메일을 보고 있을 때만 — 디자인 README "9단계" |
|
||||
| 외부 이미지 · 인쇄 (9단계) | `src/components/mail/MailBodyFrame.vue`(`imagesShown`) · `shared/utils/mail-html.ts`(`mailFrameCspImagesShown` · `PROXIED_IMAGE_PATTERN`) · `mailStore.showImages()` · 인쇄 `pages/mail/MailPrintPage.vue` + `composables/useMailPrint.ts` · `shared/constants/mail-print.ts` | [이미지 보기]는 `GET /messages/{id}?images=show` 로 그 메일의 본문 칸만 다시 받는다. 본문 틀의 CSP 는 보일 때만 그림에 **우리 출처의 중계 경로**(`<origin>/api/mail-images/`)를 연다 — `'self'` 로 열면 정화를 빠져나간 CSS `url()` 이 다른 API 를 부를 수 있다(2026-10-29 리뷰). 중계 주소 모양은 짝: backend `images/mail-image-token.ts` 의 `TOKEN_PATTERN`. 인쇄는 새 탭(`window.open` 은 누름 처리 안에서 바로), A4 폭(210mm — @page 와 같은 값, 함께 고친다)에서 본문 높이를 다시 잰 뒤 `window.print()` 한 번. Ctrl+P 판정은 `keyboard-shortcut.ts`(조합 중 · 다른 수식키는 브라우저 인쇄, `code === 'KeyP'` 도) |
|
||||
| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `src/composables/useSenderBlock.ts` · `components/mail/MessageBlockDialog.vue` · `stores/mail-block.store.ts` · `shared/utils/mail-block.ts` · `shared/constants/mail-block.ts`(`PUBLIC_MAIL_DOMAINS`) · `pages/settings/MailBlocksPage.vue` · `MailSendReadPage.vue` · `stores/mail-settings.store.ts` · `composables/useMailSettings.ts` · 설정 틀의 `title-aside` 슬롯(`SettingsPageFrame`) | 차단은 엔진의 Sieve(필터 · 부재중과 같은 스크립트 — 계약 2절 "보낸 사람 차단"). 여러 사람이 쓰는 메일 서비스 목록은 **짝: backend `mail.constants.ts`**(30개 · 같은 차례 — 한쪽을 고치면 둘 다). 차단 · 풀기가 성공하면 엔진 규칙이 켜진 것으로 본다(`isActive`). [되돌리기]는 원래 메일함으로. 설정은 고르는 대로 `PATCH` — **칸마다 저장은 한 번에 하나**(보내는 동안 고른 값은 끝난 뒤 다르면 보낸다, 끝내 실패하면 서버가 확인한 값으로 — 6단계 필터의 `saving` 과 같은 결) |
|
||||
| 대용량 첨부 (11단계) | 올리기 엔진 `src/composables/useLargeFileUploads.ts` · 쓰기 화면 잇기 `useComposeLargeFiles.ts` · 첨부 칸 `components/mail/ComposeAttachmentGroups.vue` · `ComposeLargeFileItem.vue` · 읽기 `MessageAttachments.vue`(+ `MessageLargeFileItem` · `MessageLargeFileLinkBar` · `LargeFileRevokeDialog`) · 검사 다시 묻기 `useLargeFileScanRefresh.ts` · 위험 알림 `useLargeFileAlerts.ts`(실시간 `large-file`) · 설정 `pages/settings/MailLargeFilesPage.vue` + `components/settings/LargeFile*.vue` · `stores/large-files.store.ts` · API `useLargeFiles.ts` · 공개 화면 `pages/share/SharePage.vue` + `usePublicShare.ts` · `shared/utils/large-file.ts` · `large-file-status.ts` · `kst-date.ts` · `share-links.ts` · `shared/constants/large-file.ts` | 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). **올리기**: 고르자마자 서버에 모두 만들고(임시 저장본에 바로 실림) 조각(16MiB · `Content-Digest`)은 파일 3개까지만 함께, 끊기면 2 · 5 · 15 · 30초로 다시, 1분 넘으면 멈춤 + [다시 시도], 404 는 저절로 다시 올리지 않는다, 새로고침 뒤 같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256)을 다시 고르면 잇는다. 다 올릴 때까지 [보내기] · ▾ 는 `aria-disabled` + 까닭(누르면 role=status). **본문**: 대용량 파일이 있을 때만 첫 원문 인용 앞에 `<div data-ara-quote-start="">` 와 `bodyQuoteStart`(텍스트 인용 줄의 처음)를 보낸다 — 서버가 그 자리에 덩이를 넣는다. 다시 연 저장본의 원문 인용은 `mail-extensions.ts` 의 `isMailQuoteBlock`("원본 메일" 머리 문단 + 머리 표)으로 알아본다. **읽기**: 첨부 칸의 대용량 항목은 서버의 `largeFiles` 에서만 오고, 본문 덩이는 서버만 붙일 수 있는 `data-ara-large-files` 요소만 `<template>` 안에서 접는다(`mail-html.ts`). **공개 화면** `/s/:token` 은 `meta.public`(세션 복원을 건너뛴다 — 이 라우트 하나뿐), 요청은 `credentials: 'omit'` · `no-store` · `no-referrer`, v-html 없음. 서버 거절은 사전 문구(`getErrorMessage`), 화면 상수 글은 서버에 묻기 전 검사뿐 — 계약 4절, 디자인 README "11단계" |
|
||||
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
|
||||
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 |
|
||||
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
|
||||
|
||||
@@ -91,6 +91,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
|
||||
#
|
||||
@@ -108,6 +110,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`
|
||||
#
|
||||
|
||||
+35
-1
@@ -77,6 +77,8 @@ REDIS_DATA_DIR=./data/redis
|
||||
# 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
|
||||
|
||||
@@ -100,7 +102,7 @@ DB_SYNCHRONIZE=false
|
||||
# 선택적 서비스 (Compose profiles)
|
||||
#
|
||||
# 기본값 = mail — 메일 엔진(Stalwart)을 띄운다. 직원 로그인이 메일 엔진에 의존한다.
|
||||
# (redis 는 기동하지 않는다)
|
||||
# (redis 는 기동하지 않는다. 대용량 첨부의 악성 검사기 clamav · clamav-updater 는 profile 없이 늘 뜬다 — 아래 "대용량 첨부")
|
||||
# 캐시·세션 공유가 필요해지면 redis 를 추가하면 컨테이너가 함께 뜬다.
|
||||
# COMPOSE_PROFILES=mail,redis
|
||||
# 일회성으로 켜려면: docker compose --profile redis ... up -d
|
||||
@@ -133,6 +135,38 @@ FRONTEND_PRIVATE_BIND=
|
||||
# ==========================================
|
||||
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`
|
||||
#
|
||||
|
||||
+122
-1
@@ -180,14 +180,132 @@ 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). 두 꼴 모두 문법을 본다
|
||||
docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.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 "$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;'; do
|
||||
printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; }
|
||||
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 을 가른다
|
||||
- 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"
|
||||
|
||||
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
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
release
|
||||
hold portal.ci.test "/api/messages/M1/large-files/$F" 30
|
||||
for p in $INAPP; do expect "429 -" portal.ci.test "$p"; done
|
||||
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 "$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; }
|
||||
|
||||
# 대용량 첨부의 악성 검사기(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.
|
||||
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다)
|
||||
@@ -202,8 +320,11 @@ jobs:
|
||||
- name: 정리
|
||||
if: always()
|
||||
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
|
||||
# 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다)
|
||||
docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
ci/runner-data/
|
||||
# 업로드 파일 (로컬 저장) — 코드가 아니다
|
||||
backend/uploads/
|
||||
# 대용량 첨부 파일 (11단계 — FILE_STORAGE_DIR 의 기본값 ./files, 볼륨을 붙이지 않은 개발 · 호스트 실행) — 코드가 아니다
|
||||
backend/files/
|
||||
|
||||
# ==========================================
|
||||
# 의존성/빌드 산출물
|
||||
|
||||
@@ -12,6 +12,8 @@ node_modules
|
||||
dist
|
||||
coverage
|
||||
uploads
|
||||
# 대용량 첨부 파일 (11단계 — 호스트에서 백엔드를 띄우면 FILE_STORAGE_DIR 기본값 ./files 에 쌓인다. 2GB 짜리가 빌드 컨텍스트에 실리지 않게)
|
||||
files
|
||||
logs
|
||||
*.log
|
||||
.env
|
||||
|
||||
+5
-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
|
||||
@@ -41,10 +44,10 @@ RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# 업로드 저장 위치(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
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guar
|
||||
import { OriginGuard } from '@/common/guards/origin.guard';
|
||||
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 { UploadModule } from '@/modules/upload/upload.module';
|
||||
@@ -68,6 +70,10 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
MailModule,
|
||||
// 주소록 API — 내 주소록 · 사내 목록 (8단계, 메일 모듈도 받는 사람 추천에 쓴다)
|
||||
ContactsModule,
|
||||
// 대용량 첨부 (11단계) — 파일 · 조각 올리기 · 악성 검사(FileStorageModule)와 공개 링크 · 내려받기(FileSharingModule).
|
||||
// 메일 모듈도 가져온다(메일 → 공유 → 저장 한 방향) — 2부 파일 저장소가 같은 두 모듈을 쓴다
|
||||
FileStorageModule,
|
||||
FileSharingModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { BadRequestException, HttpStatus } from '@nestjs/common';
|
||||
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';
|
||||
@@ -9,6 +20,8 @@ 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,
|
||||
@@ -273,7 +286,7 @@ describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () =>
|
||||
});
|
||||
|
||||
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/'],
|
||||
@@ -301,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/',
|
||||
@@ -331,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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
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 { json, type NextFunction, type Request, type Response } from 'express';
|
||||
import express, {
|
||||
json,
|
||||
type Express,
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from 'express';
|
||||
import helmet from 'helmet';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
@@ -10,6 +19,11 @@ 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 {
|
||||
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,
|
||||
@@ -50,17 +64,55 @@ export const JSON_SHAPE_LIMITS: JsonShapeLimits = {
|
||||
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'>,
|
||||
@@ -173,14 +225,29 @@ 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);
|
||||
|
||||
// 공개 호스트(대용량 첨부의 내려받기 주소 — 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));
|
||||
|
||||
@@ -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);
|
||||
@@ -82,6 +82,29 @@ describe('에러 코드 레지스트리', () => {
|
||||
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)
|
||||
|
||||
@@ -84,6 +84,40 @@ const DOMAIN_ERROR_CODES = {
|
||||
status: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
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,
|
||||
@@ -176,6 +210,13 @@ const DOMAIN_ERROR_CODES = {
|
||||
message:
|
||||
'보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
|
||||
},
|
||||
// 보내거나 저장할 대용량 첨부(11단계 — 계약 4절) 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험.
|
||||
// 메일은 나가지 않았다 · 저장하지 않았다. 클라이언트는 파일마다 GET /api/file-uploads/{id} 로 다시 물어 쓸 수 없는 것을 보인다 (MAIL_004 와 같은 흐름)
|
||||
MAIL_016: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
},
|
||||
// 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).
|
||||
// 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절)
|
||||
CONTACT_001: {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -9,7 +9,7 @@ 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';
|
||||
@@ -22,11 +22,16 @@ import {
|
||||
const DEFAULT_PORT = 3000;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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,221 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator';
|
||||
|
||||
import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator';
|
||||
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import type {
|
||||
PublicLink,
|
||||
PublicLinkFile,
|
||||
SharedFile,
|
||||
SharedFileStatus,
|
||||
ShareLinkState,
|
||||
ShareLinkStatus,
|
||||
} from '../file-sharing.types';
|
||||
|
||||
/** 인용 속 링크를 한 번에 묻는 수 · 주소 길이 (계약 4절 ShareLinkLookupRequest) */
|
||||
export const MAX_SHARE_LINK_LOOKUP_URLS = 20;
|
||||
export const MAX_SHARE_LINK_URL_LENGTH = 300;
|
||||
|
||||
const SHARED_FILE_STATUSES: SharedFileStatus[] = [
|
||||
'available',
|
||||
'unscannable',
|
||||
'scanning',
|
||||
'infected',
|
||||
'limit-reached',
|
||||
'revoked',
|
||||
'expired',
|
||||
'missing',
|
||||
];
|
||||
const LINK_STATUSES: ShareLinkStatus[] = ['active', 'expired', 'revoked'];
|
||||
|
||||
/** POST /api/share-link-lookups — 인용 속 공개 화면 주소 */
|
||||
export class ShareLinkLookupRequestDto {
|
||||
@ApiProperty({
|
||||
description: `본문(인용)에서 찾은 공개 화면 주소 — 1~${MAX_SHARE_LINK_LOOKUP_URLS}개, 겹치지 않게, 한 개 ${MAX_SHARE_LINK_URL_LENGTH}자까지. 주소(토큰)를 쿼리에 싣지 않으려고 본문으로 받는다`,
|
||||
type: [String],
|
||||
example: [
|
||||
'https://files.arasystem.it.kr/s/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCd',
|
||||
],
|
||||
})
|
||||
// 형식 → 개수 → 겹침 → 항목 (적은 차례대로 돈다)
|
||||
@applyDecorators(
|
||||
IsArray({ message: '링크 목록 형식이 올바르지 않습니다.' }),
|
||||
ArrayMinSize(1, { message: '확인할 링크가 없습니다.' }),
|
||||
ArrayMaxSize(MAX_SHARE_LINK_LOOKUP_URLS, {
|
||||
message: `링크는 한 번에 ${MAX_SHARE_LINK_LOOKUP_URLS}개까지 확인합니다.`,
|
||||
}),
|
||||
ArrayDistinct({ message: '같은 링크를 두 번 보냈습니다.' }),
|
||||
IsString({ each: true, message: '링크 형식이 올바르지 않습니다.' }),
|
||||
MaxCodeUnits(MAX_SHARE_LINK_URL_LENGTH, {
|
||||
each: true,
|
||||
message: `링크는 ${MAX_SHARE_LINK_URL_LENGTH}자 이하여야 합니다.`,
|
||||
}),
|
||||
)
|
||||
urls!: string[];
|
||||
}
|
||||
|
||||
export class ShareLinkStateDto implements ShareLinkState {
|
||||
@ApiProperty({ description: '보낸 그대로의 주소' })
|
||||
url!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'active 받을 수 있는 기간 · expired 기간 지남 · revoked 공유 멈춤 · unknown 우리 링크 모양이 아님 · 모르는 · 보내기를 취소한 · 기록을 지운 링크',
|
||||
enum: [...LINK_STATUSES, 'unknown'],
|
||||
})
|
||||
status!: ShareLinkStatus | 'unknown';
|
||||
|
||||
@ApiProperty({
|
||||
description: '끝나는 때 — unknown 이면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
expiresAt!: string | null;
|
||||
}
|
||||
|
||||
export class ShareLinkLookupResultDto {
|
||||
@ApiProperty({ description: '요청 차례 그대로', type: [ShareLinkStateDto] })
|
||||
links!: ShareLinkStateDto[];
|
||||
}
|
||||
|
||||
export class SharedFileDto implements SharedFile {
|
||||
@ApiProperty({ description: '파일 id' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '이름 (보낼 때의 이름)' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '바이트' })
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({ description: '파일 상태', enum: SHARED_FILE_STATUSES })
|
||||
status!: SharedFileStatus;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'받은 수 — 0번째 바이트부터 받기 시작한 수 (누가 받았는지는 남기지 않는다)',
|
||||
})
|
||||
downloadCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막으로 받은 때',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
lastDownloadedAt!: string | null;
|
||||
}
|
||||
|
||||
export class ShareLinkDto {
|
||||
@ApiProperty({ description: '링크 id' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'보낸 사본의 메일 id — [보낸 메일 보기]. 보낸 결과를 모르면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
sentEmailId!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'보낸 메일 제목 — 메일 서버에서 읽는다 (지웠거나 읽지 못하면 null)',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
subject!: string | null;
|
||||
|
||||
@ApiProperty({ description: '보낸 때 (예약은 보낼 시각)' })
|
||||
sentAt!: string;
|
||||
|
||||
@ApiProperty({ description: '끝나는 때' })
|
||||
expiresAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'active 사용 중 · expired 기간 지남 · revoked 끊음',
|
||||
enum: LINK_STATUSES,
|
||||
})
|
||||
status!: ShareLinkStatus;
|
||||
|
||||
@ApiProperty({
|
||||
description: '끊은 때 (보낸 사람 · 운영) — revoked 일 때만 값이 있다',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '2026-10-22T05:12:00.000Z',
|
||||
})
|
||||
revokedAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '링크의 파일 (보낸 차례)',
|
||||
type: [SharedFileDto],
|
||||
})
|
||||
files!: SharedFileDto[];
|
||||
}
|
||||
|
||||
export class ShareLinkPageDto {
|
||||
@ApiProperty({
|
||||
description: '내가 만든 링크 — 보낸 때 최근부터 (취소한 링크는 빠진다)',
|
||||
type: [ShareLinkDto],
|
||||
})
|
||||
items!: ShareLinkDto[];
|
||||
|
||||
@ApiProperty({ description: '페이지 정보 (total 포함)', type: PageInfoDto })
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
export class PublicLinkSenderDto {
|
||||
@ApiProperty({ description: '보낸 사람 이름', nullable: true, type: String })
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({ description: '보낸 사람 주소' })
|
||||
email!: string;
|
||||
}
|
||||
|
||||
export class PublicLinkFileDto implements PublicLinkFile {
|
||||
@ApiProperty({ description: '파일 id — 받기 경로에 쓴다' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '이름' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '바이트' })
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '파일 상태',
|
||||
enum: SHARED_FILE_STATUSES.filter(
|
||||
(status) => status !== 'revoked' && status !== 'expired',
|
||||
),
|
||||
})
|
||||
status!: PublicLinkFile['status'];
|
||||
}
|
||||
|
||||
export class PublicLinkDto implements PublicLink {
|
||||
@ApiProperty({ description: '링크 상태', enum: LINK_STATUSES })
|
||||
status!: ShareLinkStatus;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'보낼 때 로그인한 계정의 이름 · 주소 — 보낸 사람이 꾸밀 수 없다',
|
||||
type: PublicLinkSenderDto,
|
||||
})
|
||||
sender!: PublicLinkSenderDto;
|
||||
|
||||
@ApiProperty({ description: '보낸 때' })
|
||||
sentAt!: string;
|
||||
|
||||
@ApiProperty({ description: '끝나는 때' })
|
||||
expiresAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'active 일 때만 — 끝난 링크는 빈 배열 (파일 이름도 보이지 않는다)',
|
||||
type: [PublicLinkFileDto],
|
||||
})
|
||||
files!: PublicLinkFileDto[];
|
||||
|
||||
@ApiProperty({ description: '신고 · 문의 주소' })
|
||||
reportAddress!: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { FileSharingRepository } from './file-sharing.repository';
|
||||
|
||||
/** 끝나거나 끊기거나 취소된 링크 기록을 남기는 기간 — 그동안 공개 화면은 "기간 지남" · "공유 멈춤"이고 목록에 남는다 (계약 4절) */
|
||||
export const ENDED_SHARE_LINK_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 링크 기록 정리 — 하루에 한 번. 끝나거나(기간) 끊기거나 취소된 지 90일이 지난 링크를 지운다 → 그 뒤는 "없는 링크"(404), 목록에서 사라진다.
|
||||
* 파일의 바이트 · 기록은 파일 모듈의 정리 작업이 따로 지운다 (링크가 끝나면 파일의 보관 기간도 끝난다).
|
||||
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 일을 한다 (결과는 같지만 낭비).
|
||||
*/
|
||||
@Injectable()
|
||||
export class FileSharingCleanup {
|
||||
private readonly logger = new Logger(FileSharingCleanup.name);
|
||||
|
||||
constructor(private readonly repository: FileSharingRepository) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_5AM, { name: 'share-link-cleanup' })
|
||||
async purgeEndedLinks(now: Date = new Date()): Promise<number> {
|
||||
try {
|
||||
const removed = await this.repository.deleteEndedBefore(
|
||||
new Date(now.getTime() - ENDED_SHARE_LINK_RETENTION_MS),
|
||||
);
|
||||
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
|
||||
if (removed > 0)
|
||||
this.logger.debug(`끝난 링크 ${removed}개를 지웠습니다.`);
|
||||
return removed;
|
||||
} catch (error) {
|
||||
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다
|
||||
this.logger.error(
|
||||
`[SYS_001] 끝난 링크 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FileStorageModule } from '@/modules/file-storage/file-storage.module';
|
||||
import { FileSharingCleanup } from './file-sharing.cleanup';
|
||||
import { FileSharingRepository } from './file-sharing.repository';
|
||||
import { FileSharingService } from './file-sharing.service';
|
||||
import {
|
||||
FILE_SHARING_SETTINGS,
|
||||
type FileSharingSettings,
|
||||
buildFileSharingSettings,
|
||||
} from './file-sharing.settings';
|
||||
import { PublicLinkController } from './public-link.controller';
|
||||
import { ShareLinkFileEntity } from './share-link-file.entity';
|
||||
import { ShareLinkLookupController } from './share-link-lookup.controller';
|
||||
import { ShareLinkEntity } from './share-link.entity';
|
||||
|
||||
/**
|
||||
* 공유 링크 모듈 (11단계 대용량 첨부 — 계약 4절 "공개 내려받기" · "인용 속 링크의 상태") — 로그인 없는 공개 링크 · 공개 내려받기.
|
||||
* 의존 방향은 메일 → 공유(이 모듈) → 저장(FileStorageModule) 하나다 — 이 모듈은 메일을 가져오지 않는다.
|
||||
* 보낸 사람의 링크 목록 · 끊기(메일 제목을 함께 보인다)는 메일 모듈의 컨트롤러가 이 서비스를 쓴다. 2부 파일 저장소가 같은 링크를 쓴다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ShareLinkEntity, ShareLinkFileEntity]),
|
||||
FileStorageModule,
|
||||
],
|
||||
controllers: [PublicLinkController, ShareLinkLookupController],
|
||||
providers: [
|
||||
{
|
||||
provide: FILE_SHARING_SETTINGS,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): FileSharingSettings =>
|
||||
buildFileSharingSettings((key) => config.get<string>(key)),
|
||||
},
|
||||
FileSharingRepository,
|
||||
FileSharingService,
|
||||
FileSharingCleanup,
|
||||
],
|
||||
exports: [FileSharingService, FILE_SHARING_SETTINGS, FileStorageModule],
|
||||
})
|
||||
export class FileSharingModule {}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, type Repository } from 'typeorm';
|
||||
|
||||
import type { FileLinkRetention } from '@/modules/file-storage/file-storage.types';
|
||||
import type { ShareLinkRecord } from './file-sharing.types';
|
||||
import { ShareLinkFileEntity } from './share-link-file.entity';
|
||||
import {
|
||||
ShareLinkEntity,
|
||||
type ShareLinkCancelReason,
|
||||
type ShareLinkRevoker,
|
||||
} from './share-link.entity';
|
||||
|
||||
/**
|
||||
* 공유 링크 · 링크 파일 표 접근 — 판단(상태 · 누구의 것)은 FileSharingService 가 한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FileSharingRepository {
|
||||
constructor(
|
||||
@InjectRepository(ShareLinkEntity)
|
||||
private readonly links: Repository<ShareLinkEntity>,
|
||||
@InjectRepository(ShareLinkFileEntity)
|
||||
private readonly linkFiles: Repository<ShareLinkFileEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** 링크와 파일을 한 트랜잭션에 넣는다 */
|
||||
async insert(record: ShareLinkRecord): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.insert(ShareLinkEntity, record.link);
|
||||
await manager.insert(ShareLinkFileEntity, record.files);
|
||||
});
|
||||
}
|
||||
|
||||
async findByTokenHashes(
|
||||
hashes: readonly Buffer[],
|
||||
): Promise<ShareLinkRecord[]> {
|
||||
if (hashes.length === 0) return [];
|
||||
const links = await this.links.findBy({ tokenHash: In([...hashes]) });
|
||||
return this.withFiles(links);
|
||||
}
|
||||
|
||||
async findOwned(owner: string, id: string): Promise<ShareLinkRecord | null> {
|
||||
const link = await this.links.findOneBy({ id, ownerEmail: owner });
|
||||
if (!link) return null;
|
||||
const [record] = await this.withFiles([link]);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** 보낸 사본의 메일 id 로 — 가장 늦게 만든 것 (취소한 링크 포함) */
|
||||
async findBySentEmail(
|
||||
owner: string,
|
||||
sentEmailId: string,
|
||||
): Promise<ShareLinkRecord | null> {
|
||||
const link = await this.links.findOne({
|
||||
where: { ownerEmail: owner, sentEmailId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
if (!link) return null;
|
||||
const [record] = await this.withFiles([link]);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** 같은 Message-ID 의 발송이 만든 링크 가운데 보낸 메일을 아직 모르는 것 (같은 키의 재시도) */
|
||||
findPendingByMessageId(
|
||||
owner: string,
|
||||
messageId: string,
|
||||
): Promise<ShareLinkEntity[]> {
|
||||
return this.links
|
||||
.createQueryBuilder('link')
|
||||
.where('link.owner_email = :owner', { owner })
|
||||
.andWhere('link.message_id = :messageId', { messageId })
|
||||
.andWhere('link.sent_email_id IS NULL')
|
||||
.andWhere('link.canceled_at IS NULL')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** 내 링크 — 취소한 링크는 빼고 보낸 때 최근부터 */
|
||||
async listOwned(
|
||||
owner: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Promise<{ records: ShareLinkRecord[]; total: number }> {
|
||||
const [links, total] = await this.links
|
||||
.createQueryBuilder('link')
|
||||
.where('link.owner_email = :owner', { owner })
|
||||
.andWhere('link.canceled_at IS NULL')
|
||||
.orderBy('link.sentAt', 'DESC')
|
||||
.addOrderBy('link.id', 'DESC')
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.getManyAndCount();
|
||||
return { records: await this.withFiles(links), total };
|
||||
}
|
||||
|
||||
async markSent(id: string, sentEmailId: string): Promise<void> {
|
||||
await this.links.update({ id }, { sentEmailId });
|
||||
}
|
||||
|
||||
/** 취소한 링크로 — 이미 취소했으면 그대로 (true = 이번에 바꿨다) */
|
||||
async cancel(
|
||||
id: string,
|
||||
reason: ShareLinkCancelReason,
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
const result = await this.links
|
||||
.createQueryBuilder()
|
||||
.update(ShareLinkEntity)
|
||||
.set({ canceledAt: now, cancelReason: reason })
|
||||
.where('id = :id', { id })
|
||||
.andWhere('canceled_at IS NULL')
|
||||
.execute();
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 끊는다 — 끊지 않았고 · 취소하지 않았고 · 기간 안일 때만 (true = 이번에 끊었다) */
|
||||
async revoke(id: string, by: ShareLinkRevoker, now: Date): Promise<boolean> {
|
||||
const result = await this.links
|
||||
.createQueryBuilder()
|
||||
.update(ShareLinkEntity)
|
||||
.set({ revokedAt: now, revokedBy: by })
|
||||
.where('id = :id', { id })
|
||||
.andWhere('revoked_at IS NULL')
|
||||
.andWhere('canceled_at IS NULL')
|
||||
.andWhere('expires_at >= :now', { now })
|
||||
.execute();
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은 수를 하나 더한다 — 상한 아래일 때만 (한 문장이라 동시에 받아도 상한을 넘지 않는다).
|
||||
* @returns 더했으면 true, 상한에 닿았으면 false
|
||||
*/
|
||||
async countDownload(
|
||||
linkId: string,
|
||||
fileId: string,
|
||||
limit: number,
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
const result = await this.dataSource.query<[unknown[], number]>(
|
||||
`UPDATE "share_link_files"
|
||||
SET "download_count" = "download_count" + 1, "last_downloaded_at" = $4
|
||||
WHERE "link_id" = $1 AND "file_id" = $2 AND "download_count" < $3
|
||||
RETURNING "download_count"`,
|
||||
[linkId, fileId, limit, now],
|
||||
);
|
||||
return result[0].length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일마다의 링크 상태 — 취소하지 않은 링크의 가장 늦게 만든 때 · 끊지 않은 링크의 가장 늦은 끝 (파일 모듈의 보관 기간 계산).
|
||||
* 링크가 없는 파일도 결과에 든다 (둘 다 null)
|
||||
*/
|
||||
async retentionOf(fileIds: readonly string[]): Promise<FileLinkRetention[]> {
|
||||
if (fileIds.length === 0) return [];
|
||||
const rows = await this.dataSource.query<
|
||||
{ fileId: string; linkedAt: Date | null; linkedUntil: Date | null }[]
|
||||
>(
|
||||
`SELECT f."file_id" AS "fileId",
|
||||
MAX(l."created_at") FILTER (WHERE l."canceled_at" IS NULL) AS "linkedAt",
|
||||
MAX(l."expires_at") FILTER (WHERE l."canceled_at" IS NULL AND l."revoked_at" IS NULL) AS "linkedUntil"
|
||||
FROM "share_link_files" f
|
||||
JOIN "share_links" l ON l."id" = f."link_id"
|
||||
WHERE f."file_id" = ANY($1::uuid[])
|
||||
GROUP BY f."file_id"`,
|
||||
[[...fileIds]],
|
||||
);
|
||||
const found = new Map(rows.map((row) => [row.fileId, row]));
|
||||
return [...new Set(fileIds)].map((fileId) => ({
|
||||
fileId,
|
||||
linkedAt: found.get(fileId)?.linkedAt ?? null,
|
||||
linkedUntil: found.get(fileId)?.linkedUntil ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝나거나(기간) 끊기거나 취소된 지 오래된 링크 기록을 지운다 (정리 작업 — 링크 파일은 함께 지워진다).
|
||||
* @returns 지운 수
|
||||
*/
|
||||
async deleteEndedBefore(cutoff: Date): Promise<number> {
|
||||
const result = await this.links
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(ShareLinkEntity)
|
||||
.where('LEAST(canceled_at, revoked_at, expires_at) < :cutoff', {
|
||||
cutoff,
|
||||
})
|
||||
.execute();
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
private async withFiles(
|
||||
links: readonly ShareLinkEntity[],
|
||||
): Promise<ShareLinkRecord[]> {
|
||||
if (links.length === 0) return [];
|
||||
const files = await this.linkFiles.find({
|
||||
where: { linkId: In(links.map((link) => link.id)) },
|
||||
order: { position: 'ASC' },
|
||||
});
|
||||
return links.map((link) => ({
|
||||
link,
|
||||
files: files.filter((file) => file.linkId === link.id),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { PassThrough, Readable } from 'stream';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
FileContentMissingError,
|
||||
type FileStorageService,
|
||||
} from '@/modules/file-storage/file-storage.service';
|
||||
import type {
|
||||
FileLinkRetention,
|
||||
StoredFileView,
|
||||
} from '@/modules/file-storage/file-storage.types';
|
||||
import type { FileSharingRepository } from './file-sharing.repository';
|
||||
import { FileSharingService } from './file-sharing.service';
|
||||
import type { FileSharingSettings } from './file-sharing.settings';
|
||||
import type { ShareLinkRecord } from './file-sharing.types';
|
||||
import { hashShareToken, shareUrl } from './share-token';
|
||||
|
||||
const ORIGIN = 'https://files.arasystem.test';
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const FILE_A = '0f8fad5b-d9cb-469f-a165-70867728950e';
|
||||
const FILE_B = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const CONTENT = Buffer.from('0123456789');
|
||||
|
||||
/** 링크 표 대역 — 해시로 찾기 · 원자적 받은 수 · 취소 · 끊기를 메모리에서 */
|
||||
class FakeRepository {
|
||||
readonly records: ShareLinkRecord[] = [];
|
||||
|
||||
insert(record: ShareLinkRecord) {
|
||||
this.records.push(record);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
findByTokenHashes(hashes: readonly Buffer[]) {
|
||||
return Promise.resolve(
|
||||
this.records.filter((record) =>
|
||||
hashes.some((hash) => hash.equals(record.link.tokenHash)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
findOwned(owner: string, id: string) {
|
||||
return Promise.resolve(
|
||||
this.records.find(
|
||||
(record) => record.link.id === id && record.link.ownerEmail === owner,
|
||||
) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
findBySentEmail(owner: string, sentEmailId: string) {
|
||||
return Promise.resolve(
|
||||
this.records.find(
|
||||
(record) =>
|
||||
record.link.ownerEmail === owner &&
|
||||
record.link.sentEmailId === sentEmailId,
|
||||
) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
findPendingByMessageId(owner: string, messageId: string) {
|
||||
return Promise.resolve(
|
||||
this.records
|
||||
.map((record) => record.link)
|
||||
.filter(
|
||||
(link) =>
|
||||
link.ownerEmail === owner &&
|
||||
link.messageId === messageId &&
|
||||
link.sentEmailId === null &&
|
||||
link.canceledAt === null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
listOwned(owner: string, offset: number, limit: number) {
|
||||
const all = this.records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.link.ownerEmail === owner && record.link.canceledAt === null,
|
||||
)
|
||||
.sort((a, b) => b.link.sentAt.getTime() - a.link.sentAt.getTime());
|
||||
return Promise.resolve({
|
||||
records: all.slice(offset, offset + limit),
|
||||
total: all.length,
|
||||
});
|
||||
}
|
||||
|
||||
markSent(id: string, sentEmailId: string) {
|
||||
const record = this.records.find((item) => item.link.id === id);
|
||||
if (record) record.link.sentEmailId = sentEmailId;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
cancel(id: string, reason: 'send-failed' | 'send-canceled', now: Date) {
|
||||
const link = this.records.find((item) => item.link.id === id)?.link;
|
||||
if (!link || link.canceledAt !== null) return Promise.resolve(false);
|
||||
link.canceledAt = now;
|
||||
link.cancelReason = reason;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
revoke(id: string, by: 'owner', now: Date) {
|
||||
const link = this.records.find((item) => item.link.id === id)?.link;
|
||||
if (
|
||||
!link ||
|
||||
link.revokedAt !== null ||
|
||||
link.canceledAt !== null ||
|
||||
link.expiresAt < now
|
||||
) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
link.revokedAt = now;
|
||||
link.revokedBy = by;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
countDownload(linkId: string, fileId: string, limit: number, now: Date) {
|
||||
const file = this.records
|
||||
.find((item) => item.link.id === linkId)
|
||||
?.files.find((item) => item.fileId === fileId);
|
||||
if (!file || file.downloadCount >= limit) return Promise.resolve(false);
|
||||
file.downloadCount += 1;
|
||||
file.lastDownloadedAt = now;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
retentionOf(fileIds: readonly string[]): Promise<FileLinkRetention[]> {
|
||||
return Promise.resolve(
|
||||
fileIds.map((fileId) => {
|
||||
const links = this.records
|
||||
.filter((record) =>
|
||||
record.files.some((file) => file.fileId === fileId),
|
||||
)
|
||||
.map((record) => record.link);
|
||||
const live = links.filter((link) => link.canceledAt === null);
|
||||
return {
|
||||
fileId,
|
||||
linkedAt: live.length > 0 ? live[0].createdAt : null,
|
||||
linkedUntil:
|
||||
live.filter((link) => link.revokedAt === null)[0]?.expiresAt ??
|
||||
null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 응답 대역 — 흘려보낸 바이트 · 상태 · 머리글을 모은다 */
|
||||
class FakeResponse extends PassThrough {
|
||||
statusCode = 200;
|
||||
readonly headers = new Map<string, string>();
|
||||
readonly received: Buffer[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.on('data', (part: Buffer) => this.received.push(part));
|
||||
}
|
||||
|
||||
status(code: number): this {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
setHeader(name: string, value: string): this {
|
||||
this.headers.set(name.toLowerCase(), value);
|
||||
return this;
|
||||
}
|
||||
|
||||
get body(): string {
|
||||
return Buffer.concat(this.received).toString();
|
||||
}
|
||||
}
|
||||
|
||||
const view = (overrides: Partial<StoredFileView> = {}): StoredFileView => ({
|
||||
id: FILE_A,
|
||||
ownerEmail: ARA,
|
||||
name: '설계도.dwg',
|
||||
size: CONTENT.length,
|
||||
state: 'ready',
|
||||
removedReason: null,
|
||||
scanStatus: 'clean',
|
||||
completedAt: new Date('2026-10-30T03:00:00.000Z'),
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
alive: true,
|
||||
bytesAvailable: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function setup(settings: Partial<FileSharingSettings> = {}) {
|
||||
const repository = new FakeRepository();
|
||||
const views = new Map<string, StoredFileView>([
|
||||
[FILE_A, view()],
|
||||
[FILE_B, view({ id: FILE_B, name: '영상.mp4', scanStatus: 'pending' })],
|
||||
]);
|
||||
const files = {
|
||||
limits: { maxFilesPerMessage: 10, retentionDays: 30 },
|
||||
views: jest.fn((ids: readonly string[]) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
ids.flatMap((id) => (views.has(id) ? [[id, views.get(id)]] : [])),
|
||||
),
|
||||
),
|
||||
),
|
||||
applyLinkRetention: jest.fn().mockResolvedValue(undefined),
|
||||
openContent: jest.fn(
|
||||
(_id: string, range?: { start: number; end: number }) =>
|
||||
Promise.resolve(
|
||||
Readable.from([
|
||||
range ? CONTENT.subarray(range.start, range.end + 1) : CONTENT,
|
||||
]),
|
||||
),
|
||||
),
|
||||
};
|
||||
const service = new FileSharingService(
|
||||
repository as unknown as FileSharingRepository,
|
||||
{
|
||||
publicOrigin: ORIGIN,
|
||||
reportAddress: 'postmaster@arasystem.test',
|
||||
downloadLimit: 2,
|
||||
dedicatedPublicHost: null,
|
||||
...settings,
|
||||
},
|
||||
files as unknown as FileStorageService,
|
||||
);
|
||||
return { service, repository, files, views };
|
||||
}
|
||||
|
||||
async function createLink(service: FileSharingService) {
|
||||
return service.createMailLink({
|
||||
ownerEmail: 'Ara.Kim@arasystem.test',
|
||||
senderName: '김아라',
|
||||
files: [
|
||||
{ id: FILE_A, name: '설계도.dwg', size: CONTENT.length },
|
||||
{ id: FILE_B, name: '영상.mp4', size: CONTENT.length },
|
||||
],
|
||||
sentAt: new Date(),
|
||||
messageId: 'ara.1@arasystem.test',
|
||||
});
|
||||
}
|
||||
|
||||
const request = (method: string, headers: Record<string, string> = {}) =>
|
||||
({ method, headers }) as unknown as Request;
|
||||
|
||||
async function serve(
|
||||
service: FileSharingService,
|
||||
record: ShareLinkRecord,
|
||||
fileId: string,
|
||||
req: Request,
|
||||
count = true,
|
||||
) {
|
||||
const res = new FakeResponse();
|
||||
await service.serveFile(req, res as unknown as Response, record, fileId, {
|
||||
count,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
|
||||
try {
|
||||
await run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileSharingService — 공유 링크 · 공개 내려받기 (11단계)', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('링크를 만든다 — 토큰은 해시만, 끝은 보낸 날(한국) + 30일의 끝, 보낸 사람은 소문자 주소, 파일의 보관 기간을 다시 센다', async () => {
|
||||
const { service, repository, files } = setup();
|
||||
const created = await createLink(service);
|
||||
expect(created.url).toBe(shareUrl(ORIGIN, created.token));
|
||||
const [stored] = repository.records;
|
||||
expect(stored.link.tokenHash).toEqual(hashShareToken(created.token));
|
||||
expect(stored.link.ownerEmail).toBe(ARA);
|
||||
expect(stored.link.expiresAt.getUTCHours()).toBe(14);
|
||||
expect(stored.files.map((file) => [file.fileId, file.position])).toEqual([
|
||||
[FILE_A, 0],
|
||||
[FILE_B, 1],
|
||||
]);
|
||||
expect(files.applyLinkRetention).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ fileId: FILE_A }),
|
||||
expect.objectContaining({ fileId: FILE_B }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('공개 링크 정보 — 보낸 사람 · 끝 · 파일 상태 · 신고 주소. 끊으면 파일 없이 revoked, 취소 · 모르는 링크는 404', async () => {
|
||||
const { service } = setup();
|
||||
const { token, record } = await createLink(service);
|
||||
const active = await service.publicLink(token);
|
||||
expect(active).toMatchObject({
|
||||
status: 'active',
|
||||
sender: { name: '김아라', email: ARA },
|
||||
reportAddress: 'postmaster@arasystem.test',
|
||||
});
|
||||
expect(active.files.map((file) => file.status)).toEqual([
|
||||
'available',
|
||||
'scanning',
|
||||
]);
|
||||
|
||||
await service.revoke(ARA, record.link.id);
|
||||
const revoked = await service.publicLink(token);
|
||||
expect(revoked).toMatchObject({ status: 'revoked', files: [] });
|
||||
|
||||
expect(await codeOf(() => service.publicLink('x'.repeat(43)))).toBe(
|
||||
'RES_001',
|
||||
);
|
||||
expect(await codeOf(() => service.publicLink('짧음'))).toBe('RES_001');
|
||||
await service.cancel(record, 'send-failed');
|
||||
expect(await codeOf(() => service.publicLink(token))).toBe('RES_001');
|
||||
});
|
||||
|
||||
describe('파일 응답 (Range · HEAD · 횟수)', () => {
|
||||
it('전체 200 — 내려받기 머리글(형식 · 이름 · nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => {
|
||||
const { service } = setup();
|
||||
const { record } = await createLink(service);
|
||||
const res = await serve(service, record, FILE_A, request('GET'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('0123456789');
|
||||
expect(Object.fromEntries(res.headers)).toMatchObject({
|
||||
'content-type': 'application/octet-stream',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-security-policy': "default-src 'none'; sandbox",
|
||||
'cache-control': 'private, no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
'x-robots-tag': 'noindex',
|
||||
'accept-ranges': 'bytes',
|
||||
etag: `"${FILE_A}"`,
|
||||
'content-length': '10',
|
||||
'x-accel-buffering': 'no',
|
||||
});
|
||||
expect(res.headers.get('content-disposition')).toContain('attachment');
|
||||
expect(record.files[0].downloadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('범위 하나 206 (0번부터면 센다 · 이어 받기는 세지 않는다) · 처음이 크기 이상이면 416 · HEAD 는 머리글만 세지 않는다', async () => {
|
||||
const { service, files } = setup();
|
||||
const { record } = await createLink(service);
|
||||
|
||||
const resumed = await serve(
|
||||
service,
|
||||
record,
|
||||
FILE_A,
|
||||
request('GET', { range: 'bytes=4-' }),
|
||||
);
|
||||
expect(resumed.statusCode).toBe(206);
|
||||
expect(resumed.body).toBe('456789');
|
||||
expect(resumed.headers.get('content-range')).toBe('bytes 4-9/10');
|
||||
expect(record.files[0].downloadCount).toBe(0);
|
||||
|
||||
const head = await serve(
|
||||
service,
|
||||
record,
|
||||
FILE_A,
|
||||
request('HEAD', { range: 'bytes=0-1' }),
|
||||
);
|
||||
expect(head.statusCode).toBe(206);
|
||||
expect(head.headers.get('content-length')).toBe('2');
|
||||
expect(head.body).toBe('');
|
||||
expect(files.openContent).toHaveBeenCalledTimes(1);
|
||||
|
||||
const unsatisfiable = await serve(
|
||||
service,
|
||||
record,
|
||||
FILE_A,
|
||||
request('GET', { range: 'bytes=10-' }),
|
||||
);
|
||||
expect(unsatisfiable.statusCode).toBe(416);
|
||||
expect(unsatisfiable.headers.get('content-range')).toBe('bytes */10');
|
||||
expect(record.files[0].downloadCount).toBe(0);
|
||||
|
||||
const fromStart = await serve(
|
||||
service,
|
||||
record,
|
||||
FILE_A,
|
||||
request('GET', { range: 'bytes=0-3' }),
|
||||
);
|
||||
expect(fromStart.body).toBe('0123');
|
||||
expect(record.files[0].downloadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('상한에 닿으면 410 FILE_007 (이어 받기도) · 링크를 만든 사람이 우리 웹메일로 받으면 세지 않는다', async () => {
|
||||
const { service } = setup();
|
||||
const { record } = await createLink(service);
|
||||
await serve(service, record, FILE_A, request('GET'), false);
|
||||
expect(record.files[0].downloadCount).toBe(0);
|
||||
await serve(service, record, FILE_A, request('GET'));
|
||||
await serve(service, record, FILE_A, request('GET'));
|
||||
expect(
|
||||
await codeOf(() => serve(service, record, FILE_A, request('GET'))),
|
||||
).toBe('FILE_007');
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
serve(service, record, FILE_A, request('GET', { range: 'bytes=5-' })),
|
||||
),
|
||||
).toBe('FILE_007');
|
||||
});
|
||||
|
||||
it('검사 중 409 FILE_008 · 위험 · 찾을 수 없음 · 끊음 410 FILE_007 · 링크에 없는 파일 404 — 머리글을 쓰기 전에', async () => {
|
||||
const { service, views, files } = setup();
|
||||
const { record } = await createLink(service);
|
||||
expect(
|
||||
await codeOf(() => serve(service, record, FILE_B, request('GET'))),
|
||||
).toBe('FILE_008');
|
||||
|
||||
views.set(
|
||||
FILE_B,
|
||||
view({ id: FILE_B, scanStatus: 'infected', bytesAvailable: false }),
|
||||
);
|
||||
expect(
|
||||
await codeOf(() => serve(service, record, FILE_B, request('GET'))),
|
||||
).toBe('FILE_007');
|
||||
|
||||
files.openContent.mockRejectedValueOnce(
|
||||
new FileContentMissingError(FILE_A),
|
||||
);
|
||||
expect(
|
||||
await codeOf(() => serve(service, record, FILE_A, request('GET'))),
|
||||
).toBe('FILE_007');
|
||||
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
serve(
|
||||
service,
|
||||
record,
|
||||
'11111111-2222-4333-8444-555555555555',
|
||||
request('GET'),
|
||||
),
|
||||
),
|
||||
).toBe('RES_001');
|
||||
|
||||
await service.revoke(ARA, record.link.id);
|
||||
expect(
|
||||
await codeOf(() => serve(service, record, FILE_A, request('GET'))),
|
||||
).toBe('FILE_007');
|
||||
});
|
||||
});
|
||||
|
||||
it('끊기 — 그 링크의 파일 모두, 다시 끊어도 그대로 200 · 남의 링크 404 · 파일 보관 기간을 다시 센다', async () => {
|
||||
const { service, files } = setup();
|
||||
const { record } = await createLink(service);
|
||||
files.applyLinkRetention.mockClear();
|
||||
const revoked = await service.revoke(ARA, record.link.id);
|
||||
expect(revoked.status).toBe('revoked');
|
||||
// 끊은 때 — revoked 일 때만 값이 있다 (다시 끊어도 처음 끊은 때 그대로)
|
||||
expect(revoked.revokedAt).toBe(record.link.revokedAt?.toISOString());
|
||||
expect(typeof revoked.revokedAt).toBe('string');
|
||||
expect(revoked.files.map((file) => file.status)).toEqual([
|
||||
'revoked',
|
||||
'revoked',
|
||||
]);
|
||||
expect(files.applyLinkRetention).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(service.revoke(ARA, record.link.id)).resolves.toMatchObject({
|
||||
status: 'revoked',
|
||||
revokedAt: revoked.revokedAt,
|
||||
});
|
||||
expect(files.applyLinkRetention).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.revoke('doyun.lee@arasystem.test', record.link.id),
|
||||
),
|
||||
).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('인용 속 링크의 상태 — 우리 링크면 상태 · 끝나는 날만, 모양이 틀린 · 모르는 · 취소한 링크는 unknown (요청 차례 그대로)', async () => {
|
||||
const { service } = setup();
|
||||
const { url, record } = await createLink(service);
|
||||
const other = await createLink(service);
|
||||
await service.cancel(other.record, 'send-canceled');
|
||||
|
||||
const states = await service.lookup([
|
||||
`${url}?utm=1`,
|
||||
'https://elsewhere.example/s/abc',
|
||||
other.url,
|
||||
]);
|
||||
expect(states).toEqual([
|
||||
{
|
||||
url: `${url}?utm=1`,
|
||||
status: 'active',
|
||||
expiresAt: record.link.expiresAt.toISOString(),
|
||||
},
|
||||
{
|
||||
url: 'https://elsewhere.example/s/abc',
|
||||
status: 'unknown',
|
||||
expiresAt: null,
|
||||
},
|
||||
{ url: other.url, status: 'unknown', expiresAt: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('같은 키의 재시도 — 보낸 메일을 모르는 링크에 보낸 사본을 잇는다', async () => {
|
||||
const { service, repository } = setup();
|
||||
await createLink(service);
|
||||
await service.attachRetry(ARA, 'ara.1@arasystem.test', 'sent-7');
|
||||
expect(repository.records[0].link.sentEmailId).toBe('sent-7');
|
||||
});
|
||||
|
||||
it('보낸 링크 목록 — 취소한 링크는 빠지고, 파일마다 상태 · 받은 수', async () => {
|
||||
const { service } = setup();
|
||||
const { record } = await createLink(service);
|
||||
const canceled = await createLink(service);
|
||||
await service.cancel(canceled.record, 'send-failed');
|
||||
await serve(service, record, FILE_A, request('GET'));
|
||||
|
||||
const page = await service.listOwned(ARA, { limit: 20 });
|
||||
expect(page.pageInfo).toMatchObject({ total: 1, hasNext: false });
|
||||
expect(page.items[0]).toMatchObject({
|
||||
id: record.link.id,
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
id: FILE_A,
|
||||
status: 'available',
|
||||
downloadCount: 1,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: FILE_B,
|
||||
status: 'scanning',
|
||||
downloadCount: 0,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
it('링크를 넣은 뒤 파일 기간을 다시 세지 못하면 그 링크를 취소해 두고 던진다 — 보내지 않을 링크가 살아 있는 링크로 남지 않는다', async () => {
|
||||
const { service, repository, files } = setup();
|
||||
files.applyLinkRetention.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await expect(createLink(service)).rejects.toThrow('db down');
|
||||
expect(repository.records).toHaveLength(1);
|
||||
expect(repository.records[0].link).toMatchObject({
|
||||
cancelReason: 'send-failed',
|
||||
});
|
||||
expect(repository.records[0].link.canceledAt).not.toBeNull();
|
||||
// 취소한 링크는 목록 · 공개 경로에 없다
|
||||
expect((await service.listOwned(ARA, { limit: 20 })).items).toEqual([]);
|
||||
});
|
||||
|
||||
it('보낸 사람 이름은 표의 칸(200자 — 코드 포인트)에 맞춰 자른다 — 서로게이트 쌍을 반쪽으로 자르지 않는다', async () => {
|
||||
const { service, repository } = setup();
|
||||
const long = '가'.repeat(199) + '😀' + '나'.repeat(50);
|
||||
await service.createMailLink({
|
||||
ownerEmail: ARA,
|
||||
senderName: long,
|
||||
files: [{ id: FILE_A, name: '설계도.dwg', size: CONTENT.length }],
|
||||
sentAt: new Date(),
|
||||
messageId: null,
|
||||
});
|
||||
const stored = repository.records[0].link.senderName ?? '';
|
||||
expect(Array.from(stored)).toHaveLength(200);
|
||||
expect(stored.endsWith('😀')).toBe(true);
|
||||
|
||||
await service.createMailLink({
|
||||
ownerEmail: ARA,
|
||||
senderName: '',
|
||||
files: [{ id: FILE_A, name: '설계도.dwg', size: CONTENT.length }],
|
||||
sentAt: new Date(),
|
||||
messageId: null,
|
||||
});
|
||||
expect(repository.records[1].link.senderName).toBeNull();
|
||||
});
|
||||
|
||||
describe('서버 종료', () => {
|
||||
/** 조건이 맞을 때까지 이벤트 루프를 몇 바퀴 돌린다 (고정 대기 없이) */
|
||||
async function settleUntil(condition: () => boolean): Promise<void> {
|
||||
for (let turn = 0; turn < 100 && !condition(); turn += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
it('흘려보내는 파일 응답을 끊는다 — 오류 로그 없이 처리기가 끝나고, 그 뒤의 받기는 시작하지 않는다(세지 않는다)', async () => {
|
||||
const error = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const { service, files } = setup();
|
||||
const { record } = await createLink(service);
|
||||
const slow = new PassThrough();
|
||||
files.openContent.mockResolvedValueOnce(slow);
|
||||
slow.write(CONTENT.subarray(0, 4));
|
||||
|
||||
const res = new FakeResponse();
|
||||
const serving = service.serveFile(
|
||||
request('GET'),
|
||||
res as unknown as Response,
|
||||
record,
|
||||
FILE_A,
|
||||
{ count: true },
|
||||
);
|
||||
await settleUntil(() => res.body.length > 0);
|
||||
expect(res.body).toBe('0123');
|
||||
expect(res.destroyed).toBe(false);
|
||||
|
||||
service.beforeApplicationShutdown();
|
||||
await serving;
|
||||
expect(res.destroyed).toBe(true);
|
||||
expect(slow.destroyed).toBe(true);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
expect(record.files[0].downloadCount).toBe(1);
|
||||
|
||||
// 종료 중에 온 받기 — 파일을 열지 않고 끊는다
|
||||
files.openContent.mockClear();
|
||||
const late = await serve(service, record, FILE_A, request('GET'));
|
||||
expect(late.destroyed).toBe(true);
|
||||
expect(files.openContent).not.toHaveBeenCalled();
|
||||
expect(record.files[0].downloadCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,623 @@
|
||||
import {
|
||||
type BeforeApplicationShutdown,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
FileContentMissingError,
|
||||
FileStorageService,
|
||||
} from '@/modules/file-storage/file-storage.service';
|
||||
import type { StoredFileView } from '@/modules/file-storage/file-storage.types';
|
||||
import { currentRequestId, traceTag } from '@/shared/logger/request-context';
|
||||
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
|
||||
import { decideRange } from '@/shared/utils/http-range';
|
||||
import { kstEndOfDayAfter } from '@/shared/utils/kst-date';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { isStreamAbortError } from '@/shared/utils/stream-abort';
|
||||
import {
|
||||
decodeOffsetCursor,
|
||||
encodeOffsetCursor,
|
||||
} from '@/shared/utils/offset-cursor';
|
||||
import { FileSharingRepository } from './file-sharing.repository';
|
||||
import {
|
||||
FILE_SHARING_SETTINGS,
|
||||
type FileSharingSettings,
|
||||
} from './file-sharing.settings';
|
||||
import type {
|
||||
CreatedShareLink,
|
||||
MailLinkInput,
|
||||
PublicLink,
|
||||
PublicLinkFile,
|
||||
SharedFile,
|
||||
SharedFileStatus,
|
||||
ShareLinkRecord,
|
||||
ShareLinkState,
|
||||
ShareLinkSummary,
|
||||
} from './file-sharing.types';
|
||||
import type { ShareLinkFileEntity } from './share-link-file.entity';
|
||||
import {
|
||||
SENDER_NAME_MAX_LENGTH,
|
||||
ShareLinkEntity,
|
||||
type ShareLinkCancelReason,
|
||||
} from './share-link.entity';
|
||||
import {
|
||||
createShareToken,
|
||||
hashShareToken,
|
||||
isShareToken,
|
||||
shareUrl,
|
||||
tokenFromShareUrl,
|
||||
} from './share-token';
|
||||
import {
|
||||
isDownloadable,
|
||||
sharedFileStatus,
|
||||
shareLinkStatus,
|
||||
} from './shared-file-status';
|
||||
|
||||
/** 보낸 링크 목록의 커서 범위 (shared/utils/offset-cursor.ts) */
|
||||
const SHARE_LINKS_SCOPE = 'share-links';
|
||||
|
||||
/** 파일 응답의 머리글 (계약 4절 "파일 응답") — 형식은 서버가 정한다(늘 내려받기) */
|
||||
const FILE_CONTENT_TYPE = 'application/octet-stream';
|
||||
const FILE_CSP = "default-src 'none'; sandbox";
|
||||
const FILE_CACHE_CONTROL = 'private, no-store';
|
||||
|
||||
/** 내려받기 — 누가 받나 */
|
||||
export interface DownloadOptions {
|
||||
/** 받은 수에 넣는다 (링크를 만든 사람이 우리 웹메일로 받으면 세지 않는다) */
|
||||
readonly count: boolean;
|
||||
}
|
||||
|
||||
/** 보낸 사람 이름 — 표의 칸 길이(코드 포인트)에 맞춰 자른다 (엔진의 이름 길이는 정해져 있지 않다). 비면 null */
|
||||
function senderNameOf(name: string | null): string | null {
|
||||
if (!name) return null;
|
||||
const codePoints = Array.from(name);
|
||||
return codePoints.length > SENDER_NAME_MAX_LENGTH
|
||||
? codePoints.slice(0, SENDER_NAME_MAX_LENGTH).join('')
|
||||
: name;
|
||||
}
|
||||
|
||||
/** 머리글 하나 (여럿이면 첫 값) */
|
||||
function firstHeader(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
/** 받는 쪽이 끊어 흐름이 멈췄다 — 로그를 남기지 않는다 (응답이 이미 닫혔거나 끊김 오류) */
|
||||
function isClientAbort(error: unknown, res: Response): boolean {
|
||||
return res.destroyed || isStreamAbortError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유 링크 · 공개 내려받기 (11단계 — 계약 4절 "공개 내려받기" · "보낸 사람 관리"). 파일은 파일 모듈(FileStorageService)에서 읽는다.
|
||||
* 메일 모듈이 발송 때 링크를 만들고(이 모듈은 메일을 모른다 — 메일 → 공유 → 저장 한 방향), 2부 파일 저장소가 같은 링크를 쓴다.
|
||||
*
|
||||
* 로그(계약 4절 "로그"): 토큰 · 링크 주소 · 파일 이름 · 받는 쪽 IP 는 남기지 않는다 — 링크 · 파일 id 와 개수만, 직원 주소는 가린다.
|
||||
* 링크 만들기 · 끊기는 log, 내려받기 성공은 남기지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FileSharingService implements BeforeApplicationShutdown {
|
||||
private readonly logger = new Logger(FileSharingService.name);
|
||||
/** 지금 흘려보내는 파일 응답 (공개 받기 · 우리 웹메일 받기) — 서버 종료 때 끊는다 */
|
||||
private readonly streaming = new Set<Response>();
|
||||
private shuttingDown = false;
|
||||
|
||||
constructor(
|
||||
private readonly repository: FileSharingRepository,
|
||||
@Inject(FILE_SHARING_SETTINGS)
|
||||
private readonly settings: FileSharingSettings,
|
||||
private readonly files: FileStorageService,
|
||||
) {}
|
||||
|
||||
/** 공개 화면의 출처 (덩이 · 덩이 알아보기) */
|
||||
get publicOrigin(): string {
|
||||
return this.settings.publicOrigin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 종료(HTTP 서버를 닫기 전) — 흘려보내는 파일 응답을 끊는다. 큰 내려받기가 종료를 붙잡아 종료 유예(30초)를 다 쓰고 강제 종료되지 않게 —
|
||||
* 브라우저 · 내려받기 관리자는 Range 로 이어 받는다 (실시간 알림의 종료와 같은 결 — mail-events.service.ts). 그 뒤에 온 받기는 시작하지 않는다
|
||||
*/
|
||||
beforeApplicationShutdown(): void {
|
||||
this.shuttingDown = true;
|
||||
for (const res of this.streaming) res.destroy();
|
||||
this.streaming.clear();
|
||||
}
|
||||
|
||||
// ── 메일 발송이 쓰는 것 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 링크를 만든다 — 끝나는 때 = 보낸 때(예약은 보낼 시각)의 한국 날짜 + 보관 날 수의 끝. 파일의 보관 기간을 다시 계산한다.
|
||||
* 토큰 원문은 돌려줄 때만 있다 (DB 에는 SHA-256). 넣은 뒤 파일 기간을 다시 세지 못하면 그 링크를 취소해 두고 던진다 —
|
||||
* 보내지 않을 링크가 살아 있는 링크로 남지 않게(목록 · 공개 경로의 유령 링크)
|
||||
*/
|
||||
async createMailLink(input: MailLinkInput): Promise<CreatedShareLink> {
|
||||
const token = createShareToken();
|
||||
const now = new Date();
|
||||
const link = new ShareLinkEntity();
|
||||
Object.assign(link, {
|
||||
id: randomUUID(),
|
||||
tokenHash: hashShareToken(token),
|
||||
ownerEmail: input.ownerEmail.toLowerCase(),
|
||||
senderName: senderNameOf(input.senderName),
|
||||
purpose: 'mail',
|
||||
messageId: input.messageId,
|
||||
sentEmailId: null,
|
||||
sentAt: input.sentAt,
|
||||
expiresAt: kstEndOfDayAfter(
|
||||
input.sentAt,
|
||||
this.files.limits.retentionDays,
|
||||
),
|
||||
createdAt: now,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
canceledAt: null,
|
||||
cancelReason: null,
|
||||
} satisfies Partial<ShareLinkEntity>);
|
||||
const files = input.files.map(
|
||||
(file, position): ShareLinkFileEntity => ({
|
||||
linkId: link.id,
|
||||
fileId: file.id,
|
||||
position,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
downloadCount: 0,
|
||||
lastDownloadedAt: null,
|
||||
}),
|
||||
);
|
||||
const record: ShareLinkRecord = { link, files };
|
||||
await this.repository.insert(record);
|
||||
try {
|
||||
await this.refreshRetention(files.map((file) => file.fileId));
|
||||
} catch (error) {
|
||||
// 파일 기간은 아직 이 링크를 모른다 — 링크만 취소해 두면 앞뒤가 맞는다 (파일 기간을 다시 셀 필요가 없다)
|
||||
await this.repository
|
||||
.cancel(link.id, 'send-failed', new Date())
|
||||
.catch((cancelError: unknown) =>
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 파일 기간을 세지 못한 링크를 취소하지도 못했습니다 — 보내지 않은 링크가 남습니다: link=${link.id}, reason=${cancelError instanceof Error ? cancelError.message : String(cancelError)}`,
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
this.logger.log(
|
||||
`${traceTag()} 대용량 첨부 링크를 만들었습니다: user=${maskEmail(link.ownerEmail)}, link=${link.id}, 파일 ${files.length}개, 끝=${link.expiresAt.toISOString()}`,
|
||||
);
|
||||
return { record, token, url: shareUrl(this.publicOrigin, token) };
|
||||
}
|
||||
|
||||
/** 보내기에 성공했다 — 보낸 사본의 메일 id 를 잇는다 */
|
||||
async markSent(linkId: string, sentEmailId: string): Promise<void> {
|
||||
await this.repository.markSent(linkId, sentEmailId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 Idempotency-Key 의 재시도가 처음 메일을 찾았다 — 그 발송이 만든 링크(보낸 메일을 모르는 것)에 보낸 사본을 잇는다.
|
||||
* 결과를 모르게 끝난 첫 시도의 링크가 이렇게 제자리를 찾는다 (계약 4절 "링크를 만드는 때")
|
||||
*/
|
||||
async attachRetry(
|
||||
owner: string,
|
||||
messageId: string,
|
||||
sentEmailId: string,
|
||||
): Promise<void> {
|
||||
const pending = await this.repository.findPendingByMessageId(
|
||||
owner.toLowerCase(),
|
||||
messageId,
|
||||
);
|
||||
for (const link of pending) {
|
||||
await this.repository.markSent(link.id, sentEmailId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 링크를 취소한 링크로 둔다(보내지 못함 · 보내기 취소) — 공개 404 · 목록에서 빠진다. 파일의 보관 기간을 다시 계산한다 */
|
||||
async cancel(
|
||||
record: ShareLinkRecord,
|
||||
reason: ShareLinkCancelReason,
|
||||
): Promise<void> {
|
||||
const changed = await this.repository.cancel(
|
||||
record.link.id,
|
||||
reason,
|
||||
new Date(),
|
||||
);
|
||||
if (!changed) return;
|
||||
await this.refreshRetention(record.files.map((file) => file.fileId));
|
||||
this.logger.log(
|
||||
`${traceTag()} 대용량 첨부 링크를 취소했습니다(${reason === 'send-failed' ? '보내지 못함' : '보내기 취소'}): user=${maskEmail(record.link.ownerEmail)}, link=${record.link.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 보낸 사본의 메일 id 로 그 링크 (취소한 링크 포함) */
|
||||
findBySentEmail(
|
||||
owner: string,
|
||||
sentEmailId: string,
|
||||
): Promise<ShareLinkRecord | null> {
|
||||
return this.repository.findBySentEmail(owner.toLowerCase(), sentEmailId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰으로 링크를 찾는다 — 취소한 링크는 빼고 (메일 읽기 화면의 덩이 알아보기). 돌려준 맵의 키는 토큰
|
||||
*/
|
||||
async findByTokens(
|
||||
tokens: readonly string[],
|
||||
): Promise<Map<string, ShareLinkRecord>> {
|
||||
const valid = [...new Set(tokens)].filter(isShareToken);
|
||||
const records = await this.repository.findByTokenHashes(
|
||||
valid.map(hashShareToken),
|
||||
);
|
||||
const byHash = new Map(
|
||||
records
|
||||
.filter((record) => record.link.canceledAt === null)
|
||||
.map((record) => [record.link.tokenHash.toString('hex'), record]),
|
||||
);
|
||||
const result = new Map<string, ShareLinkRecord>();
|
||||
for (const token of valid) {
|
||||
const record = byHash.get(hashShareToken(token).toString('hex'));
|
||||
if (record) result.set(token, record);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 링크 파일들의 상태 (메일 읽기 화면 · 보낸 사람 목록) — 링크 id:파일 id 로 찾는다 */
|
||||
async fileStatuses(
|
||||
records: readonly ShareLinkRecord[],
|
||||
): Promise<Map<string, SharedFileStatus>> {
|
||||
const views = await this.files.views(
|
||||
records.flatMap((record) => record.files.map((file) => file.fileId)),
|
||||
);
|
||||
const now = new Date();
|
||||
const result = new Map<string, SharedFileStatus>();
|
||||
for (const record of records) {
|
||||
for (const file of record.files) {
|
||||
result.set(
|
||||
`${record.link.id}:${file.fileId}`,
|
||||
this.statusOf(record, file, views.get(file.fileId), now),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 보낸 사람 관리 (/api/share-links — 메일 모듈의 컨트롤러가 제목을 더한다) ──────────
|
||||
|
||||
/** 내 링크 — 취소한 링크는 빼고 보낸 때 최근부터, total 포함 */
|
||||
async listOwned(
|
||||
owner: string,
|
||||
query: { readonly cursor?: string; readonly limit: number },
|
||||
): Promise<PaginatedResult<ShareLinkSummary>> {
|
||||
let offset = 0;
|
||||
if (query.cursor !== undefined) {
|
||||
const reading = decodeOffsetCursor(query.cursor, SHARE_LINKS_SCOPE);
|
||||
if ('problem' in reading) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-share-link-cursor:${reading.problem}` },
|
||||
});
|
||||
}
|
||||
offset = reading.offset;
|
||||
}
|
||||
const { records, total } = await this.repository.listOwned(
|
||||
owner.toLowerCase(),
|
||||
offset,
|
||||
query.limit,
|
||||
);
|
||||
const statuses = await this.fileStatuses(records);
|
||||
const next = offset + records.length;
|
||||
const hasNext = next < total;
|
||||
return toPaginatedResult(
|
||||
records.map((record) => this.summarize(record, statuses)),
|
||||
{
|
||||
hasNext,
|
||||
nextCursor: hasNext
|
||||
? encodeOffsetCursor(next, SHARE_LINKS_SCOPE)
|
||||
: null,
|
||||
total,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 링크 끊기 — 그 링크(메일)의 파일 모두. 되돌릴 수 없다. 이미 끊었거나 기간이 지났으면 바꾸지 않고 지금 상태.
|
||||
* 남의 링크 · 없는 것 · 취소한 링크는 404 RES_001
|
||||
*/
|
||||
async revoke(owner: string, id: string): Promise<ShareLinkSummary> {
|
||||
const ownerEmail = owner.toLowerCase();
|
||||
const record = await this.repository.findOwned(ownerEmail, id);
|
||||
if (!record || record.link.canceledAt !== null) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'share-link-not-found', id },
|
||||
});
|
||||
}
|
||||
const now = new Date();
|
||||
if (await this.repository.revoke(id, 'owner', now)) {
|
||||
record.link.revokedAt = now;
|
||||
record.link.revokedBy = 'owner';
|
||||
await this.refreshRetention(record.files.map((file) => file.fileId));
|
||||
this.logger.log(
|
||||
`${traceTag()} 대용량 첨부 링크를 끊었습니다: user=${maskEmail(ownerEmail)}, link=${id}, 파일 ${record.files.length}개`,
|
||||
);
|
||||
}
|
||||
return this.summarize(record, await this.fileStatuses([record]));
|
||||
}
|
||||
|
||||
// ── 인용 속 링크의 상태 (POST /api/share-link-lookups) ─────────────────────────
|
||||
|
||||
/** 주소마다 상태 · 끝나는 날만 — 누구의 링크든 같다. 요청 차례 그대로 */
|
||||
async lookup(urls: readonly string[]): Promise<ShareLinkState[]> {
|
||||
const tokens = urls.map((url) =>
|
||||
tokenFromShareUrl(this.publicOrigin, url, { allowQuery: true }),
|
||||
);
|
||||
const found = await this.findByTokens(
|
||||
tokens.filter((token): token is string => token !== null),
|
||||
);
|
||||
const now = new Date();
|
||||
return urls.map((url, index) => {
|
||||
const token = tokens[index];
|
||||
const record = token === null ? undefined : found.get(token);
|
||||
if (!record) return { url, status: 'unknown', expiresAt: null };
|
||||
return {
|
||||
url,
|
||||
status: shareLinkStatus(record.link, now),
|
||||
expiresAt: record.link.expiresAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── 공개 경로 (/api/public-links — 로그인 없음) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* 공개 링크 정보 — 모양이 틀린 · 모르는 · 취소한 · 기록을 지운 링크는 404 RES_001. 끝난 링크는 파일 없이(이름도 보이지 않게)
|
||||
*/
|
||||
async publicLink(token: string): Promise<PublicLink> {
|
||||
const record = await this.requirePublicRecord(token);
|
||||
const now = new Date();
|
||||
const status = shareLinkStatus(record.link, now);
|
||||
let files: PublicLinkFile[] = [];
|
||||
if (status === 'active') {
|
||||
const statuses = await this.fileStatuses([record]);
|
||||
files = record.files.map((file) => ({
|
||||
id: file.fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: statuses.get(
|
||||
`${record.link.id}:${file.fileId}`,
|
||||
) as PublicLinkFile['status'],
|
||||
}));
|
||||
}
|
||||
return {
|
||||
status,
|
||||
sender: { name: record.link.senderName, email: record.link.ownerEmail },
|
||||
sentAt: record.link.sentAt.toISOString(),
|
||||
expiresAt: record.link.expiresAt.toISOString(),
|
||||
files,
|
||||
reportAddress: this.settings.reportAddress,
|
||||
};
|
||||
}
|
||||
|
||||
/** 공개 받기 — 토큰의 링크 (취소 · 모르는 링크는 404) */
|
||||
async requirePublicRecord(token: string): Promise<ShareLinkRecord> {
|
||||
const record = isShareToken(token)
|
||||
? (await this.findByTokens([token])).get(token)
|
||||
: undefined;
|
||||
if (!record) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'share-link-unknown' },
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 하나를 내려준다 (공개 받기 · 우리 웹메일 받기 공용 — 계약 4절 "파일 응답").
|
||||
* 실패는 머리글을 쓰기 **전에** 던진다(부르는 쪽의 필터가 표준 실패 형식으로): 링크에 없는 파일 404 RES_001, 검사 중 409 FILE_008,
|
||||
* 기간 지남 · 끊음 · 위험 · 횟수 초과 · 찾을 수 없음 410 FILE_007.
|
||||
* 범위: 없으면 200 전체, 하나면 206, 처음이 크기 이상이면 416(본문 없음 — 래핑 예외). HEAD 는 머리글만(파일을 열지 않고 세지 않는다).
|
||||
* 횟수: GET 이고 0번째 바이트부터 보내는 응답만 — 머리글을 보내기 직전에 원자적으로 더한다(상한이면 410)
|
||||
*/
|
||||
async serveFile(
|
||||
req: Request,
|
||||
res: Response,
|
||||
record: ShareLinkRecord,
|
||||
fileId: string,
|
||||
options: DownloadOptions,
|
||||
): Promise<void> {
|
||||
const entry = record.files.find((file) => file.fileId === fileId);
|
||||
if (!entry) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'file-not-in-link' },
|
||||
});
|
||||
}
|
||||
const view = (await this.files.views([fileId])).get(fileId);
|
||||
const now = new Date();
|
||||
const status = this.statusOf(record, entry, view, now);
|
||||
if (status === 'scanning') {
|
||||
throw new BusinessException('FILE_008', {
|
||||
context: { reason: 'file-scanning', fileId },
|
||||
});
|
||||
}
|
||||
if (!isDownloadable(status) || !view) {
|
||||
throw this.notDownloadable(status, fileId);
|
||||
}
|
||||
|
||||
const size = entry.size;
|
||||
const etag = `"${fileId}"`;
|
||||
const lastModified = view.completedAt ?? record.link.createdAt;
|
||||
const decision = decideRange(
|
||||
firstHeader(req.headers.range),
|
||||
firstHeader(req.headers['if-range']),
|
||||
size,
|
||||
{ etag, lastModified },
|
||||
);
|
||||
if (decision.kind === 'unsatisfiable') {
|
||||
this.writeFileHeaders(res, entry.name, etag, lastModified);
|
||||
res.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||
res.setHeader('Content-Range', `bytes */${size}`);
|
||||
res.setHeader('Content-Length', '0');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const range =
|
||||
decision.kind === 'partial'
|
||||
? { start: decision.start, end: decision.end }
|
||||
: undefined;
|
||||
const length = range ? range.end - range.start + 1 : size;
|
||||
const statusCode = range ? HttpStatus.PARTIAL_CONTENT : HttpStatus.OK;
|
||||
|
||||
if (req.method === 'HEAD') {
|
||||
this.writeFileHeaders(res, entry.name, etag, lastModified);
|
||||
this.writeLength(res, statusCode, length, range, size);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shuttingDown) {
|
||||
// 종료 중 — 시작하지 않는다(세지 않는다). 연결을 끊으면 받는 쪽이 다시 시도한다
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
let stream;
|
||||
try {
|
||||
stream = await this.files.openContent(fileId, range);
|
||||
} catch (error) {
|
||||
if (error instanceof FileContentMissingError) {
|
||||
throw this.notDownloadable('missing', fileId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (options.count && (range === undefined || range.start === 0)) {
|
||||
const counted = await this.repository
|
||||
.countDownload(record.link.id, fileId, this.settings.downloadLimit, now)
|
||||
.catch((error: unknown) => {
|
||||
stream.destroy();
|
||||
throw error;
|
||||
});
|
||||
if (!counted) {
|
||||
stream.destroy();
|
||||
throw this.notDownloadable('limit-reached', fileId);
|
||||
}
|
||||
}
|
||||
|
||||
this.writeFileHeaders(res, entry.name, etag, lastModified);
|
||||
this.writeLength(res, statusCode, length, range, size);
|
||||
// 흐름 오류는 처리기가 끝난 뒤에 올 수 있다 — 추적 ID 를 지금 잡아 둔다
|
||||
const traceId = currentRequestId() ?? 'unknown';
|
||||
this.streaming.add(res);
|
||||
try {
|
||||
// 파일을 여는 사이 종료가 시작됐으면 흘려보내지 않는다 (종료 때 끊을 목록에 들기 전이었다)
|
||||
if (this.shuttingDown) res.destroy();
|
||||
await pipeline(stream, res);
|
||||
} catch (error) {
|
||||
if (!isClientAbort(error, res)) {
|
||||
this.logger.error(
|
||||
`[SYS_001][${traceId}] 대용량 첨부 전송이 멈췄습니다: link=${record.link.id}, file=${fileId}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (!res.destroyed) res.destroy();
|
||||
} finally {
|
||||
this.streaming.delete(res);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 내부 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private statusOf(
|
||||
record: ShareLinkRecord,
|
||||
file: ShareLinkFileEntity,
|
||||
view: StoredFileView | undefined,
|
||||
now: Date,
|
||||
): SharedFileStatus {
|
||||
return sharedFileStatus({
|
||||
link: record.link,
|
||||
file: view,
|
||||
downloadCount: file.downloadCount,
|
||||
downloadLimit: this.settings.downloadLimit,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
private summarize(
|
||||
record: ShareLinkRecord,
|
||||
statuses: Map<string, SharedFileStatus>,
|
||||
): ShareLinkSummary {
|
||||
const now = new Date();
|
||||
return {
|
||||
id: record.link.id,
|
||||
sentEmailId: record.link.sentEmailId,
|
||||
sentAt: record.link.sentAt.toISOString(),
|
||||
expiresAt: record.link.expiresAt.toISOString(),
|
||||
status: shareLinkStatus(record.link, now),
|
||||
revokedAt: record.link.revokedAt?.toISOString() ?? null,
|
||||
files: record.files.map(
|
||||
(file): SharedFile => ({
|
||||
id: file.fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: statuses.get(`${record.link.id}:${file.fileId}`) ?? 'missing',
|
||||
downloadCount: file.downloadCount,
|
||||
lastDownloadedAt: file.lastDownloadedAt?.toISOString() ?? null,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 파일의 링크 상태를 다시 읽어 파일 모듈에 알린다 — 보관 기간이 바뀐다 */
|
||||
private async refreshRetention(fileIds: readonly string[]): Promise<void> {
|
||||
await this.files.applyLinkRetention(
|
||||
await this.repository.retentionOf(fileIds),
|
||||
);
|
||||
}
|
||||
|
||||
private notDownloadable(
|
||||
status: SharedFileStatus,
|
||||
fileId: string,
|
||||
): BusinessException {
|
||||
return new BusinessException('FILE_007', {
|
||||
context: { reason: `file-${status}`, fileId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 파일 응답의 머리글 (계약 4절 "파일 응답") — 형식 · 이름 · 검증자 · 캐시 · 버퍼링 */
|
||||
private writeFileHeaders(
|
||||
res: Response,
|
||||
name: string,
|
||||
etag: string,
|
||||
lastModified: Date,
|
||||
): void {
|
||||
res.setHeader('Content-Type', FILE_CONTENT_TYPE);
|
||||
res.setHeader('Content-Disposition', buildAttachmentDisposition(name));
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', FILE_CSP);
|
||||
res.setHeader('Cache-Control', FILE_CACHE_CONTROL);
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
res.setHeader('X-Robots-Tag', 'noindex');
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('ETag', etag);
|
||||
res.setHeader('Last-Modified', lastModified.toUTCString());
|
||||
// 앞단 nginx · 외부 프록시가 이 응답을 임시 파일에 받아 두지 않게 (느린 받는 사람마다 1GB 까지)
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
}
|
||||
|
||||
private writeLength(
|
||||
res: Response,
|
||||
statusCode: number,
|
||||
length: number,
|
||||
range: { start: number; end: number } | undefined,
|
||||
size: number,
|
||||
): void {
|
||||
res.status(statusCode);
|
||||
res.setHeader('Content-Length', String(length));
|
||||
if (range) {
|
||||
res.setHeader(
|
||||
'Content-Range',
|
||||
`bytes ${range.start}-${range.end}/${size}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
import {
|
||||
DEV_DEFAULT_ORIGINS,
|
||||
buildAllowedOrigins,
|
||||
normalizeOrigin,
|
||||
parseOriginList,
|
||||
} from '@/shared/utils/request-origin';
|
||||
|
||||
/**
|
||||
* 공개 링크 설정 (계약 4절 "공개 호스트" · "환경 변수") — 루트 env 에서 compose 가 넣는다. 검사는 env.validation.ts 가 기동 때 한다.
|
||||
*/
|
||||
export const FILE_SHARING_SETTINGS = Symbol('FILE_SHARING_SETTINGS');
|
||||
|
||||
export interface FileSharingSettings {
|
||||
/** 공개 화면의 출처(스킴 · 호스트 · 포트 — 끝 `/` 없음) — 링크 · 덩이 · 덩이 알아보기가 쓴다. 운영 `https://files.arasystem.it.kr` */
|
||||
readonly publicOrigin: string;
|
||||
/** 공개 화면의 신고 · 문의 주소 */
|
||||
readonly reportAddress: string;
|
||||
/** 파일당 내려받기 상한 (받기 시작한 수) */
|
||||
readonly downloadLimit: number;
|
||||
/**
|
||||
* 공개 호스트를 포털과 따로 둘 때 그 호스트 이름(소문자, 포트 없이) — 공개 호스트에서는 공개 API 만 열고, 포털에서는 공개 API 를 닫는다.
|
||||
* 공개 출처가 포털의 출처(허용 출처 목록)에 들어 있으면(개발 — 같은 주소) null — 둘 다 연다
|
||||
*/
|
||||
readonly dedicatedPublicHost: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SHARE_DOWNLOAD_LIMIT = 500;
|
||||
export const SHARE_DOWNLOAD_LIMIT_MAX = 100_000;
|
||||
|
||||
/** env 값을 읽어 설정을 만든다 (순수 함수 — 읽기 함수를 주입받는다) */
|
||||
export function buildFileSharingSettings(
|
||||
read: (key: string) => string | undefined,
|
||||
): FileSharingSettings {
|
||||
const configured = read('SHARE_PUBLIC_URL')?.trim();
|
||||
// 비운영에서 비우면 CORS_ORIGIN 의 첫 주소(포털과 같은 주소) — 운영은 env 검증이 필수로 막는다
|
||||
const publicOrigin =
|
||||
(configured ? normalizeOrigin(configured) : null) ??
|
||||
parseOriginList(read('CORS_ORIGIN')).origins[0] ??
|
||||
DEV_DEFAULT_ORIGINS[0];
|
||||
|
||||
const domain = read('MAIL_DOMAIN')?.trim().toLowerCase();
|
||||
const reportAddress =
|
||||
read('SHARE_REPORT_ADDRESS')?.trim() ||
|
||||
`postmaster@${domain || new URL(publicOrigin).hostname}`;
|
||||
|
||||
// 호스트로 가른다(nginx 는 포트 없는 Host 를 넘긴다) — 포털과 호스트 이름이 같으면(포트만 다른 개발 등) 가를 수 없어 둘 다 연다.
|
||||
// 운영은 env 검사가 같은 호스트를 막는다 (env.validation.ts)
|
||||
const hostOf = (origin: string) =>
|
||||
new URL(origin).hostname.toLowerCase().replace(/\.+$/, '');
|
||||
const publicHost = hostOf(publicOrigin);
|
||||
const portalHosts = new Set(buildAllowedOrigins(read).map(hostOf));
|
||||
return {
|
||||
publicOrigin,
|
||||
reportAddress,
|
||||
downloadLimit: Math.min(
|
||||
parsePositiveInt(
|
||||
read('SHARE_DOWNLOAD_LIMIT'),
|
||||
DEFAULT_SHARE_DOWNLOAD_LIMIT,
|
||||
),
|
||||
SHARE_DOWNLOAD_LIMIT_MAX,
|
||||
),
|
||||
dedicatedPublicHost: portalHosts.has(publicHost) ? null : publicHost,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { ShareLinkFileEntity } from './share-link-file.entity';
|
||||
import type { ShareLinkEntity } from './share-link.entity';
|
||||
|
||||
/**
|
||||
* 공유 링크 도메인 타입 — 계약 4절 "타입" 과 같은 모양이다 (11단계 대용량 첨부, 2부 파일 저장소의 공유 링크가 같은 표를 쓴다).
|
||||
*/
|
||||
|
||||
/** 링크 안 파일 하나의 상태 — 받는 쪽에서 본 것 */
|
||||
export type SharedFileStatus =
|
||||
/** 받을 수 있다 (검사에서 깨끗함) */
|
||||
| 'available'
|
||||
/** 받을 수 있다 — "검사하지 못함" */
|
||||
| 'unscannable'
|
||||
/** 검사 중 — 끝나면 받는다 (그동안 받기는 409 FILE_008) */
|
||||
| 'scanning'
|
||||
/** 위험 — 막았다 */
|
||||
| 'infected'
|
||||
/** 이 파일의 내려받기 상한에 닿았다 */
|
||||
| 'limit-reached'
|
||||
/** 보낸 사람(또는 운영)이 링크를 끊었다 */
|
||||
| 'revoked'
|
||||
/** 기간이 지났다 */
|
||||
| 'expired'
|
||||
/** 파일을 찾을 수 없다 (저장소 복원 등 — 드묾) */
|
||||
| 'missing';
|
||||
|
||||
/** 링크의 상태 — 취소한 링크는 없는 링크로 다루므로 여기 없다 */
|
||||
export type ShareLinkStatus = 'active' | 'expired' | 'revoked';
|
||||
|
||||
/** 링크 하나와 그 파일 (보낸 차례) */
|
||||
export interface ShareLinkRecord {
|
||||
readonly link: ShareLinkEntity;
|
||||
readonly files: ShareLinkFileEntity[];
|
||||
}
|
||||
|
||||
/** 링크의 파일 하나 — 보낸 사람이 보는 것 (ShareLink.files) */
|
||||
export interface SharedFile {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
readonly status: SharedFileStatus;
|
||||
readonly downloadCount: number;
|
||||
readonly lastDownloadedAt: string | null;
|
||||
}
|
||||
|
||||
/** 보낸 사람의 링크 하나 — 메일 제목(subject)은 메일 모듈이 엔진에서 읽어 더한다 (ShareLink) */
|
||||
export interface ShareLinkSummary {
|
||||
readonly id: string;
|
||||
readonly sentEmailId: string | null;
|
||||
readonly sentAt: string;
|
||||
readonly expiresAt: string;
|
||||
readonly status: ShareLinkStatus;
|
||||
/** 끊은 때 (보낸 사람 · 운영) — revoked 일 때만 값이 있다 */
|
||||
readonly revokedAt: string | null;
|
||||
readonly files: SharedFile[];
|
||||
}
|
||||
|
||||
/** POST /api/share-link-lookups 의 항목 — 누구의 링크든 상태 · 끝나는 날만 */
|
||||
export interface ShareLinkState {
|
||||
readonly url: string;
|
||||
/** unknown = 우리 링크 모양이 아님 · 모르는 · 보내기를 취소한 · 기록을 지운 링크 */
|
||||
readonly status: ShareLinkStatus | 'unknown';
|
||||
readonly expiresAt: string | null;
|
||||
}
|
||||
|
||||
/** 공개 화면의 파일 — 끝난 링크에는 없다 */
|
||||
export interface PublicLinkFile {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
readonly status: Exclude<SharedFileStatus, 'revoked' | 'expired'>;
|
||||
}
|
||||
|
||||
/** GET /api/public-links/{token} — 로그인 없음 */
|
||||
export interface PublicLink {
|
||||
readonly status: ShareLinkStatus;
|
||||
/** 보낼 때 로그인한 계정의 이름 · 주소 — 보낸 사람이 꾸밀 수 없다 */
|
||||
readonly sender: { readonly name: string | null; readonly email: string };
|
||||
readonly sentAt: string;
|
||||
readonly expiresAt: string;
|
||||
/** active 일 때만 — 끝난 링크는 빈 배열 (파일 이름도 보이지 않는다) */
|
||||
readonly files: PublicLinkFile[];
|
||||
/** 신고 · 문의 주소 */
|
||||
readonly reportAddress: string;
|
||||
}
|
||||
|
||||
/** 메일 발송이 만드는 링크 — 메일 모듈이 넘긴다 */
|
||||
export interface MailLinkInput {
|
||||
readonly ownerEmail: string;
|
||||
readonly senderName: string | null;
|
||||
/** 싣는 파일 (보낸 차례 — 파일 모듈이 확인한 것) */
|
||||
readonly files: readonly {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
}[];
|
||||
/** 보낸 때 (예약은 보낼 시각) — 끝나는 날은 이 날짜로 센다 */
|
||||
readonly sentAt: Date;
|
||||
/** 중복 발송 방지의 Message-ID (있을 때) */
|
||||
readonly messageId: string | null;
|
||||
}
|
||||
|
||||
/** 만든 링크 — 토큰 원문은 이때만 있다(본문 덩이에 넣는다) */
|
||||
export interface CreatedShareLink {
|
||||
readonly record: ShareLinkRecord;
|
||||
readonly token: string;
|
||||
readonly url: string;
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
request as expressRequest,
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from 'express';
|
||||
|
||||
import {
|
||||
createPublicHostMiddleware,
|
||||
hostnameOf,
|
||||
normalizeRequestPath,
|
||||
publicHostAllows,
|
||||
requestPathViews,
|
||||
} from './public-host';
|
||||
|
||||
const FILES = 'files.arasystem.test';
|
||||
const PORTAL = 'portal.arasystem.test';
|
||||
const TOKEN = 'Ab_-9Ab_-9Ab_-9Ab_-9Ab_-9Ab_-9Ab_-9Ab_-9xyz';
|
||||
const LINK = `/api/public-links/${TOKEN}`;
|
||||
const FILE_ID = '0b6f1f6e-1d2c-4c5b-9a1e-6c9f0a1b2c3d';
|
||||
/** 역슬래시 하나 — 요청 대상에 그대로 넣는다 */
|
||||
const BS = '\\';
|
||||
|
||||
/**
|
||||
* 실제 Express 요청 — 라우터가 쓰는 경로(req.path)를 Express 의 getter(parseurl)로 읽는다.
|
||||
* 앱의 요청도 이 prototype 을 잇는다 (express 의 app.handle 이 Object.setPrototypeOf(req, app.request))
|
||||
*/
|
||||
const expressRequestOf = (
|
||||
url: string,
|
||||
headers: Record<string, string> = {},
|
||||
): Request =>
|
||||
Object.assign(Object.create(expressRequest) as Request, { url, headers });
|
||||
|
||||
/** 이 Host · 요청 대상을 열까 — 라우터가 쓰는 경로는 실제 Express 로 읽는다 */
|
||||
const allows = (
|
||||
host: string | undefined,
|
||||
url: string,
|
||||
dedicated: string | null = FILES,
|
||||
) =>
|
||||
publicHostAllows(
|
||||
{ host, url, routedPath: expressRequestOf(url).path },
|
||||
dedicated,
|
||||
);
|
||||
|
||||
/** 여러 대상의 판정 — 실패하면 어느 대상인지 보이게 묶는다 */
|
||||
const verdictsOf = (host: string | undefined, urls: readonly string[]) =>
|
||||
urls.map((url) => ({ url, allowed: allows(host, url) }));
|
||||
const expectAll = (
|
||||
host: string | undefined,
|
||||
urls: readonly string[],
|
||||
allowed: boolean,
|
||||
) =>
|
||||
expect(verdictsOf(host, urls)).toEqual(urls.map((url) => ({ url, allowed })));
|
||||
|
||||
describe('publicHostAllows (공개 호스트 막기)', () => {
|
||||
it('공개 호스트가 포털과 같으면(개발) 늘 연다', () => {
|
||||
expect(allows('localhost:3000', LINK, null)).toBe(true);
|
||||
expect(allows('localhost:3000', '/api/messages', null)).toBe(true);
|
||||
expect(allows('localhost:3000', `http://x${LINK}`, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('공개 호스트에서는 공개 링크 API 만 — 포트 · 대소문자는 보지 않는다', () => {
|
||||
expect(allows(FILES, LINK)).toBe(true);
|
||||
expect(allows(`${FILES.toUpperCase()}:443`, `${LINK}/files/x`)).toBe(true);
|
||||
expect(allows(FILES, LINK.toUpperCase())).toBe(true);
|
||||
expectAll(FILES, ['/api/auth/login', '/api/messages?q=1', '/'], false);
|
||||
});
|
||||
|
||||
it('포털 · 그 밖의 호스트에서는 공개 링크 API 를 닫는다', () => {
|
||||
expect(allows(PORTAL, LINK)).toBe(false);
|
||||
expect(allows(PORTAL, '/api/messages')).toBe(true);
|
||||
expect(allows(undefined, LINK)).toBe(false);
|
||||
expect(allows('[::1]:3000', '/api/health')).toBe(true);
|
||||
});
|
||||
|
||||
it('평소 요청은 그대로 지난다 (회귀) — 포털의 API · 화면, 공개 호스트의 공개 링크 정보 · 파일', () => {
|
||||
expectAll(
|
||||
PORTAL,
|
||||
[
|
||||
'/',
|
||||
'/api/auth/me',
|
||||
'/api/health',
|
||||
'/api/messages?q=%2Fapi%2Fpublic-links%2Fx&cursor=abc',
|
||||
// 쿼리의 `#` · `\` · 인코딩한 경로는 경로가 아니다
|
||||
'/api/messages?q=#/api/public-links/x',
|
||||
`/api/messages/M1?view=${BS}api${BS}public-links${BS}x`,
|
||||
'/api/mail-events',
|
||||
`/api/file-uploads/${FILE_ID}/chunks/0`,
|
||||
`/api/share-links/lookup?url=https%3A%2F%2F${FILES}%2Fs%2F${TOKEN}`,
|
||||
],
|
||||
true,
|
||||
);
|
||||
expectAll(
|
||||
FILES,
|
||||
[
|
||||
LINK,
|
||||
`${LINK}?x=1`,
|
||||
`${LINK}/`,
|
||||
`${LINK}/files/${FILE_ID}`,
|
||||
`${LINK}/files/${FILE_ID}?download=1`,
|
||||
],
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('경로의 여러 해석 — 원문 · 라우터(req.path) · WHATWG URL · 퍼센트 인코딩을 푼 모양 (2026-10-30)', () => {
|
||||
it('requestPathViews — 원문(? 앞) · 라우터가 쓰는 경로 · WHATWG pathname · 인코딩을 한 번 푼 원문', () => {
|
||||
expect(requestPathViews('/api/x?q=1', '/api/x')).toEqual([
|
||||
'/api/x',
|
||||
'/api/x',
|
||||
'/api/x',
|
||||
'/api/x',
|
||||
]);
|
||||
expect(
|
||||
requestPathViews(
|
||||
`http://${FILES}/api/public-links/x?y=1`,
|
||||
'/api/public-links/x',
|
||||
),
|
||||
).toEqual([
|
||||
`http://${FILES}/api/public-links/x`,
|
||||
'/api/public-links/x',
|
||||
'/api/public-links/x',
|
||||
`http://${FILES}/api/public-links/x`,
|
||||
]);
|
||||
expect(
|
||||
requestPathViews(`/api${BS}public-links${BS}x`, null).slice(2),
|
||||
).toEqual(['/api/public-links/x', `/api${BS}public-links${BS}x`]);
|
||||
expect(requestPathViews('/api%2Fpublic-links/%2e%2e', null)[3]).toBe(
|
||||
'/api/public-links/..',
|
||||
);
|
||||
// 읽지 못한 모양은 null
|
||||
expect(requestPathViews('//[/api', '//[/api')[2]).toBeNull();
|
||||
});
|
||||
|
||||
it('라우터는 절대 주소 꼴 · `#` 이 섞인 `\\` 를 원문과 다른 경로로 읽는다 (Express 5 — 막기가 이 모양을 함께 보는 까닭)', () => {
|
||||
expect(expressRequestOf(`http://${FILES}${LINK}`).path).toBe(LINK);
|
||||
expect(expressRequestOf(`HTTPS://user@${FILES}:443${LINK}?y=1`).path).toBe(
|
||||
LINK,
|
||||
);
|
||||
expect(expressRequestOf(`/api${BS}public-links${BS}${TOKEN}#a`).path).toBe(
|
||||
LINK,
|
||||
);
|
||||
// `#` 이 없으면 `\` 를 그대로 둔다 (parseurl 의 빠른 길)
|
||||
expect(expressRequestOf(`/api${BS}public-links${BS}${TOKEN}`).path).toBe(
|
||||
`/api${BS}public-links${BS}${TOKEN}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('절대 주소 꼴(absolute-form) — 포털에서는 공개 링크 경로면 닫는다(라우터가 그 경로로 보낸다), 공개 호스트는 이 꼴을 받지 않는다', () => {
|
||||
expectAll(
|
||||
PORTAL,
|
||||
[
|
||||
`http://${FILES}${LINK}`,
|
||||
`https://${FILES}${LINK}?y=1`,
|
||||
`HTTP://${FILES.toUpperCase()}${LINK}`,
|
||||
`http://user@${FILES}${LINK}`,
|
||||
`http://${FILES}:80${LINK}/files/${FILE_ID}`,
|
||||
`http://${PORTAL}/API/Public-Links/${TOKEN}`,
|
||||
// WHATWG URL 은 못 읽지만(포트가 범위 밖) 라우터는 공개 링크 경로로 읽는다
|
||||
`http://${FILES}:99999${LINK}`,
|
||||
],
|
||||
false,
|
||||
);
|
||||
// 공개 링크가 아닌 경로는 포털 그대로
|
||||
expect(allows(PORTAL, `http://${PORTAL}/api/messages`)).toBe(true);
|
||||
|
||||
expectAll(
|
||||
FILES,
|
||||
[
|
||||
`http://${FILES}${LINK}`,
|
||||
`https://${FILES}${LINK}`,
|
||||
`http://${PORTAL}/api/auth/me`,
|
||||
],
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('역슬래시 — 포털에서는 어느 해석으로든 공개 링크 경로면 닫고, 공개 호스트에서는 모든 해석이 공개 링크 경로여야 연다', () => {
|
||||
expectAll(
|
||||
PORTAL,
|
||||
[
|
||||
`/api${BS}public-links${BS}${TOKEN}#a`, // 라우터가 공개 링크로 보낸다
|
||||
`/api${BS}public-links${BS}${TOKEN}`, // WHATWG 가 `/` 로 읽는다
|
||||
`/api${BS}public-links${BS}${TOKEN}?#`,
|
||||
`${BS}api${BS}public-links${BS}${TOKEN}`,
|
||||
`/api/public-links${BS}${TOKEN}`,
|
||||
`/api/users/..${BS}public-links/${TOKEN}`,
|
||||
],
|
||||
false,
|
||||
);
|
||||
expectAll(
|
||||
FILES,
|
||||
[
|
||||
`/api/public-links${BS}${TOKEN}`,
|
||||
`${LINK}${BS}..${BS}..${BS}auth${BS}me`,
|
||||
`${BS}api${BS}public-links${BS}${TOKEN}`,
|
||||
],
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('퍼센트 인코딩(%2F · %2e%2e) — 앞단 nginx · WHATWG 가 푸는 모양까지 본다', () => {
|
||||
expectAll(
|
||||
PORTAL,
|
||||
[
|
||||
`/api%2Fpublic-links/${TOKEN}`,
|
||||
`/api%2fpublic-links%2f${TOKEN}`,
|
||||
`/api/%2e%2e/api/public-links/${TOKEN}`,
|
||||
`/api/.%2E/api/public-links/${TOKEN}`,
|
||||
`/api/users/%2E%2E/public-links/${TOKEN}`,
|
||||
`/%61pi/public-links/${TOKEN}`,
|
||||
],
|
||||
false,
|
||||
);
|
||||
expectAll(
|
||||
FILES,
|
||||
[
|
||||
`${LINK}/%2e%2e/%2e%2e/auth/me`,
|
||||
`${LINK}%2F..%2F..%2Fauth%2Fme`,
|
||||
`/api/public-links/%2E%2E/%2E%2E/auth/me`,
|
||||
`/api/public-links%2F${TOKEN}`,
|
||||
],
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('대소문자 · 겹친 / · 끝 /', () => {
|
||||
expectAll(
|
||||
PORTAL,
|
||||
[
|
||||
`/API/Public-Links/${TOKEN}`,
|
||||
`//api//public-links/${TOKEN}`,
|
||||
`/api//public-links/${TOKEN}`,
|
||||
`${LINK}/`,
|
||||
`${LINK}//`,
|
||||
],
|
||||
false,
|
||||
);
|
||||
expectAll(FILES, [`/API/PUBLIC-LINKS/${TOKEN}`, `${LINK}/`], true);
|
||||
// `//api/…` 는 WHATWG 가 api 를 호스트로 읽는다 — 모든 해석이 공개 링크 경로가 아니다
|
||||
expectAll(
|
||||
FILES,
|
||||
[`//api//public-links/${TOKEN}`, `//api/public-links/${TOKEN}`],
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('읽지 못한 모양이 있으면 어느 호스트에서도 열지 않는다 (닫힌 쪽)', () => {
|
||||
expect(allows(PORTAL, '//[/api')).toBe(false);
|
||||
expect(
|
||||
publicHostAllows({ host: FILES, url: LINK, routedPath: null }, FILES),
|
||||
).toBe(false);
|
||||
expect(
|
||||
publicHostAllows(
|
||||
{ host: PORTAL, url: '/api/messages', routedPath: null },
|
||||
FILES,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('공개 호스트 막기 — 겹친 / · . · .. 조각과 Host 의 끝 점 · 포트 · 대문자 (nginx 의 정규화에 기대지 않는다 — 2026-10-30 리뷰 L1)', () => {
|
||||
it('normalizeRequestPath — 쿼리를 떼고 겹친 / 를 하나로, . 조각은 지우고 .. 은 앞 조각을 지운다(소문자)', () => {
|
||||
expect(normalizeRequestPath('//api//public-links/x?q=1')).toBe(
|
||||
'/api/public-links/x',
|
||||
);
|
||||
expect(normalizeRequestPath('/api/./public-links/x/')).toBe(
|
||||
'/api/public-links/x/',
|
||||
);
|
||||
expect(normalizeRequestPath('/API/users/../Public-Links/x')).toBe(
|
||||
'/api/public-links/x',
|
||||
);
|
||||
expect(normalizeRequestPath('/../../api')).toBe('/api');
|
||||
expect(normalizeRequestPath('/')).toBe('/');
|
||||
// 퍼센트 인코딩은 풀지 않는다 — 푼 모양은 requestPathViews 가 따로 낸다
|
||||
expect(normalizeRequestPath('/api/%2e/public-links/x')).toBe(
|
||||
'/api/%2e/public-links/x',
|
||||
);
|
||||
});
|
||||
|
||||
it('hostnameOf — 포트 · 대소문자 · 끝의 점 · 앞뒤 공백을 뗀다', () => {
|
||||
expect(hostnameOf('FILES.arasystem.test.:443')).toBe(FILES);
|
||||
expect(hostnameOf('files.arasystem.test..')).toBe(FILES);
|
||||
expect(hostnameOf(' Files.Arasystem.Test:8443 ')).toBe(FILES);
|
||||
expect(hostnameOf('[::1]:3000')).toBe('[::1]');
|
||||
expect(hostnameOf(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('포털 호스트에서는 정규화한 모양 가운데 하나라도 공개 링크 경로면 닫는다', () => {
|
||||
expectAll(
|
||||
PORTAL,
|
||||
[
|
||||
'//api/public-links/x',
|
||||
'/api//public-links/x',
|
||||
'/api/./public-links/x',
|
||||
'/api/users/../public-links/x',
|
||||
'/API/./PUBLIC-LINKS/x',
|
||||
],
|
||||
false,
|
||||
);
|
||||
expect(allows(PORTAL, '/api//messages')).toBe(true);
|
||||
});
|
||||
|
||||
it('Host 의 끝 점 · 포트 · 대문자 — 공개 호스트 이름이 같으면 같은 판정 (설정 값의 끝 점 · 대문자도)', () => {
|
||||
for (const host of [
|
||||
`${FILES}.`,
|
||||
`${FILES}..`,
|
||||
`${FILES.toUpperCase()}.:443`,
|
||||
`${FILES}:8443`,
|
||||
` ${FILES} `,
|
||||
]) {
|
||||
expect({ host, link: allows(host, LINK) }).toEqual({ host, link: true });
|
||||
expect({ host, me: allows(host, '/api/auth/me') }).toEqual({
|
||||
host,
|
||||
me: false,
|
||||
});
|
||||
}
|
||||
expect(allows(FILES.toUpperCase(), LINK, `${FILES}.`)).toBe(true);
|
||||
expect(allows(FILES, '/api/auth/me', FILES.toUpperCase())).toBe(false);
|
||||
// 포털 이름에 끝 점 · 포트 · 대문자가 붙어도 포털이다
|
||||
for (const host of [
|
||||
`${PORTAL}.`,
|
||||
`${PORTAL.toUpperCase()}:443`,
|
||||
'files.arasystem.test.evil',
|
||||
]) {
|
||||
expect({ host, link: allows(host, LINK) }).toEqual({ host, link: false });
|
||||
expect({ host, mail: allows(host, '/api/messages') }).toEqual({
|
||||
host,
|
||||
mail: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('공개 호스트에서는 모든 모양이 공개 링크 경로일 때만 연다', () => {
|
||||
expectAll(
|
||||
FILES,
|
||||
[
|
||||
`${LINK}/../../users`,
|
||||
'/api/public-links/../auth/me',
|
||||
'/api/public-links/x#/../../auth/me',
|
||||
],
|
||||
false,
|
||||
);
|
||||
expect(allows(FILES, `${LINK}/./files/x`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPublicHostMiddleware', () => {
|
||||
const setup = () => {
|
||||
const middleware = createPublicHostMiddleware(FILES);
|
||||
const next = jest.fn() as NextFunction;
|
||||
const json = jest.fn();
|
||||
const status = jest.fn().mockReturnValue({ json });
|
||||
const res = { setHeader: jest.fn(), status } as unknown as Response;
|
||||
return { middleware, next, json, status, res };
|
||||
};
|
||||
const request = (url: string, host: string) =>
|
||||
expressRequestOf(url, { host, 'x-request-id': 'trace-1' });
|
||||
|
||||
it('막은 요청은 표준 실패 형식의 404 RES_001 (traceId 포함), 연 요청은 다음으로', () => {
|
||||
const { middleware, next, json, status, res } = setup();
|
||||
|
||||
middleware(request(LINK, FILES), res, next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
|
||||
middleware(request('/api/auth/me', FILES), res, next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
const [body] = json.mock.calls[0] as [
|
||||
{ success: boolean; error: { code: string; traceId?: string } },
|
||||
];
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error.code).toBe('RES_001');
|
||||
expect(body.error.traceId).toBe('trace-1');
|
||||
});
|
||||
|
||||
it('라우터가 쓰는 경로(req.path)를 함께 본다 — 포털에 절대 주소 꼴 · `\\…#` 로 보낸 공개 링크 경로도 404', () => {
|
||||
const { middleware, next, status, res } = setup();
|
||||
|
||||
middleware(request(`http://${FILES}${LINK}`, PORTAL), res, next);
|
||||
middleware(
|
||||
request(`/api${BS}public-links${BS}${TOKEN}#a`, PORTAL),
|
||||
res,
|
||||
next,
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
|
||||
middleware(request('/api/messages', PORTAL), res, next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('공개 호스트가 포털과 같으면(개발) 경로를 읽지 않고 다음으로', () => {
|
||||
const next = jest.fn() as NextFunction;
|
||||
createPublicHostMiddleware(null)(
|
||||
request(`http://${FILES}${LINK}`, PORTAL),
|
||||
{} as Response,
|
||||
next,
|
||||
);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { getErrorDefinition } from '@/common/errors/error-codes';
|
||||
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
|
||||
|
||||
/** 공개 링크 API 경로 (`/api/public-links/…`) — 로그인 없이 바깥 사람이 부른다 */
|
||||
export const PUBLIC_LINKS_ROUTE = 'public-links';
|
||||
|
||||
const PUBLIC_API_PREFIX = `/api/${PUBLIC_LINKS_ROUTE}/`;
|
||||
|
||||
/** WHATWG URL 로 요청 대상을 읽을 때의 바탕 주소 — 경로만 쓴다 */
|
||||
const PATH_BASE_URL = 'http://localhost';
|
||||
|
||||
/**
|
||||
* Host 머리글의 호스트 이름 (포트 없이, 소문자, 끝의 점 없이 — `files.arasystem.it.kr.` 도 같은 이름) — `[::1]:3000` 같은 IPv6 도
|
||||
*/
|
||||
export function hostnameOf(host: string | undefined): string {
|
||||
const value = (host ?? '').trim().toLowerCase();
|
||||
if (value.startsWith('[')) {
|
||||
const end = value.indexOf(']');
|
||||
return end < 0 ? value : value.slice(0, end + 1);
|
||||
}
|
||||
return value.split(':')[0].replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
/** 경로 하나를 견줄 모양으로 — 겹친 `/` 를 하나로, `.` 조각을 지우고 `..` 은 앞 조각을 지운 뒤 소문자로 (쿼리는 떼지 않는다 — 이미 경로다) */
|
||||
function normalizePath(path: string): string {
|
||||
const segments: string[] = [];
|
||||
for (const segment of path.split('/')) {
|
||||
if (segment === '' || segment === '.') continue;
|
||||
if (segment === '..') {
|
||||
segments.pop();
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
const trailingSlash = segments.length > 0 && path.endsWith('/') ? '/' : '';
|
||||
return `/${segments.join('/')}${trailingSlash}`.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 견줄 경로 (순수 함수) — 쿼리를 떼고, 겹친 `/` 를 하나로, `.` 조각을 지우고 `..` 은 앞 조각을 지운 뒤 소문자로.
|
||||
* 퍼센트 인코딩은 풀지 않는다(`%2e` 는 `.` 조각이 아니다) — 푼 모양 · `\` 를 `/` 로 읽는 모양은 requestPathViews 가 따로 낸다
|
||||
*/
|
||||
export function normalizeRequestPath(url: string): string {
|
||||
return normalizePath(url.split('?')[0]);
|
||||
}
|
||||
|
||||
/** `%XX` 를 한 번 푼다 — 하나씩 글자 하나로(바이트 그대로 — 견줄 글자는 ASCII 뿐이다). 틀린 모양은 그대로 둔다(던지지 않는다) */
|
||||
function percentDecodeOnce(path: string): string {
|
||||
return path.replace(/%([0-9a-f]{2})/gi, (_match, hex: string) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||
);
|
||||
}
|
||||
|
||||
/** WHATWG URL 의 pathname — 읽지 못하면 null */
|
||||
function whatwgPathname(url: string): string | null {
|
||||
try {
|
||||
return new URL(url, PATH_BASE_URL).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 요청의 경로를 스택이 읽을 수 있는 모양들 (순수 함수 — 정규화 전). 읽지 못한 모양은 null — 부르는 쪽이 닫는다.
|
||||
* 1. 원문(`?` 앞) 그대로
|
||||
* 2. 라우터가 쓰는 경로 — Express 의 req.path(parseurl), 미들웨어가 넘긴다(routedPath).
|
||||
* 절대 주소 꼴(`GET http://호스트/경로 HTTP/1.1` — absolute-form)은 스킴 · 호스트를 뗀 경로로 읽고, `#` · 공백 등이 섞이면 Node 의 옛 url.parse 로 읽어
|
||||
* `#` 뒤를 버리고 `?` · `#` 앞의 `\` 를 `/` 로 본다 — 원문과 다른 경로로 라우팅한다
|
||||
* (2026-10-30 확인 — Express 5.2.1 · router 2.2.0 · parseurl 1.3.3: `GET http://…/api/public-links/x` · `GET /api\public-links\x#a` 가 공개 링크 컨트롤러에 닿는다)
|
||||
* 3. WHATWG URL 의 pathname — `\` 를 `/` 로, `%2e` 조각을 `.` 으로 읽어 `.` · `..` 을 풀고, `//이름/…` 은 이름을 호스트로 읽는다
|
||||
* 4. 퍼센트 인코딩을 한 번 푼 원문 — 앞단 nginx 가 location 을 고르는 모양(`%2F` 도 `/`)
|
||||
*/
|
||||
export function requestPathViews(
|
||||
url: string,
|
||||
routedPath: string | null,
|
||||
): readonly (string | null)[] {
|
||||
const raw = url.split('?')[0];
|
||||
return [raw, routedPath, whatwgPathname(url), percentDecodeOnce(raw)];
|
||||
}
|
||||
|
||||
/** 공개 호스트 막기가 보는 요청 */
|
||||
export interface PublicHostRequest {
|
||||
/** Host 머리글 */
|
||||
readonly host: string | undefined;
|
||||
/** 요청 줄의 대상 그대로 (req.url — 쿼리 포함, 절대 주소 꼴이면 스킴 · 호스트도) */
|
||||
readonly url: string;
|
||||
/** 라우터가 쓰는 경로 (Express 의 req.path) — 읽지 못했으면 null */
|
||||
readonly routedPath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 호스트를 따로 둘 때(계약 4절 "공개 호스트") 이 요청을 열까 — 공개 호스트에서는 공개 링크 API 만, 그 밖의 호스트에서는 공개 링크 API 를 닫는다.
|
||||
* 공개 호스트가 포털과 같은 주소면(dedicatedPublicHost 가 null — 개발) 늘 연다.
|
||||
*
|
||||
* 경로는 스택이 읽을 수 있는 **모든 모양**(requestPathViews — 원문 · 라우터가 쓰는 경로 · WHATWG URL · 퍼센트 인코딩을 푼 모양)을
|
||||
* 정규화해(겹친 `/` · `.` · `..` 조각, 대소문자) 본다: 공개 호스트에서는 모든 모양이 공개 링크 경로일 때만 열고,
|
||||
* 그 밖의 호스트에서는 어느 모양도 공개 링크 경로가 아닐 때만 연다. 읽지 못한 모양이 있으면 어느 쪽에서도 열지 않는다 — 그 모양으로는 어디로 갈지
|
||||
* 알 수 없다(닫힌 쪽 — 예: 범위 밖 포트의 절대 주소 꼴은 WHATWG URL 이 못 읽어도 라우터는 공개 링크로 보낸다. 브라우저 · nginx 가 보내는 요청에서는 생기지 않는다).
|
||||
* 그래서 공개 호스트는 절대 주소 꼴(absolute-form)을 받지 않는다 — 원문이 경로가 아니다. 브라우저는 서버에 그 꼴을 보내지 않고 앞단 nginx 도 경로만 넘긴다.
|
||||
* 앞단 nginx 는 merge_slashes(기본 켜짐)로 겹친 `/` 를 합치고 `.` · `..` 조각을 풀어 넘기지만, 백엔드는 그것에 기대지 않는다
|
||||
* (nginx 를 거치지 않는 요청 · 설정이 바뀐 앞단에서도 같은 판정 — 2026-10-30 리뷰 L1, 절대 주소 꼴 · `\` 는 같은 날 메인 검토)
|
||||
*/
|
||||
export function publicHostAllows(
|
||||
request: PublicHostRequest,
|
||||
dedicatedPublicHost: string | null,
|
||||
): boolean {
|
||||
if (dedicatedPublicHost === null) return true;
|
||||
const verdicts = requestPathViews(request.url, request.routedPath).map(
|
||||
(view) =>
|
||||
view === null ? null : normalizePath(view).startsWith(PUBLIC_API_PREFIX),
|
||||
);
|
||||
const onPublicHost =
|
||||
hostnameOf(request.host) ===
|
||||
dedicatedPublicHost.toLowerCase().replace(/\.+$/, '');
|
||||
return onPublicHost
|
||||
? verdicts.every((isPublic) => isPublic === true)
|
||||
: verdicts.every((isPublic) => isPublic === false);
|
||||
}
|
||||
|
||||
/** 라우터가 쓰는 경로 — Express 의 req.path(parseurl). 읽지 못하면(던지면) null */
|
||||
function routedPathOf(req: Request): string | null {
|
||||
try {
|
||||
const path: unknown = req.path;
|
||||
return typeof path === 'string' ? path : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 호스트 막기 (express 미들웨어 — app.setup.ts 의 configureApp 이 요청 추적 ID 바로 뒤, 본문 파서 앞에 붙인다).
|
||||
* 막은 요청은 표준 실패 형식의 404 RES_001 — 로그를 남기지 않는다(봇이 두드리는 주소). 앞단 nginx(호스트마다 server)와 두 겹이다 (release-ops)
|
||||
*/
|
||||
export function createPublicHostMiddleware(
|
||||
dedicatedPublicHost: string | null,
|
||||
): (req: Request, res: Response, next: NextFunction) => void {
|
||||
return (req, res, next) => {
|
||||
if (
|
||||
dedicatedPublicHost === null ||
|
||||
publicHostAllows(
|
||||
{ host: req.headers.host, url: req.url, routedPath: routedPathOf(req) },
|
||||
dedicatedPublicHost,
|
||||
)
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const traceId = req.header(REQUEST_ID_HEADER) ?? undefined;
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.status(HttpStatus.NOT_FOUND).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'RES_001',
|
||||
message: getErrorDefinition('RES_001').message,
|
||||
...(traceId ? { traceId } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiProduces,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { PublicLinkDto } from './dto/share-link.dto';
|
||||
import { FileSharingService } from './file-sharing.service';
|
||||
import { PUBLIC_LINKS_ROUTE } from './public-host';
|
||||
|
||||
/** 공개 경로의 요청 수 한도 — IP 마다(IPv6 는 /64) 1분: 링크 정보 60회 · 파일 300회(HEAD 포함 — 내려받기 관리자가 범위를 나눠 부른다) */
|
||||
export const PUBLIC_LINK_THROTTLE_TTL_MS = 60_000;
|
||||
export const PUBLIC_LINK_INFO_THROTTLE_LIMIT = 60;
|
||||
export const PUBLIC_FILE_THROTTLE_LIMIT = 300;
|
||||
|
||||
/** 공개 경로의 예상된 실패(없는 링크 404 · 검사 중 409 · 끝난 링크 410)는 debug 로만 — 받는 사람 · 봇이 잦게 부른다 */
|
||||
const QUIET_PUBLIC_FAILURES = [
|
||||
HttpStatus.NOT_FOUND,
|
||||
HttpStatus.CONFLICT,
|
||||
HttpStatus.GONE,
|
||||
];
|
||||
|
||||
/** 공개 화면 · 링크 정보 응답 — 검색에 걸리지 않게, 나가는 요청에 주소가 실리지 않게 */
|
||||
function writePublicHeaders(res: Response): void {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Robots-Tag', 'noindex');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 내려받기 (11단계 — 계약 4절 "공개 내려받기") — **로그인 없음**. 바깥 거래처 · 내려받기 관리자 · 받는 쪽 보안 장비가 부른다.
|
||||
* 쿠키를 쓰지 않는다(읽지도 않는다 — 가드 없음). 상태를 바꾸지 않는 GET · HEAD 라 출처 검사도 해당 없다.
|
||||
* 응답에 실리는 메일 정보는 보낸 사람 이름 · 주소뿐이다 — 제목 · 본문 · 받는 사람 · 메일 id 는 싣지 않는다.
|
||||
*
|
||||
* 404 · 409 · 410 은 표준 실패 형식이지만 이 컨트롤러의 필터가 쓴다 — 로그는 debug 만(외부 이미지 중계와 같은 결). 5xx 는 지금처럼 error.
|
||||
* 공개 호스트를 따로 두면 그 호스트에서는 이 경로만 열린다 (public-host.ts — configureApp)
|
||||
*/
|
||||
@ApiTags('Public links')
|
||||
@UseFilters(new HttpExceptionFilter({ quietStatuses: QUIET_PUBLIC_FAILURES }))
|
||||
@Controller(PUBLIC_LINKS_ROUTE)
|
||||
export class PublicLinkController {
|
||||
constructor(private readonly sharing: FileSharingService) {}
|
||||
|
||||
@Get(':token')
|
||||
@Throttle({
|
||||
default: {
|
||||
ttl: PUBLIC_LINK_THROTTLE_TTL_MS,
|
||||
limit: PUBLIC_LINK_INFO_THROTTLE_LIMIT,
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: '공개 링크 정보 (로그인 없음)',
|
||||
description:
|
||||
'보낸 사람 이름 · 주소, 보낸 때 · 끝나는 때, 파일 목록(active 일 때만 — 끝난 링크는 빈 배열), 신고 · 문의 주소. ' +
|
||||
'모양이 틀린 · 모르는 토큰, 보내기를 취소한 링크, 기록을 지운 링크(끝난 뒤 90일)는 404. 기간 지남 · 끊음은 200 의 status. ' +
|
||||
`Cache-Control: no-store · X-Robots-Tag: noindex · Referrer-Policy: no-referrer. 요청 수는 IP 마다 1분 ${PUBLIC_LINK_INFO_THROTTLE_LIMIT}회.`,
|
||||
})
|
||||
@ApiStandardResponse(PublicLinkDto, { description: '링크 정보' })
|
||||
@ApiErrorResponse('RES_001', '없는 링크')
|
||||
@ApiErrorResponse('REQ_001', '요청 수 한도')
|
||||
info(
|
||||
@Param('token') token: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
writePublicHeaders(res);
|
||||
return this.sharing.publicLink(token);
|
||||
}
|
||||
|
||||
@Get(':token/files/:fileId')
|
||||
@SkipTransform()
|
||||
@Throttle({
|
||||
default: {
|
||||
ttl: PUBLIC_LINK_THROTTLE_TTL_MS,
|
||||
limit: PUBLIC_FILE_THROTTLE_LIMIT,
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'공개 파일 받기 (로그인 없음 · GET · HEAD · Range — 표준 래퍼 없음)',
|
||||
description:
|
||||
'늘 application/octet-stream + Content-Disposition: attachment + nosniff + sandbox CSP + private, no-store. 강한 ETag("<fileId>") · Last-Modified · Accept-Ranges: bytes. ' +
|
||||
'Range 하나면 206, 여럿 · 형식 오류면 200 전체, 처음이 크기 이상이면 416(본문 없음). HEAD 는 머리글만(세지 않는다). ' +
|
||||
'받은 수는 GET 이고 0번째 바이트부터 보낸 응답만 센다 — 상한에 닿으면 이어 받기도 410. ' +
|
||||
`실패: 없는 링크 · 그 링크에 없는 파일 404, 검사 중 409 FILE_008, 기간 지남 · 끊음 · 위험 · 횟수 초과 · 찾을 수 없음 410 FILE_007. 요청 수는 IP 마다 1분 ${PUBLIC_FILE_THROTTLE_LIMIT}회.`,
|
||||
})
|
||||
@ApiProduces('application/octet-stream')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '파일 (206 — 범위)',
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
|
||||
description: '받을 수 없는 범위 — Content-Range: bytes */<크기>, 본문 없음',
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '없는 링크 · 그 링크에 없는 파일')
|
||||
@ApiErrorResponse('FILE_008', '보안 검사 중 — 끝나면 받는다')
|
||||
@ApiErrorResponse(
|
||||
'FILE_007',
|
||||
'기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음',
|
||||
)
|
||||
@ApiErrorResponse('REQ_001', '요청 수 한도')
|
||||
async file(
|
||||
@Param('token') token: string,
|
||||
@Param('fileId') fileId: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const record = await this.sharing.requirePublicRecord(token);
|
||||
await this.sharing.serveFile(req, res, record, fileId.toLowerCase(), {
|
||||
count: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { BIGINT_NUMBER } from '@/shared/utils/bigint-column';
|
||||
import { ShareLinkEntity } from './share-link.entity';
|
||||
|
||||
/** 정리한 파일 이름 — 코드 포인트 255자 */
|
||||
const NAME_COLUMN_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* 링크의 파일 하나 — 보낸 차례(position), 받은 수 · 마지막으로 받은 때(누가 · IP 는 남기지 않는다).
|
||||
* 이름 · 크기는 보낼 때의 값을 함께 둔다 — 파일 기록(file-storage)이 먼저 지워져도 링크 기록(목록 · 공개 화면의 끝난 링크)이 남게.
|
||||
* 파일 표와 외래 키로 묶지 않는다 — 두 모듈의 정리 작업이 서로를 기다리지 않게 (파일 기록이 없으면 "찾을 수 없음")
|
||||
*/
|
||||
@Entity({ name: 'share_link_files' })
|
||||
@Index('IDX_share_link_files_file', ['fileId'])
|
||||
export class ShareLinkFileEntity {
|
||||
@PrimaryColumn({ name: 'link_id', type: 'uuid' })
|
||||
linkId!: string;
|
||||
|
||||
@PrimaryColumn({ name: 'file_id', type: 'uuid' })
|
||||
fileId!: string;
|
||||
|
||||
/** 보낸 차례 (0 부터) */
|
||||
@Column({ type: 'smallint' })
|
||||
position!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: NAME_COLUMN_LENGTH })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'bigint', transformer: BIGINT_NUMBER })
|
||||
size!: number;
|
||||
|
||||
/** 받은 수 — 0번째 바이트부터 보내기 시작한 GET 만 센다 */
|
||||
@Column({ name: 'download_count', type: 'integer', default: 0 })
|
||||
downloadCount!: number;
|
||||
|
||||
@Column({ name: 'last_downloaded_at', type: 'timestamptz', nullable: true })
|
||||
lastDownloadedAt!: Date | null;
|
||||
|
||||
@ManyToOne(() => ShareLinkEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'link_id' })
|
||||
link?: ShareLinkEntity;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
ShareLinkLookupRequestDto,
|
||||
ShareLinkLookupResultDto,
|
||||
} from './dto/share-link.dto';
|
||||
import { FileSharingService } from './file-sharing.service';
|
||||
|
||||
/** 인용 속 링크의 상태 경로 (`/api/share-link-lookups`) */
|
||||
export const SHARE_LINK_LOOKUPS_ROUTE = 'share-link-lookups';
|
||||
|
||||
/**
|
||||
* 인용 속 링크의 상태 (11단계 — 계약 4절 "인용 속 링크의 상태") — 쓰기 화면(전달 · 답장)이 인용에서 찾은 공개 화면 주소마다 상태 · 끝나는 날만.
|
||||
* 누구의 링크든 같다(이름 · 보낸 사람 · 받은 수는 주지 않는다). 상태를 바꾸지 않지만 주소(토큰)를 쿼리에 싣지 않으려고 POST 본문으로 받는다.
|
||||
* 로그에는 개수만 — 본문(주소)은 남기지 않는다 (계약 4절 "로그")
|
||||
*/
|
||||
@ApiTags('Files')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(SHARE_LINK_LOOKUPS_ROUTE)
|
||||
export class ShareLinkLookupController {
|
||||
constructor(private readonly sharing: FileSharingService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '인용 속 대용량 첨부 링크의 상태',
|
||||
description:
|
||||
'`<SHARE_PUBLIC_URL>/s/<토큰>` 모양이 아니거나 모르는 · 보내기를 취소한 · 기록을 지운 링크는 unknown. 파일마다의 상태(검사 중 · 위험 등)는 주지 않는다.',
|
||||
})
|
||||
@ApiStandardResponse(ShareLinkLookupResultDto, {
|
||||
description: '링크마다 상태 (요청 차례 그대로)',
|
||||
})
|
||||
@ApiErrorResponse('VAL_001', '1~20개 · 겹침 · 한 개 300자 초과')
|
||||
async lookup(
|
||||
@Body() dto: ShareLinkLookupRequestDto,
|
||||
): Promise<ShareLinkLookupResultDto> {
|
||||
return { links: await this.sharing.lookup(dto.urls) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 표시 이름 — 받는 사람 이름과 같은 한도 (글자 수 — PostgreSQL varchar 는 코드 포인트로 센다). 넣기 전에 이 길이로 자른다 */
|
||||
export const SENDER_NAME_MAX_LENGTH = 200;
|
||||
const NAME_COLUMN_LENGTH = SENDER_NAME_MAX_LENGTH;
|
||||
/** 메일 id · Message-ID — 엔진 id 는 255자까지 */
|
||||
const MAIL_ID_COLUMN_LENGTH = 255;
|
||||
/** 쓰임 · 까닭 같은 짧은 낱말 */
|
||||
const WORD_COLUMN_LENGTH = 32;
|
||||
|
||||
/** 취소한 까닭 — 보내지 못함(확실한 실패) · 보내기 취소(9단계의 취소) */
|
||||
export type ShareLinkCancelReason = 'send-failed' | 'send-canceled';
|
||||
/** 끊은 사람 — 보낸 사람 · 운영 */
|
||||
export type ShareLinkRevoker = 'owner' | 'operator';
|
||||
|
||||
/**
|
||||
* 공유 링크 하나 (계약 4절 — 메일 한 통의 파일 묶음). 받는 사람별 링크는 없다(보낸 사본과 받는 사람의 사본이 같은 메일이다).
|
||||
* - 토큰은 SHA-256 만(`token_hash`) — 원문은 메일 본문에만 있다
|
||||
* - 보낸 사람 이름 · 주소는 보낼 때 로그인한 계정의 값 (꾸밀 수 없다 — 공개 화면에 보인다)
|
||||
* - `message_id`: 같은 Idempotency-Key 의 재시도가 처음 메일을 찾으면 그 메일 id(`sent_email_id`)를 잇는 데 쓴다
|
||||
* - 취소한 링크(`canceled_at` — 보내지 못함 · 보내기 취소)는 없는 링크처럼 다룬다(공개 404 · 목록에서 빠짐). 끊은 링크(`revoked_at`)는 "공유 멈춤"
|
||||
* - 기록은 끝나거나(expires_at) 끊기거나 취소된 뒤 90일 지나면 정리 작업이 지운다
|
||||
*/
|
||||
@Entity({ name: 'share_links' })
|
||||
@Index('IDX_share_links_owner_sent', ['ownerEmail', 'sentAt'])
|
||||
@Index('IDX_share_links_owner_sent_email', ['ownerEmail', 'sentEmailId'])
|
||||
@Index('IDX_share_links_owner_message', ['ownerEmail', 'messageId'])
|
||||
export class ShareLinkEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
/** 토큰의 SHA-256 */
|
||||
@Index('UQ_share_links_token_hash', { unique: true })
|
||||
@Column({ name: 'token_hash', type: 'bytea' })
|
||||
tokenHash!: Buffer;
|
||||
|
||||
@Column({ name: 'owner_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
ownerEmail!: string;
|
||||
|
||||
/** 보낼 때 로그인한 계정의 이름 */
|
||||
@Column({
|
||||
name: 'sender_name',
|
||||
type: 'varchar',
|
||||
length: NAME_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
senderName!: string | null;
|
||||
|
||||
/** 쓰임 — 'mail' */
|
||||
@Column({ type: 'varchar', length: WORD_COLUMN_LENGTH })
|
||||
purpose!: string;
|
||||
|
||||
/** 중복 발송 방지의 Message-ID (Idempotency-Key 가 있는 발송만) */
|
||||
@Column({
|
||||
name: 'message_id',
|
||||
type: 'varchar',
|
||||
length: MAIL_ID_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
messageId!: string | null;
|
||||
|
||||
/** 보낸 사본의 메일 id — 보낸 결과를 모르면 null */
|
||||
@Column({
|
||||
name: 'sent_email_id',
|
||||
type: 'varchar',
|
||||
length: MAIL_ID_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
sentEmailId!: string | null;
|
||||
|
||||
/** 보낸 때 (예약은 보낼 시각) */
|
||||
@Column({ name: 'sent_at', type: 'timestamptz' })
|
||||
sentAt!: Date;
|
||||
|
||||
/** 끝나는 때 — 보낸 때의 한국 날짜 + 보관 날 수의 끝 */
|
||||
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||
expiresAt!: Date;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
|
||||
revokedAt!: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'revoked_by',
|
||||
type: 'varchar',
|
||||
length: WORD_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
revokedBy!: ShareLinkRevoker | null;
|
||||
|
||||
@Column({ name: 'canceled_at', type: 'timestamptz', nullable: true })
|
||||
canceledAt!: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'cancel_reason',
|
||||
type: 'varchar',
|
||||
length: WORD_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
cancelReason!: ShareLinkCancelReason | null;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import {
|
||||
SHARE_TOKEN_PATTERN,
|
||||
createShareToken,
|
||||
hashShareToken,
|
||||
isShareToken,
|
||||
shareUrl,
|
||||
tokenFromShareUrl,
|
||||
} from './share-token';
|
||||
|
||||
const ORIGIN = 'https://files.arasystem.test';
|
||||
const TOKEN = 'Ab_-9'.repeat(8) + 'xyz';
|
||||
|
||||
describe('공개 링크 토큰', () => {
|
||||
it('256비트 base64url 43자 — 매번 다르다', () => {
|
||||
const tokens = new Set(Array.from({ length: 50 }, createShareToken));
|
||||
expect(tokens.size).toBe(50);
|
||||
for (const token of tokens) expect(token).toMatch(SHARE_TOKEN_PATTERN);
|
||||
});
|
||||
|
||||
it('DB 에는 SHA-256 만 둔다', () => {
|
||||
expect(hashShareToken(TOKEN)).toEqual(
|
||||
createHash('sha256').update(TOKEN).digest(),
|
||||
);
|
||||
expect(isShareToken(TOKEN)).toBe(true);
|
||||
expect(isShareToken(`${TOKEN}=`)).toBe(false);
|
||||
});
|
||||
|
||||
it('공개 화면 주소 — <출처>/s/<토큰>', () => {
|
||||
expect(shareUrl(ORIGIN, TOKEN)).toBe(`${ORIGIN}/s/${TOKEN}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenFromShareUrl', () => {
|
||||
it('우리 공개 화면 주소의 토큰 — 호스트 대소문자 · 기본 포트는 같은 주소로 본다', () => {
|
||||
expect(tokenFromShareUrl(ORIGIN, `${ORIGIN}/s/${TOKEN}`)).toBe(TOKEN);
|
||||
expect(
|
||||
tokenFromShareUrl(ORIGIN, `https://FILES.arasystem.test:443/s/${TOKEN}`),
|
||||
).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it.each([
|
||||
`https://files.arasystem.test.evil.example/s/${TOKEN}`,
|
||||
`http://files.arasystem.test/s/${TOKEN}`,
|
||||
`https://files.arasystem.test/s/${TOKEN}/`,
|
||||
`https://files.arasystem.test/x/${TOKEN}`,
|
||||
`https://files.arasystem.test/s/${TOKEN.slice(1)}`,
|
||||
`https://user@files.arasystem.test/s/${TOKEN}`,
|
||||
`https://files.arasystem.test/s/${TOKEN}?utm=1`,
|
||||
'그냥 글',
|
||||
])('우리 링크 모양이 아니면 null — %s', (value) => {
|
||||
expect(tokenFromShareUrl(ORIGIN, value)).toBeNull();
|
||||
});
|
||||
|
||||
it('allowQuery 면 뒤에 붙은 쿼리 · 조각은 무시한다 (인용 속 주소)', () => {
|
||||
expect(
|
||||
tokenFromShareUrl(ORIGIN, `${ORIGIN}/s/${TOKEN}?utm=1#x`, {
|
||||
allowQuery: true,
|
||||
}),
|
||||
).toBe(TOKEN);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
|
||||
/**
|
||||
* 공개 링크 토큰 (순수 함수) — 계약 4절 "공개 내려받기"의 토큰.
|
||||
*
|
||||
* - 링크마다 `crypto.randomBytes(32)` 의 base64url(43자, `=` 없음 — 256비트). 서버 서명 키(JWT_SECRET)에서 만들지 않는다 (보안 규칙)
|
||||
* - DB 에는 **SHA-256 만** 두고 그것으로 찾는다 — 원문은 메일 본문에만 있고, 서버는 링크를 다시 만들 수 없다.
|
||||
* 맞혀 볼 수 없는 값이라 느린 해시는 필요 없다
|
||||
* - 경로에 둔다(`/s/<토큰>` · `/api/public-links/<토큰>`) — 로그에서는 가린다(mask.ts)
|
||||
*/
|
||||
|
||||
/** 토큰 바이트 수 (256비트) */
|
||||
const SHARE_TOKEN_BYTES = 32;
|
||||
|
||||
/** 토큰 모양 — 아니면 DB 에 묻지 않고 404 */
|
||||
export const SHARE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
||||
|
||||
/** 공개 화면 경로 — `<SHARE_PUBLIC_URL>/s/<토큰>` (frontend 라우트 `/s/:token` 과 짝) */
|
||||
export const SHARE_PAGE_PATH = '/s/';
|
||||
|
||||
/** 이보다 긴 주소는 링크로 보지 않는다 (공개 주소 + 경로 + 토큰은 100자 안쪽) */
|
||||
const MAX_SHARE_URL_LENGTH = 2_048;
|
||||
|
||||
export function createShareToken(): string {
|
||||
return randomBytes(SHARE_TOKEN_BYTES).toString('base64url');
|
||||
}
|
||||
|
||||
export function hashShareToken(token: string): Buffer {
|
||||
return createHash('sha256').update(token, 'utf8').digest();
|
||||
}
|
||||
|
||||
export function isShareToken(value: string): boolean {
|
||||
return SHARE_TOKEN_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/** 공개 화면 주소 — 덩이 · `MessageLargeFile.url` 이 쓴다. publicOrigin 은 정규화한 출처(끝 `/` 없음) */
|
||||
export function shareUrl(publicOrigin: string, token: string): string {
|
||||
return `${publicOrigin}${SHARE_PAGE_PATH}${token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주소가 우리 공개 화면(`<publicOrigin>/s/<토큰>`)이면 그 토큰, 아니면 null.
|
||||
* 출처는 비교할 모양으로(스킴 · 호스트 소문자, 기본 포트 생략) 견주고, 경로는 정확히 `/s/<토큰>` 이어야 한다.
|
||||
* `allowQuery` 면 뒤에 붙은 쿼리 · 조각(#)을 무시한다 (인용 속 주소를 묻는 때 — 받는 쪽 장비가 붙였을 수 있다)
|
||||
*/
|
||||
export function tokenFromShareUrl(
|
||||
publicOrigin: string,
|
||||
value: string,
|
||||
options: { readonly allowQuery?: boolean } = {},
|
||||
): string | null {
|
||||
if (value.length > MAX_SHARE_URL_LENGTH) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.origin !== publicOrigin || url.username || url.password) {
|
||||
return null;
|
||||
}
|
||||
if (!options.allowQuery && (url.search !== '' || url.hash !== '')) {
|
||||
return null;
|
||||
}
|
||||
if (!url.pathname.startsWith(SHARE_PAGE_PATH)) return null;
|
||||
const token = url.pathname.slice(SHARE_PAGE_PATH.length);
|
||||
return isShareToken(token) ? token : null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { StoredFileView } from '@/modules/file-storage/file-storage.types';
|
||||
import {
|
||||
isDownloadable,
|
||||
sharedFileStatus,
|
||||
shareLinkStatus,
|
||||
} from './shared-file-status';
|
||||
|
||||
const NOW = new Date('2026-11-10T00:00:00.000Z');
|
||||
const ACTIVE = {
|
||||
revokedAt: null,
|
||||
expiresAt: new Date('2026-11-29T14:59:59.999Z'),
|
||||
};
|
||||
|
||||
const view = (overrides: Partial<StoredFileView> = {}): StoredFileView => ({
|
||||
id: 'f1',
|
||||
ownerEmail: 'ara.kim@arasystem.test',
|
||||
name: '설계도.dwg',
|
||||
size: 1000,
|
||||
state: 'ready',
|
||||
removedReason: null,
|
||||
scanStatus: 'clean',
|
||||
completedAt: new Date('2026-10-30T03:00:00.000Z'),
|
||||
expiresAt: ACTIVE.expiresAt,
|
||||
alive: true,
|
||||
bytesAvailable: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const status = (
|
||||
overrides: Partial<Parameters<typeof sharedFileStatus>[0]> = {},
|
||||
) =>
|
||||
sharedFileStatus({
|
||||
link: ACTIVE,
|
||||
file: view(),
|
||||
downloadCount: 0,
|
||||
downloadLimit: 500,
|
||||
now: NOW,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('shareLinkStatus', () => {
|
||||
it('끊음 → 기간 지남 → 사용 중', () => {
|
||||
expect(shareLinkStatus(ACTIVE, NOW)).toBe('active');
|
||||
expect(shareLinkStatus({ ...ACTIVE, revokedAt: NOW }, NOW)).toBe('revoked');
|
||||
expect(shareLinkStatus(ACTIVE, new Date('2026-11-29T15:00:00.000Z'))).toBe(
|
||||
'expired',
|
||||
);
|
||||
// 끝나는 때 그 순간까지는 받을 수 있다 (지금 ≤ expiresAt)
|
||||
expect(shareLinkStatus(ACTIVE, ACTIVE.expiresAt)).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedFileStatus (계약 4절 "상태별 응답")', () => {
|
||||
it('링크가 끝났으면 링크의 상태가 먼저', () => {
|
||||
expect(status({ link: { ...ACTIVE, revokedAt: NOW } })).toBe('revoked');
|
||||
expect(
|
||||
status({
|
||||
now: new Date('2026-12-01T00:00:00.000Z'),
|
||||
file: view({ scanStatus: 'infected' }),
|
||||
}),
|
||||
).toBe('expired');
|
||||
});
|
||||
|
||||
it('위험 → 찾을 수 없음 → 횟수 초과 → 검사 중 → 검사하지 못함 · 받을 수 있음', () => {
|
||||
expect(
|
||||
status({ file: view({ scanStatus: 'infected', bytesAvailable: false }) }),
|
||||
).toBe('infected');
|
||||
expect(status({ file: undefined })).toBe('missing');
|
||||
expect(status({ file: view({ bytesAvailable: false }) })).toBe('missing');
|
||||
expect(status({ downloadCount: 500 })).toBe('limit-reached');
|
||||
expect(status({ file: view({ scanStatus: 'pending' }) })).toBe('scanning');
|
||||
expect(status({ file: view({ scanStatus: 'unscannable' }) })).toBe(
|
||||
'unscannable',
|
||||
);
|
||||
expect(status()).toBe('available');
|
||||
});
|
||||
|
||||
it('받을 수 있는 상태는 받을 수 있음 · 검사하지 못함뿐', () => {
|
||||
expect(isDownloadable('available')).toBe(true);
|
||||
expect(isDownloadable('unscannable')).toBe(true);
|
||||
for (const other of [
|
||||
'scanning',
|
||||
'infected',
|
||||
'limit-reached',
|
||||
'revoked',
|
||||
'expired',
|
||||
'missing',
|
||||
] as const) {
|
||||
expect(isDownloadable(other)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { StoredFileView } from '@/modules/file-storage/file-storage.types';
|
||||
import type { SharedFileStatus, ShareLinkStatus } from './file-sharing.types';
|
||||
|
||||
/**
|
||||
* 링크 · 파일의 상태 (순수 함수) — 계약 4절 "상태별 응답". 요청마다 지금 시각으로 본다(정리 작업이 늦어도 정확하다).
|
||||
*/
|
||||
|
||||
/** 링크의 상태 — 끊음이 먼저, 그다음 기간 (`지금 ≤ expiresAt` 이면 받을 수 있는 기간) */
|
||||
export function shareLinkStatus(
|
||||
link: { readonly revokedAt: Date | null; readonly expiresAt: Date },
|
||||
now: Date,
|
||||
): ShareLinkStatus {
|
||||
if (link.revokedAt !== null) return 'revoked';
|
||||
return now > link.expiresAt ? 'expired' : 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* 링크 안 파일 하나의 상태 — 링크(끊음 · 기간) → 위험 → 찾을 수 없음(기록 · 바이트가 없음) → 횟수 초과 → 검사 중 → 검사하지 못함 · 받을 수 있음
|
||||
*/
|
||||
export function sharedFileStatus(input: {
|
||||
readonly link: { readonly revokedAt: Date | null; readonly expiresAt: Date };
|
||||
/** 파일 기록 — 지워졌으면 undefined */
|
||||
readonly file: StoredFileView | undefined;
|
||||
readonly downloadCount: number;
|
||||
readonly downloadLimit: number;
|
||||
readonly now: Date;
|
||||
}): SharedFileStatus {
|
||||
const linkStatus = shareLinkStatus(input.link, input.now);
|
||||
if (linkStatus !== 'active') return linkStatus;
|
||||
const { file } = input;
|
||||
if (file?.scanStatus === 'infected') return 'infected';
|
||||
if (!file || !file.bytesAvailable) return 'missing';
|
||||
if (input.downloadCount >= input.downloadLimit) return 'limit-reached';
|
||||
if (file.scanStatus === 'clean') return 'available';
|
||||
if (file.scanStatus === 'unscannable') return 'unscannable';
|
||||
return 'scanning';
|
||||
}
|
||||
|
||||
/** 받을 수 있는 상태인가 — 받을 수 있음 · 검사하지 못함 */
|
||||
export function isDownloadable(status: SharedFileStatus): boolean {
|
||||
return status === 'available' || status === 'unscannable';
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import {
|
||||
ChunkGate,
|
||||
chunkLength,
|
||||
digestStream,
|
||||
parseContentDigest,
|
||||
} from './chunk-upload';
|
||||
import { ChunkLengthMismatchError } from './file-blob-store';
|
||||
|
||||
const sha = (text: string) => createHash('sha256').update(text).digest();
|
||||
|
||||
describe('parseContentDigest (RFC 9530)', () => {
|
||||
it('sha-256 값을 읽는다 — 다른 알고리즘과 함께 와도 · 매개변수가 붙어도', () => {
|
||||
const value = sha('조각').toString('base64');
|
||||
expect(parseContentDigest(`sha-256=:${value}:`)).toEqual(sha('조각'));
|
||||
expect(
|
||||
parseContentDigest(`sha-512=:AAAA:, sha-256=:${value}:;x=1`),
|
||||
).toEqual(sha('조각'));
|
||||
});
|
||||
|
||||
it('없으면(다른 알고리즘만 포함) null, 모양이 틀리면 invalid', () => {
|
||||
expect(parseContentDigest(undefined)).toBeNull();
|
||||
expect(parseContentDigest('')).toBeNull();
|
||||
expect(parseContentDigest('sha-512=:AAAA:')).toBeNull();
|
||||
expect(parseContentDigest('sha-256=abc')).toBe('invalid');
|
||||
expect(parseContentDigest('sha-256=:AAAA:')).toBe('invalid');
|
||||
expect(parseContentDigest(`sha-256=:${'A'.repeat(2_000)}:`)).toBe(
|
||||
'invalid',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chunkLength', () => {
|
||||
it('마지막 조각만 짧다', () => {
|
||||
const file = { size: 25, chunkSize: 10 };
|
||||
expect([0, 1, 2].map((index) => chunkLength(file, index))).toEqual([
|
||||
10, 10, 5,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('digestStream', () => {
|
||||
it('끝까지 읽어 SHA-256 만 — 길이가 다르면 ChunkLengthMismatchError', async () => {
|
||||
await expect(
|
||||
digestStream(Readable.from([Buffer.from('ab'), Buffer.from('c')]), 3),
|
||||
).resolves.toEqual(sha('abc'));
|
||||
await expect(
|
||||
digestStream(Readable.from([Buffer.from('ab')]), 3),
|
||||
).rejects.toBeInstanceOf(ChunkLengthMismatchError);
|
||||
await expect(
|
||||
digestStream(Readable.from([Buffer.from('abcd')]), 3),
|
||||
).rejects.toBeInstanceOf(ChunkLengthMismatchError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChunkGate (동시 수 · 같은 조각)', () => {
|
||||
it('같은 조각은 하나 · 직원마다 · 서버 전체 수를 넘으면 거절 — 놓으면 다시 받는다', () => {
|
||||
const gate = new ChunkGate(3, 2);
|
||||
const a = gate.tryEnter('ara', 'f1', 0);
|
||||
expect(typeof a).toBe('object');
|
||||
expect(gate.tryEnter('ara', 'f1', 0)).toBe('chunk-busy');
|
||||
const b = gate.tryEnter('ara', 'f1', 1);
|
||||
expect(gate.tryEnter('ara', 'f2', 0)).toBe('user-busy');
|
||||
const c = gate.tryEnter('doyun', 'f3', 0);
|
||||
expect(gate.tryEnter('minji', 'f4', 0)).toBe('server-busy');
|
||||
expect(gate.isActive('f1')).toBe(true);
|
||||
|
||||
for (const ticket of [a, b, c]) {
|
||||
if (typeof ticket === 'object') ticket.release();
|
||||
}
|
||||
expect(gate.isActive('f1')).toBe(false);
|
||||
expect(typeof gate.tryEnter('minji', 'f4', 0)).toBe('object');
|
||||
});
|
||||
|
||||
it('두 번 놓아도 한 번만 센다', () => {
|
||||
const gate = new ChunkGate(1, 1);
|
||||
const ticket = gate.tryEnter('ara', 'f1', 0);
|
||||
if (typeof ticket !== 'object') throw new Error('자리를 받지 못함');
|
||||
ticket.release();
|
||||
ticket.release();
|
||||
const again = gate.tryEnter('ara', 'f1', 0);
|
||||
expect(typeof again).toBe('object');
|
||||
expect(gate.tryEnter('doyun', 'f2', 0)).toBe('server-busy');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createHash } from 'crypto';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
import { ChunkLengthMismatchError } from './file-blob-store';
|
||||
|
||||
/**
|
||||
* 조각 올리기 도우미 (순수 함수 · 작은 셈) — 계약 4절 "올리기" 2.
|
||||
*/
|
||||
|
||||
/** SHA-256 의 바이트 수 */
|
||||
const SHA256_BYTES = 32;
|
||||
|
||||
/** `Content-Digest` 의 sha-256 값 (RFC 9530) — 없으면 null(다른 알고리즘만 있어도 없는 것), 모양이 틀리면 'invalid' */
|
||||
export type ContentDigest = Buffer | null | 'invalid';
|
||||
|
||||
/** RFC 8941 바이트 열 `:base64:` — 앞뒤 콜론 사이 */
|
||||
const BYTE_SEQUENCE = /^:([A-Za-z0-9+/]*={0,2}):$/;
|
||||
|
||||
/**
|
||||
* `Content-Digest: sha-256=:<base64>:` 를 읽는다 — 여러 알고리즘이 콤마로 이어질 수 있다(`sha-512=:…:, sha-256=:…:`).
|
||||
* 매개변수(`;…`)는 보지 않는다. sha-256 이 없으면 null, 있는데 32바이트 base64 가 아니면 'invalid'
|
||||
*/
|
||||
export function parseContentDigest(header: string | undefined): ContentDigest {
|
||||
if (header === undefined || header.trim() === '') return null;
|
||||
// 머리글 하나에 알고리즘 몇 개 — 이보다 길면 읽지 않는다(모양이 틀린 것으로)
|
||||
if (header.length > 1_024) return 'invalid';
|
||||
for (const member of header.split(',')) {
|
||||
const separator = member.indexOf('=');
|
||||
if (separator < 0) continue;
|
||||
const key = member.slice(0, separator).trim();
|
||||
if (key !== 'sha-256') continue;
|
||||
const value = member
|
||||
.slice(separator + 1)
|
||||
.split(';')[0]
|
||||
.trim();
|
||||
const match = BYTE_SEQUENCE.exec(value);
|
||||
if (!match) return 'invalid';
|
||||
const bytes = Buffer.from(match[1], 'base64');
|
||||
return bytes.length === SHA256_BYTES ? bytes : 'invalid';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 조각 번호의 길이 — 마지막 조각만 짧다 */
|
||||
export function chunkLength(
|
||||
file: { readonly size: number; readonly chunkSize: number },
|
||||
index: number,
|
||||
): number {
|
||||
const start = index * file.chunkSize;
|
||||
return Math.min(file.chunkSize, file.size - start);
|
||||
}
|
||||
|
||||
/** 본문을 끝까지 읽어 SHA-256 만 계산한다 (이미 받은 조각을 다시 보낸 경우 — 쓰지 않고 견준다). 길이가 다르면 ChunkLengthMismatchError */
|
||||
export async function digestStream(
|
||||
source: Readable,
|
||||
expectedBytes: number,
|
||||
): Promise<Buffer> {
|
||||
const hash = createHash('sha256');
|
||||
let received = 0;
|
||||
for await (const piece of source) {
|
||||
const data = Buffer.isBuffer(piece)
|
||||
? piece
|
||||
: Buffer.from(piece as Uint8Array);
|
||||
received += data.length;
|
||||
if (received > expectedBytes) {
|
||||
throw new ChunkLengthMismatchError(expectedBytes, received);
|
||||
}
|
||||
hash.update(data);
|
||||
}
|
||||
if (received !== expectedBytes) {
|
||||
throw new ChunkLengthMismatchError(expectedBytes, received);
|
||||
}
|
||||
return hash.digest();
|
||||
}
|
||||
|
||||
/** 조각을 받지 못하게 된 까닭 — 동시 수 · 같은 조각 */
|
||||
export type ChunkGateRefusal = 'server-busy' | 'user-busy' | 'chunk-busy';
|
||||
|
||||
/** 받는 자리 — 끝나면(성공 · 실패) 놓는다 */
|
||||
export interface ChunkTicket {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동시에 받는 조각의 자리 (계약 4절 "요청 수 · 동시 수") — 서버 전체 · 직원마다 수, 같은 올리기의 같은 조각은 하나.
|
||||
* 기다리지 않는다 — 자리가 없으면 거절하고 클라이언트가 잠시 뒤 같은 조각을 다시 보낸다.
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 셈 · 잠금으로 옮긴다 (nestjs 스킬 "확장 때 옮길 것")
|
||||
*/
|
||||
export class ChunkGate {
|
||||
private total = 0;
|
||||
private readonly perUser = new Map<string, number>();
|
||||
private readonly chunks = new Set<string>();
|
||||
private readonly perFile = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly maxTotal: number,
|
||||
private readonly maxPerUser: number,
|
||||
) {}
|
||||
|
||||
tryEnter(
|
||||
user: string,
|
||||
fileId: string,
|
||||
index: number,
|
||||
): ChunkTicket | ChunkGateRefusal {
|
||||
const chunkKey = `${fileId}:${index}`;
|
||||
if (this.chunks.has(chunkKey)) return 'chunk-busy';
|
||||
if (this.total >= this.maxTotal) return 'server-busy';
|
||||
const userCount = this.perUser.get(user) ?? 0;
|
||||
if (userCount >= this.maxPerUser) return 'user-busy';
|
||||
|
||||
this.total += 1;
|
||||
this.perUser.set(user, userCount + 1);
|
||||
this.chunks.add(chunkKey);
|
||||
this.perFile.set(fileId, (this.perFile.get(fileId) ?? 0) + 1);
|
||||
let released = false;
|
||||
return {
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.total -= 1;
|
||||
const left = (this.perUser.get(user) ?? 1) - 1;
|
||||
if (left > 0) this.perUser.set(user, left);
|
||||
else this.perUser.delete(user);
|
||||
this.chunks.delete(chunkKey);
|
||||
const fileLeft = (this.perFile.get(fileId) ?? 1) - 1;
|
||||
if (fileLeft > 0) this.perFile.set(fileId, fileLeft);
|
||||
else this.perFile.delete(fileId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 이 올리기의 조각을 받는 중인가 — 끝내기는 그동안 409 */
|
||||
isActive(fileId: string): boolean {
|
||||
return this.perFile.has(fileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import net, {
|
||||
createServer,
|
||||
type AddressInfo,
|
||||
type Server,
|
||||
type Socket,
|
||||
} from 'net';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import {
|
||||
ClamavUnavailableError,
|
||||
classifyClamavReply,
|
||||
scanWithClamav,
|
||||
type ClamavConnection,
|
||||
type ClamavFailure,
|
||||
} from './clamav-client';
|
||||
|
||||
/**
|
||||
* 가짜 clamd 가 받은 INSTREAM 내용으로 답을 고르는 함수 — 글이면 `\0` 을 붙여 보내고 닫는다, null 이면 답하지 않는다,
|
||||
* 함수면 그 소켓으로 직접 한다 (끊기 · 반쪽 글 · 늦은 답 등)
|
||||
*/
|
||||
type FakeReply = string | null | ((socket: Socket) => void);
|
||||
type FakeVerdict = (content: Buffer) => FakeReply;
|
||||
|
||||
/**
|
||||
* 크기 한도(StreamMaxLength)를 넘었을 때 — 실제 clamd 처럼 크기 오류 글을 보내고 닫거나(error-reply),
|
||||
* 그 글이 RST 에 묻혀 오지 않은 것처럼 바로 끊는다(reset — 받는 쪽에는 EPIPE · ECONNRESET 만 남는다)
|
||||
*/
|
||||
type OverLimit = 'error-reply' | 'reset';
|
||||
|
||||
/**
|
||||
* 가짜 clamd (테스트 안 TCP 서버) — zINSTREAM 명령 뒤의 [길이 4바이트 + 바이트] 조각을 모아, 길이 0 이 오면 답한다.
|
||||
* `limit` 을 넘게 받으면 overLimit 대로 한다(StreamMaxLength)
|
||||
*/
|
||||
async function startFakeClamd(
|
||||
verdict: FakeVerdict,
|
||||
limit = Number.POSITIVE_INFINITY,
|
||||
overLimit: OverLimit = 'error-reply',
|
||||
): Promise<{ server: Server; port: number; commands: string[] }> {
|
||||
const commands: string[] = [];
|
||||
const server = createServer((socket: Socket) => {
|
||||
let buffer = Buffer.alloc(0);
|
||||
let commandRead = false;
|
||||
const content: Buffer[] = [];
|
||||
let total = 0;
|
||||
// 끊은 쪽의 오류(ECONNRESET 등)는 테스트와 상관없다
|
||||
socket.on('error', () => undefined);
|
||||
socket.on('data', (data: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, data]);
|
||||
if (!commandRead) {
|
||||
const end = buffer.indexOf(0);
|
||||
if (end < 0) return;
|
||||
commands.push(buffer.subarray(0, end).toString('latin1'));
|
||||
buffer = buffer.subarray(end + 1);
|
||||
commandRead = true;
|
||||
}
|
||||
while (buffer.length >= 4) {
|
||||
const length = buffer.readUInt32BE(0);
|
||||
if (length === 0) {
|
||||
const reply = verdict(Buffer.concat(content));
|
||||
if (typeof reply === 'function') reply(socket);
|
||||
else if (reply !== null) socket.end(`${reply}\0`);
|
||||
return;
|
||||
}
|
||||
if (buffer.length < 4 + length) return;
|
||||
content.push(buffer.subarray(4, 4 + length));
|
||||
total += length;
|
||||
buffer = buffer.subarray(4 + length);
|
||||
if (total > limit) {
|
||||
if (overLimit === 'reset') socket.resetAndDestroy();
|
||||
else socket.end('INSTREAM size limit exceeded. ERROR\0');
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return { server, port: (server.address() as AddressInfo).port, commands };
|
||||
}
|
||||
|
||||
const options = (
|
||||
port: number,
|
||||
overrides: Partial<ClamavConnection> = {},
|
||||
): ClamavConnection => ({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
connectTimeoutMs: 2_000,
|
||||
idleTimeoutMs: 5_000,
|
||||
verdictTimeoutMs: () => 5_000,
|
||||
keepAliveInitialDelayMs: 60_000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* scanWithClamav 가 여는 소켓을 잡는다 — keepalive 설정을 보고, 커널이 keepalive 로 끊는 경우를 흉내 내려고.
|
||||
* 진짜 net.connect 를 그대로 부르고 소켓만 모은다 (describe 의 afterEach 가 되돌린다)
|
||||
*/
|
||||
function captureClamdSockets(): {
|
||||
sockets: Socket[];
|
||||
keepAlive: jest.SpyInstance[];
|
||||
} {
|
||||
const realConnect = net.connect;
|
||||
const sockets: Socket[] = [];
|
||||
const keepAlive: jest.SpyInstance[] = [];
|
||||
jest.spyOn(net, 'connect').mockImplementation((...args: unknown[]) => {
|
||||
const socket = (realConnect as (...params: unknown[]) => Socket)(...args);
|
||||
sockets.push(socket);
|
||||
keepAlive.push(jest.spyOn(socket, 'setKeepAlive'));
|
||||
return socket;
|
||||
});
|
||||
return { sockets, keepAlive };
|
||||
}
|
||||
|
||||
/**
|
||||
* keepalive 가 상대를 잃었을 때 Node 가 내는 오류 — 커널이 물음 10번에 답을 못 받으면 읽기가 ETIMEDOUT 으로 끝난다
|
||||
* (2026-10-30 실측: 상대 컨테이너의 네트워크를 내리자 `read ETIMEDOUT` · code ETIMEDOUT)
|
||||
*/
|
||||
const keepAliveTimeout = () =>
|
||||
Object.assign(new Error('read ETIMEDOUT'), {
|
||||
code: 'ETIMEDOUT',
|
||||
errno: -110,
|
||||
syscall: 'read',
|
||||
});
|
||||
|
||||
/** 끊김 글의 끝 `(까닭)` 을 뗀 것 — 끊긴 까닭만 다른 두 글을 견준다 */
|
||||
const withoutCause = (message: string) => message.replace(/ \([^()]*\)$/, '');
|
||||
|
||||
/** 끝나지 않는 내용 — 앞부분만 보내고 흘려보내는 중에 머문다 */
|
||||
const unfinishedFile = (head: string) => {
|
||||
const source = new Readable({ read: () => undefined });
|
||||
source.push(Buffer.from(head));
|
||||
return source;
|
||||
};
|
||||
|
||||
const fileOf = (text: string, pieces = 3) => {
|
||||
const data = Buffer.from(text);
|
||||
const size = Math.ceil(data.length / pieces);
|
||||
return Readable.from(
|
||||
Array.from({ length: pieces }, (_, index) =>
|
||||
data.subarray(index * size, (index + 1) * size),
|
||||
).filter((piece) => piece.length > 0),
|
||||
);
|
||||
};
|
||||
|
||||
/** 판정이 없을 때의 까닭 (ClamavUnavailableError 가 아니면 'NOT_CLAMAV') */
|
||||
async function failureOf(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<ClamavFailure | 'NOT_CLAMAV' | null> {
|
||||
try {
|
||||
await run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof ClamavUnavailableError
|
||||
? error.failure
|
||||
: 'NOT_CLAMAV';
|
||||
}
|
||||
}
|
||||
|
||||
/** 바로 던지는 함수의 까닭 — 약속으로 감싸 failureOf 와 같게 본다 */
|
||||
const failureOfSync = (run: () => unknown) =>
|
||||
failureOf(() => Promise.resolve().then(run));
|
||||
|
||||
/** 판정이 없을 때의 오류 (까닭 · 글) — 판정을 받으면 테스트를 실패시킨다 */
|
||||
async function unavailableOf(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<ClamavUnavailableError> {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
if (error instanceof ClamavUnavailableError) return error;
|
||||
throw error;
|
||||
}
|
||||
throw new Error('판정을 받았습니다 — 실패를 기대했습니다');
|
||||
}
|
||||
|
||||
describe('classifyClamavReply (clamd 응답 → 판정 — 견주기 한 곳)', () => {
|
||||
it.each([
|
||||
['stream: OK\0', { kind: 'clean' }],
|
||||
[
|
||||
'stream: Win.Test.EICAR_HDB-1 FOUND\0',
|
||||
{ kind: 'infected', signature: 'Win.Test.EICAR_HDB-1' },
|
||||
],
|
||||
[
|
||||
'stream: Heuristics.Encrypted.Zip FOUND\0',
|
||||
{ kind: 'unscannable', signature: 'Heuristics.Encrypted.Zip' },
|
||||
],
|
||||
[
|
||||
'stream: Heuristics.Limits.Exceeded.MaxFileSize FOUND\0',
|
||||
{
|
||||
kind: 'unscannable',
|
||||
signature: 'Heuristics.Limits.Exceeded.MaxFileSize',
|
||||
},
|
||||
],
|
||||
[
|
||||
'stream: Heuristics.Limits.Exceeded FOUND\0',
|
||||
{ kind: 'unscannable', signature: 'Heuristics.Limits.Exceeded' },
|
||||
],
|
||||
// 이름만 비슷한 다른 휴리스틱은 위험 — 닫힌 쪽
|
||||
[
|
||||
'stream: Heuristics.EncryptedLookalike FOUND\0',
|
||||
{ kind: 'infected', signature: 'Heuristics.EncryptedLookalike' },
|
||||
],
|
||||
[
|
||||
'stream: Heuristics.Phishing.Email.SpoofedDomain FOUND\0',
|
||||
{
|
||||
kind: 'infected',
|
||||
signature: 'Heuristics.Phishing.Email.SpoofedDomain',
|
||||
},
|
||||
],
|
||||
])('%s', (reply, verdict) => {
|
||||
expect(classifyClamavReply(reply)).toEqual(verdict);
|
||||
});
|
||||
|
||||
it('흐름 크기 한도(StreamMaxLength)는 판정이 아니라 설정 오류 — misconfigured', async () => {
|
||||
expect(
|
||||
await failureOfSync(() =>
|
||||
classifyClamavReply('INSTREAM size limit exceeded. ERROR\0'),
|
||||
),
|
||||
).toBe('misconfigured');
|
||||
});
|
||||
|
||||
it('그 밖의 오류 응답 · 모르는 모양 · 이름 없는 FOUND 는 판정이 아니다 — error-reply (다시 한다)', async () => {
|
||||
for (const reply of [
|
||||
"Can't allocate memory ERROR\0",
|
||||
'hello\0',
|
||||
'stream: FOUND\0',
|
||||
'stream: OKAY\0',
|
||||
]) {
|
||||
expect(await failureOfSync(() => classifyClamavReply(reply))).toBe(
|
||||
'error-reply',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('`\\0` 이 오기 전의 글은 판정이 아니다 — unavailable (끊긴 판정 글을 깨끗함으로 읽지 않는다)', async () => {
|
||||
for (const partial of ['stream: OK', 'stream: Win.Test.EIC', '']) {
|
||||
expect(await failureOfSync(() => classifyClamavReply(partial))).toBe(
|
||||
'unavailable',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanWithClamav (가짜 clamd)', () => {
|
||||
const servers: Server[] = [];
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await Promise.all(
|
||||
servers
|
||||
.splice(0)
|
||||
.map((server) => new Promise((resolve) => server.close(resolve))),
|
||||
);
|
||||
});
|
||||
|
||||
const start = async (
|
||||
verdict: FakeVerdict,
|
||||
limit?: number,
|
||||
overLimit?: OverLimit,
|
||||
) => {
|
||||
const fake = await startFakeClamd(verdict, limit, overLimit);
|
||||
servers.push(fake.server);
|
||||
return fake;
|
||||
};
|
||||
|
||||
it('조각을 [길이 + 바이트]로 흘려보내고 길이 0 으로 끝낸다 — 내용 그대로 clamd 가 받는다', async () => {
|
||||
let received = '';
|
||||
const fake = await start((content) => {
|
||||
received = content.toString();
|
||||
return 'stream: OK';
|
||||
});
|
||||
|
||||
const verdict = await scanWithClamav(
|
||||
options(fake.port),
|
||||
fileOf('평범한 견적서 내용입니다'),
|
||||
);
|
||||
expect(verdict).toEqual({ kind: 'clean' });
|
||||
expect(received).toBe('평범한 견적서 내용입니다');
|
||||
expect(fake.commands).toEqual(['zINSTREAM']);
|
||||
});
|
||||
|
||||
it('위험 판정 · 암호 압축(검사하지 못함)을 가른다', async () => {
|
||||
const fake = await start((content) =>
|
||||
content.toString().includes('EICAR')
|
||||
? 'stream: Win.Test.EICAR_HDB-1 FOUND'
|
||||
: 'stream: Heuristics.Encrypted.Zip FOUND',
|
||||
);
|
||||
|
||||
await expect(
|
||||
scanWithClamav(options(fake.port), fileOf('X5O!P%@AP EICAR test')),
|
||||
).resolves.toEqual({ kind: 'infected', signature: 'Win.Test.EICAR_HDB-1' });
|
||||
await expect(
|
||||
scanWithClamav(options(fake.port), fileOf('PK encrypted')),
|
||||
).resolves.toEqual({
|
||||
kind: 'unscannable',
|
||||
signature: 'Heuristics.Encrypted.Zip',
|
||||
});
|
||||
});
|
||||
|
||||
it('clamd 의 흐름 크기 한도를 넘으면 설정 오류(misconfigured) — 먼저 닫아도 받은 글을 읽는다, 판정은 없다', async () => {
|
||||
const fake = await start(() => 'stream: OK', 8);
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
scanWithClamav(options(fake.port), fileOf('0123456789abcdefghij', 4)),
|
||||
),
|
||||
).toBe('misconfigured');
|
||||
});
|
||||
|
||||
it('판정 글을 보내기 전에 끊으면(RST · 빈 응답 · 끝나지 않은 글) unavailable — 다시 한다', async () => {
|
||||
const reset = await start(() => (socket) => socket.resetAndDestroy());
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
scanWithClamav(options(reset.port), fileOf('내용')),
|
||||
),
|
||||
).toBe('unavailable');
|
||||
|
||||
const empty = await start(() => (socket) => socket.end());
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
scanWithClamav(options(empty.port), fileOf('내용')),
|
||||
),
|
||||
).toBe('unavailable');
|
||||
|
||||
// `\0` 없이 끝난 "stream: OK" 를 깨끗함으로 읽지 않는다
|
||||
const partial = await start(() => (socket) => socket.end('stream: OK'));
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
scanWithClamav(options(partial.port), fileOf('내용')),
|
||||
),
|
||||
).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('clamd 의 오류 응답은 error-reply — 다시 한다', async () => {
|
||||
const fake = await start(() => "Can't allocate memory ERROR");
|
||||
expect(
|
||||
await failureOf(() => scanWithClamav(options(fake.port), fileOf('내용'))),
|
||||
).toBe('error-reply');
|
||||
});
|
||||
|
||||
it('검사기에 닿지 못하거나 답이 없으면 unavailable — 판정 없이 열지 않는다', async () => {
|
||||
const closed = await startFakeClamd(() => 'stream: OK');
|
||||
const port = closed.port;
|
||||
await new Promise((resolve) => closed.server.close(resolve));
|
||||
const refused = await unavailableOf(() =>
|
||||
scanWithClamav(options(port), fileOf('내용')),
|
||||
);
|
||||
expect(refused.failure).toBe('unavailable');
|
||||
expect(refused.message).toContain('clamd 에 닿지 못했습니다');
|
||||
|
||||
const silent = await start(() => null);
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
scanWithClamav(
|
||||
options(silent.port, { verdictTimeoutMs: () => 300 }),
|
||||
fileOf('내용'),
|
||||
),
|
||||
),
|
||||
).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('다 보낸 뒤에는 보낸 바이트 수로 정한 시간만큼 판정을 기다린다 — 흘려보내는 동안의 유휴 시간보다 길어도', async () => {
|
||||
// 판정을 600ms 뒤에 보낸다 — 흘려보내는 동안의 유휴 시간(150ms)보다 길고 판정 대기(5초)보다 짧다
|
||||
const slow = await start(() => (socket) => {
|
||||
setTimeout(() => socket.end('stream: OK\0'), 600);
|
||||
});
|
||||
const verdictTimeoutMs = jest.fn(() => 5_000);
|
||||
const content = '평범한 견적서 내용입니다';
|
||||
await expect(
|
||||
scanWithClamav(
|
||||
options(slow.port, { idleTimeoutMs: 150, verdictTimeoutMs }),
|
||||
fileOf(content),
|
||||
),
|
||||
).resolves.toEqual({ kind: 'clean' });
|
||||
// 보낸 바이트 수(UTF-8)로 한 번 정한다
|
||||
expect(verdictTimeoutMs.mock.calls).toEqual([[Buffer.byteLength(content)]]);
|
||||
});
|
||||
|
||||
it('판정 대기 시간이 지나면 unavailable — 어느 단계 · 보낸 바이트 수를 글에 남긴다', async () => {
|
||||
const silent = await start(() => null);
|
||||
const error = await unavailableOf(() =>
|
||||
scanWithClamav(
|
||||
options(silent.port, {
|
||||
idleTimeoutMs: 60_000,
|
||||
verdictTimeoutMs: (bytes) => (bytes === 6 ? 200 : 60_000),
|
||||
}),
|
||||
fileOf('내용'),
|
||||
),
|
||||
);
|
||||
expect(error.failure).toBe('unavailable');
|
||||
expect(error.message).toContain(
|
||||
'다 받은 뒤 0.2초 안에 판정을 보내지 않았습니다',
|
||||
);
|
||||
expect(error.message).toContain('보낸 6바이트');
|
||||
});
|
||||
|
||||
it('흘려보내는 동안은 유휴 시간(idleTimeoutMs)으로 끊는다 — 판정 대기 시간은 아직 쓰지 않는다', async () => {
|
||||
const silent = await start(() => null);
|
||||
const verdictTimeoutMs = jest.fn(() => 60_000);
|
||||
const error = await unavailableOf(() =>
|
||||
scanWithClamav(
|
||||
options(silent.port, { idleTimeoutMs: 200, verdictTimeoutMs }),
|
||||
unfinishedFile('앞부분'),
|
||||
),
|
||||
);
|
||||
expect(error.failure).toBe('unavailable');
|
||||
expect(error.message).toContain('흐름을 받지 않았습니다');
|
||||
expect(error.message).toContain('흘려보내는 중(보낸 9바이트)');
|
||||
expect(verdictTimeoutMs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('크기 한도를 넘은 흐름을 clamd 가 RST 로 끊어 오류 글이 오지 않으면 unavailable — 크기 오류인지 알 수 없으니 다른 끊김처럼 다시 하고, 흘려보내는 중 · 보낸 바이트 수를 남긴다', async () => {
|
||||
const reset = await start(() => 'stream: OK', 8, 'reset');
|
||||
const source = unfinishedFile('0123456789abcdefghij');
|
||||
const error = await unavailableOf(() =>
|
||||
scanWithClamav(options(reset.port), source),
|
||||
);
|
||||
expect(error.failure).toBe('unavailable');
|
||||
expect(error.message).toContain('판정 글을 보내기 전에 연결을 닫았습니다');
|
||||
expect(error.message).toContain('흘려보내는 중(보낸 20바이트)');
|
||||
expect(source.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('연결하면 keepalive 를 켜고, 그것이 사라진 상대를 끊으면(read ETIMEDOUT — RST 도 없이) 기존 끊김과 같다 — unavailable(미뤄서 다시) · 같은 글에 까닭만 다르다', async () => {
|
||||
// 기존 끊김 — 다 보낸 뒤 clamd 가 RST 로 끊는다
|
||||
const reset = await start(() => (socket) => socket.resetAndDestroy());
|
||||
const byReset = await unavailableOf(() =>
|
||||
scanWithClamav(options(reset.port), fileOf('내용')),
|
||||
);
|
||||
|
||||
// keepalive — 답 없는 clamd 앞에서 커널이 끊는다. 판정을 기다리기 시작한 때 Node 가 그때 하는 대로 소켓을 그 오류로 닫는다
|
||||
const silent = await start(() => null);
|
||||
const captured = captureClamdSockets();
|
||||
const byKeepAlive = await unavailableOf(() =>
|
||||
scanWithClamav(
|
||||
options(silent.port, {
|
||||
keepAliveInitialDelayMs: 45_000,
|
||||
verdictTimeoutMs: () => {
|
||||
setImmediate(() => captured.sockets[0].destroy(keepAliveTimeout()));
|
||||
return 60_000;
|
||||
},
|
||||
}),
|
||||
fileOf('내용'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(captured.sockets).toHaveLength(1);
|
||||
expect(captured.keepAlive[0]).toHaveBeenCalledWith(true, 45_000);
|
||||
expect(byKeepAlive.failure).toBe('unavailable');
|
||||
expect(byKeepAlive.failure).toBe(byReset.failure);
|
||||
expect(byKeepAlive.message).toBe(
|
||||
'clamd 가 판정 글을 보내기 전에 연결을 닫았습니다 — 다 보낸 뒤(보낸 6바이트), 받은 0바이트 (read ETIMEDOUT)',
|
||||
);
|
||||
expect(withoutCause(byKeepAlive.message)).toBe(
|
||||
withoutCause(byReset.message),
|
||||
);
|
||||
});
|
||||
|
||||
it('검사를 멈추면(서버 종료) 연결과 파일을 닫는다', async () => {
|
||||
const silent = await start(() => null);
|
||||
const controller = new AbortController();
|
||||
const source = fileOf('내용');
|
||||
const scanning = scanWithClamav(
|
||||
options(silent.port),
|
||||
source,
|
||||
controller.signal,
|
||||
);
|
||||
setTimeout(() => controller.abort(), 50);
|
||||
await expect(scanning).rejects.toBeInstanceOf(ClamavUnavailableError);
|
||||
expect(source.destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { connect } from 'net';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
/**
|
||||
* clamd 의 INSTREAM 규약을 직접 구현한다 (새 의존성 없음 — 계약 4절 "악성 검사").
|
||||
*
|
||||
* `zINSTREAM\0` 을 보낸 뒤 내용을 조각마다 [길이 4바이트(빅엔디언) + 바이트]로 흘려보내고, 길이 0 으로 끝낸다.
|
||||
* clamd 는 판정 한 줄을 `\0` 으로 끝내 보내고 연결을 닫는다 — **`\0` 까지 받은 글만 판정으로 본다**
|
||||
* (그 전에 끊기면(RST · 빈 응답 · 끝나지 않은 글) 판정이 없는 것이다 — 다시 한다). 판정 글의 갈래는 아래 CLAMAV_REPLY 한 곳에서 본다:
|
||||
* - `stream: OK` → 깨끗함
|
||||
* - `stream: <이름> FOUND` → 위험 — 이름이 암호 압축 · 압축 안의 검사 한도(Heuristics.Encrypted.* · Heuristics.Limits.Exceeded.*)면 검사하지 못함
|
||||
* - `INSTREAM size limit exceeded. ERROR` → **설정 오류**(clamd 의 StreamMaxLength 가 한 파일 한도보다 작다) — 판정 없음, 부르는 쪽이 error 로 남기고 다시 한다
|
||||
* - 그 밖의 `… ERROR` · 모르는 모양 → 판정 없음 (다시 한다)
|
||||
* 판정이 없으면 ClamavUnavailableError(까닭 `failure`) — 부르는 쪽이 검사 중 그대로 두고 간격을 늘려 다시 한다.
|
||||
* **판정 없이 받을 수 있게 되는 일은 없다**(닫힌 쪽).
|
||||
*
|
||||
* 기다리는 시간은 세 단계다 — 연결(connectTimeoutMs), 흘려보내는 동안 아무것도 오가지 않는 시간(idleTimeoutMs),
|
||||
* 다 보낸 뒤 판정까지(verdictTimeoutMs(보낸 바이트 수) — clamd 는 다 받은 뒤에야 검사하고 그동안 아무것도 보내지 않는다. 큰 파일일수록 길다).
|
||||
* 연결하면 TCP keepalive 를 켠다(keepAliveInitialDelayMs) — 긴 판정 대기 동안 사라진 상대(RST 도 없이)를 커널이 `read ETIMEDOUT` 으로 끊는다.
|
||||
* 시간 초과 · 끊김의 글에는 어느 단계였는지와 보낸 바이트 수를 싣는다 — StreamMaxLength 를 넘은 흐름을 clamd 가 RST 로 끊으면
|
||||
* 크기 오류 글이 오지 않고 EPIPE · ECONNRESET 만 남는다(판정 없음 → 다른 끊김처럼 다시 하고, 이어지면 부르는 쪽이 error 로 남긴다).
|
||||
* 그때 "흘려보내는 중 · 보낸 N바이트"가 시도마다 같으면 크기 한도를 의심한다
|
||||
*
|
||||
* clamd 는 암호화하지 않은 TCP 다 — 백엔드와만 잇는 내부 네트워크에 둔다 (release-ops).
|
||||
*/
|
||||
|
||||
/** clamd 판정 */
|
||||
export type ClamavVerdict =
|
||||
| { readonly kind: 'clean' }
|
||||
| { readonly kind: 'infected'; readonly signature: string }
|
||||
/** 검사하지 못함 — 암호 압축 · 압축 안의 검사 한도 (signature 는 clamd 가 준 이름) */
|
||||
| { readonly kind: 'unscannable'; readonly signature: string };
|
||||
|
||||
/**
|
||||
* 판정을 받지 못한 까닭 — 어느 것이든 검사 중 그대로 두고 다시 한다
|
||||
* - unavailable: 닿지 못함 · 시간 초과 · 판정 글을 다 받기 전에 끊김(RST · 빈 응답) · 서버 종료
|
||||
* - error-reply: clamd 가 오류로 답했다 · 모르는 모양의 답
|
||||
* - misconfigured: 설정 오류 — 흐름이 clamd 의 StreamMaxLength 를 넘었다(한 파일 한도보다 작게 설정됨). 다시 해도 같으니 바로 error 로 남긴다
|
||||
*/
|
||||
export type ClamavFailure = 'unavailable' | 'error-reply' | 'misconfigured';
|
||||
|
||||
/** 판정을 받지 못했다 — 까닭은 `failure` */
|
||||
export class ClamavUnavailableError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly failure: ClamavFailure = 'unavailable',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ClamavUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* clamd 의 판정 글 — 견주기는 이 표와 classifyClamavReply 한 곳에만 둔다.
|
||||
* ⚠️ 실제 clamd 의 응답 모양은 release-ops 가 재는 중이다(2026-10-30) — 실측이 다르면 이 표(와 clamav-client.spec.ts 의 표본)만 고친다
|
||||
*/
|
||||
export const CLAMAV_REPLY = {
|
||||
/** INSTREAM 판정 줄의 머리 — `stream: …` */
|
||||
streamPrefix: 'stream: ',
|
||||
/** 깨끗함 — `stream: OK` */
|
||||
clean: 'OK',
|
||||
/** 판정 이름의 뒤 — `stream: <이름> FOUND` */
|
||||
foundSuffix: ' FOUND',
|
||||
/** clamd 의 오류 응답의 끝 — `<글> ERROR` */
|
||||
errorSuffix: 'ERROR',
|
||||
/** 흐름이 StreamMaxLength 를 넘었다는 오류 응답 — `INSTREAM size limit exceeded. ERROR` (설정 오류) */
|
||||
streamSizeLimit: /\bsize limit exceeded\b/i,
|
||||
/** 위험이 아니라 "검사하지 못함"인 판정 이름 — 암호 압축 · 압축 안의 검사 한도(파일 크기 · 개수 · 깊이 등) */
|
||||
unscannableSignature: /^Heuristics\.(?:Encrypted|Limits\.Exceeded)(?:\.|$)/,
|
||||
} as const;
|
||||
|
||||
export interface ClamavConnection {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
/** 연결까지 */
|
||||
readonly connectTimeoutMs: number;
|
||||
/** 흘려보내는 동안 아무것도 오가지 않는 시간의 상한 */
|
||||
readonly idleTimeoutMs: number;
|
||||
/**
|
||||
* 다 보낸 뒤(길이 0 을 쓴 뒤) 판정을 기다리는 시간 — 보낸 바이트 수로 정한다 (file-storage.constants.ts 의 clamavVerdictTimeoutMs).
|
||||
* 마지막 쓰기가 끝난 때부터 센다(소켓의 유휴 시간이라 쓰기가 끝날 때마다 다시 센다)
|
||||
*/
|
||||
readonly verdictTimeoutMs: (streamedBytes: number) => number;
|
||||
/** TCP keepalive 의 첫 대기 — 연결하면 켠다(사라진 상대를 판정 대기보다 일찍 알아차린다 — CLAMAV_KEEPALIVE_INITIAL_DELAY_MS) */
|
||||
readonly keepAliveInitialDelayMs: number;
|
||||
}
|
||||
|
||||
/** 판정 한 줄 상한 — 그보다 길게 `\0` 없이 오면 clamd 가 아니다 */
|
||||
const MAX_REPLY_BYTES = 4_096;
|
||||
|
||||
const INSTREAM_COMMAND = Buffer.from('zINSTREAM\0', 'latin1');
|
||||
const END_OF_STREAM = Buffer.alloc(4);
|
||||
|
||||
/**
|
||||
* clamd 응답 → 판정 (순수 함수 — 판정 글 견주기는 여기 한 곳). `\0` 까지 받은 글만 판정이다.
|
||||
* 판정이 아니면 ClamavUnavailableError 를 던진다 — 끝나지 않은 글 unavailable, 크기 한도 misconfigured, 그 밖의 오류 · 모르는 모양 error-reply
|
||||
*/
|
||||
export function classifyClamavReply(raw: string): ClamavVerdict {
|
||||
const end = raw.indexOf('\0');
|
||||
if (end < 0) {
|
||||
throw new ClamavUnavailableError(
|
||||
`clamd 가 판정 글을 끝까지 보내지 않았습니다 (${Buffer.byteLength(raw)}바이트)`,
|
||||
);
|
||||
}
|
||||
const reply = raw.slice(0, end).trim();
|
||||
if (reply.endsWith(CLAMAV_REPLY.errorSuffix)) {
|
||||
if (CLAMAV_REPLY.streamSizeLimit.test(reply)) {
|
||||
throw new ClamavUnavailableError(
|
||||
`clamd 의 흐름 크기 한도(StreamMaxLength)에 걸렸습니다: ${reply.slice(0, 200)}`,
|
||||
'misconfigured',
|
||||
);
|
||||
}
|
||||
throw new ClamavUnavailableError(
|
||||
`clamd 오류 응답: ${reply.slice(0, 200)}`,
|
||||
'error-reply',
|
||||
);
|
||||
}
|
||||
if (reply === `${CLAMAV_REPLY.streamPrefix}${CLAMAV_REPLY.clean}`) {
|
||||
return { kind: 'clean' };
|
||||
}
|
||||
if (
|
||||
reply.startsWith(CLAMAV_REPLY.streamPrefix) &&
|
||||
reply.endsWith(CLAMAV_REPLY.foundSuffix)
|
||||
) {
|
||||
const signature = reply
|
||||
.slice(CLAMAV_REPLY.streamPrefix.length, -CLAMAV_REPLY.foundSuffix.length)
|
||||
.trim();
|
||||
if (signature !== '') {
|
||||
return CLAMAV_REPLY.unscannableSignature.test(signature)
|
||||
? { kind: 'unscannable', signature }
|
||||
: { kind: 'infected', signature };
|
||||
}
|
||||
}
|
||||
throw new ClamavUnavailableError(
|
||||
`clamd 응답을 해석하지 못했습니다: ${reply.slice(0, 200)}`,
|
||||
'error-reply',
|
||||
);
|
||||
}
|
||||
|
||||
/** 검사가 어디까지 왔나 — 시간 초과 · 끊김의 글에 싣는다 */
|
||||
type ScanPhase = 'connecting' | 'streaming' | 'awaiting-verdict';
|
||||
|
||||
/** 기다린 시간(초) — 오류 글용 (`2,647` · `0.2`) */
|
||||
const secondsOf = (ms: number) =>
|
||||
(ms / 1000).toLocaleString('en-US', { maximumFractionDigits: 1 });
|
||||
|
||||
/**
|
||||
* 내용을 clamd 에 흘려보내고 판정을 받는다 — 내용은 다 읽거나 실패하면 닫는다(파일 핸들을 남기지 않는다).
|
||||
* `signal` 이 끊기면(서버 종료) 연결을 닫고 ClamavUnavailableError
|
||||
*/
|
||||
export function scanWithClamav(
|
||||
connection: ClamavConnection,
|
||||
source: Readable,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClamavVerdict> {
|
||||
return new Promise<ClamavVerdict>((resolve, reject) => {
|
||||
const socket = connect({ host: connection.host, port: connection.port });
|
||||
const replyParts: Buffer[] = [];
|
||||
let replyBytes = 0;
|
||||
let streamedBytes = 0;
|
||||
let phase: ScanPhase = 'connecting';
|
||||
let verdictTimeoutMs = 0;
|
||||
let settled = false;
|
||||
let lastError: unknown = null;
|
||||
|
||||
/** 시간이 지났을 때의 글 — 어느 단계에서 얼마나 기다렸는지 */
|
||||
const timeoutMessage = (): string => {
|
||||
const sent = `보낸 ${streamedBytes.toLocaleString('en-US')}바이트`;
|
||||
switch (phase) {
|
||||
case 'connecting':
|
||||
return `clamd 에 ${secondsOf(connection.connectTimeoutMs)}초 안에 닿지 못했습니다`;
|
||||
case 'streaming':
|
||||
return `clamd 가 ${secondsOf(connection.idleTimeoutMs)}초 동안 흐름을 받지 않았습니다 — 흘려보내는 중(${sent})`;
|
||||
case 'awaiting-verdict':
|
||||
return `clamd 가 다 받은 뒤 ${secondsOf(verdictTimeoutMs)}초 안에 판정을 보내지 않았습니다 — ${sent}`;
|
||||
}
|
||||
};
|
||||
/** 판정 글 전에 끊겼을 때의 단계 */
|
||||
const closedWhere = (): string => {
|
||||
const sent = `보낸 ${streamedBytes.toLocaleString('en-US')}바이트`;
|
||||
switch (phase) {
|
||||
case 'connecting':
|
||||
return '연결하는 중';
|
||||
case 'streaming':
|
||||
return `흘려보내는 중(${sent})`;
|
||||
case 'awaiting-verdict':
|
||||
return `다 보낸 뒤(${sent})`;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
if (!source.destroyed) source.destroy();
|
||||
if (!socket.destroyed) socket.destroy();
|
||||
};
|
||||
const fail = (error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(
|
||||
error instanceof ClamavUnavailableError
|
||||
? error
|
||||
: new ClamavUnavailableError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
);
|
||||
};
|
||||
const finishWithReply = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
resolve(
|
||||
classifyClamavReply(Buffer.concat(replyParts).toString('utf8')),
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
function onAbort() {
|
||||
fail(new ClamavUnavailableError('검사를 멈췄습니다 (서버 종료)'));
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
socket.setTimeout(connection.connectTimeoutMs);
|
||||
socket.on('timeout', () =>
|
||||
fail(new ClamavUnavailableError(timeoutMessage())),
|
||||
);
|
||||
socket.on('error', (error) => {
|
||||
// 크기 한도로 clamd 가 먼저 닫으면 쓰기가 EPIPE · ECONNRESET 으로 끝난다 — 판정 글(`\0` 까지)을 이미 받았으면 그것을 쓴다
|
||||
lastError = error;
|
||||
});
|
||||
socket.on('close', () => {
|
||||
// `\0` 까지 받았으면 data 에서 이미 끝냈다 — 여기 오면 판정 글 전에 끊긴 것이다(RST · 빈 응답 · 끝나지 않은 글): 다시 한다.
|
||||
// 크기 한도를 RST 로 끊어 오류 글이 오지 않은 것도 여기다 — 크기 오류인지 알 수 없으니 다른 끊김과 같이 다룬다(단계 · 보낸 바이트 수를 남긴다)
|
||||
const cause = lastError instanceof Error ? ` (${lastError.message})` : '';
|
||||
fail(
|
||||
new ClamavUnavailableError(
|
||||
phase === 'connecting'
|
||||
? `clamd 에 닿지 못했습니다${cause}`
|
||||
: `clamd 가 판정 글을 보내기 전에 연결을 닫았습니다 — ${closedWhere()}, 받은 ${replyBytes}바이트${cause}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
socket.on('data', (data: Buffer) => {
|
||||
replyParts.push(data);
|
||||
replyBytes += data.length;
|
||||
if (data.includes(0)) finishWithReply();
|
||||
else if (replyBytes > MAX_REPLY_BYTES) {
|
||||
fail(
|
||||
new ClamavUnavailableError('clamd 응답이 너무 깁니다', 'error-reply'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
socket.once('connect', () => {
|
||||
phase = 'streaming';
|
||||
// 판정 대기가 길다(최대 1시간 남짓) — 컨테이너째 사라져 RST 도 오지 않는 상대는 커널이 묻고 답이 없으면 ETIMEDOUT 으로 끊는다(아래 close — 다른 끊김과 같다)
|
||||
socket.setKeepAlive(true, connection.keepAliveInitialDelayMs);
|
||||
socket.setTimeout(connection.idleTimeoutMs);
|
||||
socket.write(INSTREAM_COMMAND);
|
||||
source.on('data', (piece: Buffer | string) => {
|
||||
const data = Buffer.isBuffer(piece) ? piece : Buffer.from(piece);
|
||||
if (data.length === 0) return;
|
||||
streamedBytes += data.length;
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt32BE(data.length, 0);
|
||||
socket.write(header);
|
||||
// 소켓이 밀리면 파일 읽기를 멈춘다 — 2GB 를 메모리에 쌓지 않게
|
||||
if (!socket.write(data)) {
|
||||
source.pause();
|
||||
socket.once('drain', () => source.resume());
|
||||
}
|
||||
});
|
||||
source.once('end', () => {
|
||||
if (socket.destroyed) return;
|
||||
socket.write(END_OF_STREAM);
|
||||
// 여기서부터 clamd 가 검사한다 — 판정까지 아무것도 오가지 않으니 보낸 크기에 맞춰 기다린다
|
||||
phase = 'awaiting-verdict';
|
||||
verdictTimeoutMs = connection.verdictTimeoutMs(streamedBytes);
|
||||
socket.setTimeout(verdictTimeoutMs);
|
||||
});
|
||||
source.once('error', fail);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
import {
|
||||
MaxCodeUnits,
|
||||
MinCodeUnits,
|
||||
} from '@/common/decorators/code-units.decorator';
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import {
|
||||
FILE_CHUNK_SIZE,
|
||||
FILE_PURPOSES,
|
||||
MAX_UPLOAD_NAME_LENGTH,
|
||||
} from '../file-storage.constants';
|
||||
import type {
|
||||
FileQuota,
|
||||
LargeFile,
|
||||
MissingLargeFile,
|
||||
ScanStatus,
|
||||
} from '../file-storage.types';
|
||||
|
||||
const SCAN_STATUSES: ScanStatus[] = [
|
||||
'pending',
|
||||
'clean',
|
||||
'infected',
|
||||
'unscannable',
|
||||
];
|
||||
|
||||
/** POST /api/file-uploads — 계약 4절 "올리기" 1 */
|
||||
export class FileUploadCreateDto {
|
||||
@ApiProperty({
|
||||
description: `파일 이름 (1~${MAX_UPLOAD_NAME_LENGTH}자 — JS 문자열 길이). 서버가 정리한 이름(응답의 name)으로 둔다 — 경로 · 제어 · 방향 제어 문자를 지운다. 형식은 정리한 이름의 확장자로만 본다(밖이면 400 FILE_001)`,
|
||||
example: '현장영상_1029.mp4',
|
||||
})
|
||||
// 형식 → 길이 차례로 (적은 차례대로 돈다 — applyDecorators)
|
||||
@applyDecorators(
|
||||
IsString({ message: '파일 이름 형식이 올바르지 않습니다.' }),
|
||||
MinCodeUnits(1, { message: '파일 이름이 비어 있습니다.' }),
|
||||
MaxCodeUnits(MAX_UPLOAD_NAME_LENGTH, {
|
||||
message: `파일 이름은 ${MAX_UPLOAD_NAME_LENGTH}자 이하여야 합니다.`,
|
||||
}),
|
||||
)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'바이트 — 1 이상 정수. 한 파일 한도(GET /api/file-quota 의 maxFileBytes)를 넘으면 413 FILE_002',
|
||||
example: 1_288_490_189,
|
||||
})
|
||||
@IsInt({ message: '파일 크기는 정수여야 합니다.' })
|
||||
@Min(1, { message: '빈 파일은 올릴 수 없습니다.' })
|
||||
@Max(Number.MAX_SAFE_INTEGER, { message: '파일 크기가 너무 큽니다.' })
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: "쓰임 — 11단계는 'mail' 만 (2부 파일 저장소가 더한다)",
|
||||
enum: FILE_PURPOSES,
|
||||
example: 'mail',
|
||||
})
|
||||
@IsIn(FILE_PURPOSES, {
|
||||
message: '파일의 쓰임이 올바르지 않습니다.',
|
||||
})
|
||||
purpose!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'브라우저 File.lastModified (ms, 0 이상 정수) — 새로고침 뒤 다시 고른 파일이 같은지 맞춰 볼 때 쓴다',
|
||||
example: 1_793_241_600_000,
|
||||
nullable: true,
|
||||
type: Number,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt({ message: '파일의 고친 시각 형식이 올바르지 않습니다.' })
|
||||
@Min(0, { message: '파일의 고친 시각 형식이 올바르지 않습니다.' })
|
||||
@Max(Number.MAX_SAFE_INTEGER, {
|
||||
message: '파일의 고친 시각 형식이 올바르지 않습니다.',
|
||||
})
|
||||
lastModified?: number | null;
|
||||
}
|
||||
|
||||
export class LargeFileDto implements LargeFile {
|
||||
@ApiProperty({
|
||||
description: '올리기 id = 다 올린 뒤의 파일 id (바뀌지 않는다)',
|
||||
example: '0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '서버가 정리한 이름',
|
||||
example: '현장영상_1029.mp4',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '바이트', example: 1_288_490_189 })
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '올리는 중 · 다 올림',
|
||||
enum: ['uploading', 'ready'],
|
||||
})
|
||||
status!: 'uploading' | 'ready';
|
||||
|
||||
@ApiProperty({
|
||||
description: `조각 크기 — ${FILE_CHUNK_SIZE.toLocaleString('ko-KR')} (마지막 조각만 짧다)`,
|
||||
example: FILE_CHUNK_SIZE,
|
||||
})
|
||||
chunkSize!: number;
|
||||
|
||||
@ApiProperty({ description: 'ceil(size / chunkSize)', example: 77 })
|
||||
chunkCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '받은 조각 번호(0 부터, 오름차순) — ready 면 모두',
|
||||
type: [Number],
|
||||
example: [0, 1, 2],
|
||||
})
|
||||
receivedChunks!: number[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'받은 0번 조각의 SHA-256 (base64) — 받기 전이면 null. 새로고침 뒤 다시 고른 파일이 같은지 맞춰 본다',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
firstChunkSha256!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'악성 검사 — ready 일 때만 (uploading 은 null). pending 검사 중 · clean 깨끗함 · infected 위험 · unscannable 검사하지 못함',
|
||||
enum: SCAN_STATUSES,
|
||||
nullable: true,
|
||||
})
|
||||
scanStatus!: ScanStatus | null;
|
||||
|
||||
@ApiProperty({ description: '만든 때 (ISO 8601 UTC)' })
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이대로 두면 지워지는 때 — 올리는 중은 마지막 조각 뒤 24시간, 어디에도 실리지 않은 파일은 다 올린 뒤 24시간, 임시 저장에 실린 파일은 다 올린 날(한국)부터 30일째 날 끝, 보낸 파일은 링크가 끝나는 때',
|
||||
})
|
||||
expiresAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '만들 때 받은 File.lastModified (ms)',
|
||||
nullable: true,
|
||||
type: Number,
|
||||
})
|
||||
lastModified!: number | null;
|
||||
}
|
||||
|
||||
export class MissingLargeFileDto implements MissingLargeFile {
|
||||
@ApiProperty({ description: '파일 id' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '없는 파일', enum: ['missing'] })
|
||||
status!: 'missing';
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'deleted 지운 파일(설정 · 쓰기 화면에서 지움) · expired 기간이 지남(올리다 멈춘 지 24시간 · 임시 저장 30일 등)',
|
||||
enum: ['deleted', 'expired'],
|
||||
})
|
||||
reason!: 'deleted' | 'expired';
|
||||
|
||||
@ApiProperty({
|
||||
description: '기록이 남아 있으면(지운 뒤 30일) 이름',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '기록이 남아 있으면 크기',
|
||||
nullable: true,
|
||||
type: Number,
|
||||
})
|
||||
size!: number | null;
|
||||
}
|
||||
|
||||
export class LargeFilePageDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'보내지 않은 내 파일 — 살아 있는 링크에 실리지 않은 것(올리는 중 포함), 만든 때 최근부터',
|
||||
type: [LargeFileDto],
|
||||
})
|
||||
items!: LargeFileDto[];
|
||||
|
||||
@ApiProperty({ description: '페이지 정보 (total 포함)', type: PageInfoDto })
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
export class FileQuotaDto implements FileQuota {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'지금 잡고 있는 양 — 올리는 중(선언한 크기) + 보내지 않은 파일 + 기간 안의 보낸 파일 (위험 판정 파일은 빠진다)',
|
||||
example: 6_657_199_309,
|
||||
})
|
||||
usedBytes!: number;
|
||||
|
||||
@ApiProperty({ description: '직원당 보관량 한도', example: 10_737_418_240 })
|
||||
limitBytes!: number;
|
||||
|
||||
@ApiProperty({ description: '한 파일 한도', example: 2_146_435_072 })
|
||||
maxFileBytes!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 한 통에 새로 싣는 대용량 파일 수',
|
||||
example: 10,
|
||||
})
|
||||
maxFilesPerMessage!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '조각 크기 — 올리기 응답의 chunkSize 와 같다',
|
||||
example: FILE_CHUNK_SIZE,
|
||||
})
|
||||
chunkSize!: number;
|
||||
|
||||
@ApiProperty({ description: '보낸 날부터 받을 수 있는 날 수', example: 30 })
|
||||
retentionDays!: number;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
/**
|
||||
* 파일 바이트 저장 포트 (11단계 — 계약 4절 "엔진에 두지 않는다") — 서비스는 이 포트만 안다.
|
||||
* 지금은 로컬 디스크(local-disk-blob-store.ts — 볼륨 `/app/files`), 인스턴스를 늘리면 S3 호환 어댑터를 같은 포트로 만든다
|
||||
* (조각 하나 = multipart 의 part 하나, 끝내기 = CompleteMultipartUpload — nestjs 스킬 "확장 때 옮길 것").
|
||||
*
|
||||
* 키는 서버가 만든 파일 id(UUID)다 — 바깥 값이 경로가 되지 않는다.
|
||||
*/
|
||||
export const FILE_BLOB_STORE = Symbol('FILE_BLOB_STORE');
|
||||
|
||||
/** 조각을 다 쓴 결과 */
|
||||
export interface WrittenChunk {
|
||||
/** 쓴 바이트 수 — expectedBytes 와 같을 때만 돌아온다 */
|
||||
readonly bytes: number;
|
||||
/** 받은 바이트의 SHA-256 */
|
||||
readonly sha256: Buffer;
|
||||
}
|
||||
|
||||
/** 조각 본문이 선언한 길이보다 짧게 끝났다(클라이언트가 끊음) · 길다 — 그 조각은 받지 않은 것이다 */
|
||||
export class ChunkLengthMismatchError extends Error {
|
||||
constructor(
|
||||
readonly expected: number,
|
||||
readonly received: number,
|
||||
) {
|
||||
super(`조각 길이가 맞지 않습니다: 기대 ${expected}, 받음 ${received}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 저장소의 한 항목 (정리 작업용) */
|
||||
export interface StoredBlob {
|
||||
readonly key: string;
|
||||
/** 올리는 중의 자리 · 다 올린 파일 */
|
||||
readonly stage: 'incoming' | 'stored';
|
||||
readonly modifiedAt: Date;
|
||||
}
|
||||
|
||||
/** 끝 포함 바이트 범위 */
|
||||
export interface BlobRange {
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
}
|
||||
|
||||
export interface FileBlobStore {
|
||||
/** 올리기 자리를 만든다 — 선언한 크기(조각을 차례 없이 제자리에 쓴다) */
|
||||
begin(key: string, size: number): Promise<void>;
|
||||
/**
|
||||
* 조각 하나를 offset 부터 쓴다 — source 를 끝까지 읽으며 SHA-256 을 함께 계산하고, 다 쓴 뒤 디스크에 내려쓴다(datasync).
|
||||
* 길이가 expectedBytes 와 다르면 ChunkLengthMismatchError, 디스크가 차면 ENOSPC 오류 그대로
|
||||
*/
|
||||
writeChunk(
|
||||
key: string,
|
||||
offset: number,
|
||||
expectedBytes: number,
|
||||
source: Readable,
|
||||
): Promise<WrittenChunk>;
|
||||
/** 올리기를 끝낸다 — 읽을 수 있는 파일로 옮긴다 (원자적) */
|
||||
complete(key: string): Promise<void>;
|
||||
/** 다 올린 파일이 있는가 (끊긴 끝내기를 되살릴 때) */
|
||||
isStored(key: string): Promise<boolean>;
|
||||
/** 올리는 중의 자리가 있는가 */
|
||||
isIncoming(key: string): Promise<boolean>;
|
||||
/**
|
||||
* 다 올린 파일을 읽는다 — 파일을 연 뒤에 돌아온다(없으면 ENOENT 오류 그대로 — 머리글을 쓰기 전에 알 수 있게).
|
||||
* range 가 있으면 그 범위만(끝 포함)
|
||||
*/
|
||||
openRead(key: string, range?: BlobRange): Promise<Readable>;
|
||||
/** 올리는 중의 자리와 다 올린 파일을 모두 지운다 — 이미 없으면 그대로 */
|
||||
remove(key: string): Promise<void>;
|
||||
/** 저장소의 모든 항목 (정리 작업 — 표에 없는 파일 찾기) */
|
||||
list(): Promise<StoredBlob[]>;
|
||||
/** 남은 공간(바이트) — 알 수 없으면 null */
|
||||
freeBytes(): Promise<number | null>;
|
||||
}
|
||||
|
||||
/** 디스크가 찼다 (ENOSPC) — 507 FILE_006 */
|
||||
export function isDiskFull(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOSPC';
|
||||
}
|
||||
|
||||
/** 없는 파일 (ENOENT) */
|
||||
export function isMissingFile(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { ChunkIndexPipe, FileIdPipe } from './file-id.pipe';
|
||||
|
||||
const codeOf = (run: () => unknown) => {
|
||||
try {
|
||||
run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
};
|
||||
|
||||
describe('FileIdPipe · ChunkIndexPipe', () => {
|
||||
it('UUID 모양만 받고 소문자로 — 아니면 404 RES_001', () => {
|
||||
const pipe = new FileIdPipe();
|
||||
expect(pipe.transform('0F8FAD5B-D9CB-469F-A165-70867728950E')).toBe(
|
||||
'0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
);
|
||||
expect(codeOf(() => pipe.transform('abc'))).toBe('RES_001');
|
||||
expect(codeOf(() => pipe.transform('../etc/passwd'))).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('조각 번호는 앞자리 0 없는 10진수 글만 — 아니면 400 VAL_001', () => {
|
||||
const pipe = new ChunkIndexPipe();
|
||||
expect(pipe.transform('0')).toBe(0);
|
||||
expect(pipe.transform('127')).toBe(127);
|
||||
expect(pipe.transform('999999')).toBe(999_999);
|
||||
for (const value of [
|
||||
'01',
|
||||
'007',
|
||||
'-1',
|
||||
'+3',
|
||||
' 2',
|
||||
'2 ',
|
||||
'1.5',
|
||||
'1.0',
|
||||
'1e1',
|
||||
'0x1',
|
||||
'0b11',
|
||||
'a',
|
||||
'',
|
||||
'1000000',
|
||||
'١',
|
||||
]) {
|
||||
expect({ value, code: codeOf(() => pipe.transform(value)) }).toEqual({
|
||||
value,
|
||||
code: 'VAL_001',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('숫자로 오면 거절한다 — 전역 ValidationPipe 가 바꾼 값(매개변수를 number 로 적은 것)을 받지 않는다', () => {
|
||||
const pipe = new ChunkIndexPipe();
|
||||
for (const value of [0, 1, Number.NaN, null, undefined]) {
|
||||
expect(codeOf(() => pipe.transform(value))).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, PipeTransform } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/** 파일 · 링크 id — 서버가 만든 UUID (대소문자 무시, 소문자로 다룬다) */
|
||||
export const FILE_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* 경로의 파일 · 링크 id 형식 확인 — UUID 모양이 아니면 "그런 대상이 없다"와 같으므로 404 RES_001 (계약 4절 — DB 에 묻지 않는다)
|
||||
*/
|
||||
@Injectable()
|
||||
export class FileIdPipe implements PipeTransform<unknown, string> {
|
||||
transform(value: unknown): string {
|
||||
if (typeof value === 'string' && FILE_ID_PATTERN.test(value)) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'malformed-file-id' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 조각 번호 — 0 이상 정수, 앞자리 0 없는 10진수 6자리까지(999,999 — 2,047MB 는 128조각이라 넉넉하다) */
|
||||
const CHUNK_INDEX_PATTERN = /^(?:0|[1-9][0-9]{0,5})$/;
|
||||
|
||||
/**
|
||||
* 경로의 조각 번호 — 앞자리 0 없는 10진수 글만 받는다(`1e1` · `0x1` · `+3` · ` 2` · `007` · `1.0` · `0b11` 은 400 VAL_001). 범위(chunkCount)는 서비스가 본다.
|
||||
* ⚠️ 처리기의 매개변수 타입을 `unknown` 으로 둔다 — `number` 로 적으면 전역 ValidationPipe(transform)가 이 파이프 **앞에서** 글을 `+value` 로
|
||||
* 숫자로 바꿔 넘겨 위의 모양을 받아 버린다(2026-10-30 리뷰 L3). 그래서 숫자가 오면 거절한다 — 매개변수 타입이 바뀐 것을 테스트가 알아챈다
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChunkIndexPipe implements PipeTransform<unknown, number> {
|
||||
transform(value: unknown): number {
|
||||
if (typeof value === 'string' && CHUNK_INDEX_PATTERN.test(value)) {
|
||||
return Number(value);
|
||||
}
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'malformed-chunk-index' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
fileExpiresAt,
|
||||
isOnLiveLink,
|
||||
type RetentionInput,
|
||||
} from './file-retention';
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const at = (iso: string) => new Date(iso);
|
||||
const NOW = at('2026-10-30T05:00:00.000Z');
|
||||
|
||||
const base = (overrides: Partial<RetentionInput> = {}): RetentionInput => ({
|
||||
state: 'ready',
|
||||
createdAt: at('2026-10-30T01:00:00.000Z'),
|
||||
lastChunkAt: at('2026-10-30T02:00:00.000Z'),
|
||||
completedAt: at('2026-10-30T03:00:00.000Z'),
|
||||
draftAttachedAt: null,
|
||||
linkedAt: null,
|
||||
linkedUntil: null,
|
||||
scanStatus: 'clean',
|
||||
scannedAt: null,
|
||||
removedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('fileExpiresAt — 계약 4절 "기간 · 보관 · 정리"', () => {
|
||||
it('올리는 중은 마지막 조각(없으면 만든 때) 뒤 24시간 — 임시 저장에 실려도 같다', () => {
|
||||
expect(
|
||||
fileExpiresAt(
|
||||
base({ state: 'uploading', completedAt: null, scanStatus: null }),
|
||||
30,
|
||||
NOW,
|
||||
).toISOString(),
|
||||
).toBe('2026-10-31T02:00:00.000Z');
|
||||
expect(
|
||||
fileExpiresAt(
|
||||
base({
|
||||
state: 'uploading',
|
||||
completedAt: null,
|
||||
lastChunkAt: null,
|
||||
scanStatus: null,
|
||||
draftAttachedAt: NOW,
|
||||
}),
|
||||
30,
|
||||
NOW,
|
||||
).toISOString(),
|
||||
).toBe('2026-10-31T01:00:00.000Z');
|
||||
});
|
||||
|
||||
it('다 올렸지만 어디에도 실리지 않은 파일은 다 올린 뒤 24시간', () => {
|
||||
expect(fileExpiresAt(base(), 30, NOW).toISOString()).toBe(
|
||||
'2026-10-31T03:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('임시 저장에 실린 파일은 다 올린 날(한국)부터 30일째 날 끝', () => {
|
||||
expect(
|
||||
fileExpiresAt(base({ draftAttachedAt: NOW }), 30, NOW).toISOString(),
|
||||
).toBe('2026-11-29T14:59:59.999Z');
|
||||
});
|
||||
|
||||
it('보낸 파일은 살아 있는 링크의 끝 — 보낸 뒤로 임시 저장에 실리지 않았으면 임시 저장 기간은 남기지 않는다', () => {
|
||||
const linkEnd = at('2026-12-01T14:59:59.999Z');
|
||||
expect(
|
||||
fileExpiresAt(
|
||||
base({
|
||||
draftAttachedAt: at('2026-10-30T04:00:00.000Z'),
|
||||
linkedAt: at('2026-10-30T04:30:00.000Z'),
|
||||
linkedUntil: linkEnd,
|
||||
}),
|
||||
30,
|
||||
NOW,
|
||||
),
|
||||
).toEqual(linkEnd);
|
||||
});
|
||||
|
||||
it('링크를 끊으면(끊지 않은 링크가 없음) 곧 — 다른 쓰임이 없을 때', () => {
|
||||
expect(
|
||||
fileExpiresAt(
|
||||
base({
|
||||
draftAttachedAt: at('2026-10-30T04:00:00.000Z'),
|
||||
linkedAt: at('2026-10-30T04:30:00.000Z'),
|
||||
linkedUntil: null,
|
||||
}),
|
||||
30,
|
||||
NOW,
|
||||
),
|
||||
).toEqual(NOW);
|
||||
});
|
||||
|
||||
it('보낸 뒤에 다시 임시 저장에 실리면(취소해 되돌린 발송 등) 임시 저장 기간으로 남는다', () => {
|
||||
expect(
|
||||
fileExpiresAt(
|
||||
base({
|
||||
draftAttachedAt: at('2026-10-30T04:45:00.000Z'),
|
||||
linkedAt: at('2026-10-30T04:30:00.000Z'),
|
||||
linkedUntil: null,
|
||||
}),
|
||||
30,
|
||||
NOW,
|
||||
).toISOString(),
|
||||
).toBe('2026-11-29T14:59:59.999Z');
|
||||
});
|
||||
|
||||
it('위험 판정 파일은 판정 뒤 30일(기록) — 살아 있는 링크가 더 늦게 끝나면 그때까지', () => {
|
||||
const scannedAt = at('2026-10-30T04:00:00.000Z');
|
||||
expect(
|
||||
fileExpiresAt(
|
||||
base({ scanStatus: 'infected', scannedAt, draftAttachedAt: NOW }),
|
||||
30,
|
||||
NOW,
|
||||
),
|
||||
).toEqual(new Date(scannedAt.getTime() + 30 * DAY));
|
||||
const linkEnd = at('2027-01-01T00:00:00.000Z');
|
||||
expect(
|
||||
fileExpiresAt(
|
||||
base({
|
||||
scanStatus: 'infected',
|
||||
scannedAt,
|
||||
linkedAt: scannedAt,
|
||||
linkedUntil: linkEnd,
|
||||
}),
|
||||
30,
|
||||
NOW,
|
||||
),
|
||||
).toEqual(linkEnd);
|
||||
});
|
||||
|
||||
it('지운 파일은 지운 때', () => {
|
||||
const removedAt = at('2026-10-30T04:00:00.000Z');
|
||||
expect(
|
||||
fileExpiresAt(base({ state: 'removed', removedAt }), 30, NOW),
|
||||
).toEqual(removedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnLiveLink', () => {
|
||||
it('끊지 않은 링크의 끝이 지금보다 뒤일 때만', () => {
|
||||
expect(isOnLiveLink({ linkedUntil: null }, NOW)).toBe(false);
|
||||
expect(
|
||||
isOnLiveLink({ linkedUntil: new Date(NOW.getTime() + 1) }, NOW),
|
||||
).toBe(true);
|
||||
expect(isOnLiveLink({ linkedUntil: NOW }, NOW)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { kstEndOfDayAfter } from '@/shared/utils/kst-date';
|
||||
import {
|
||||
INFECTED_FILE_RECORD_RETENTION_MS,
|
||||
UNATTACHED_FILE_RETENTION_MS,
|
||||
UPLOAD_IDLE_RETENTION_MS,
|
||||
} from './file-storage.constants';
|
||||
import type { StoredFileState } from './stored-file.entity';
|
||||
|
||||
/**
|
||||
* 파일이 남는 기간 (순수 함수) — 계약 4절 "기간 · 보관 · 정리"의 표:
|
||||
*
|
||||
* | 대상 | 남는 기간 |
|
||||
* | --- | --- |
|
||||
* | 올리는 중 | 마지막 조각(없으면 만든 때) 뒤 24시간 — 임시 저장에 실려도 같다 |
|
||||
* | 다 올렸지만 어디에도 실리지 않은 파일 | 다 올린 뒤 24시간 |
|
||||
* | 임시 저장에 한 번이라도 실린 파일 | 다 올린 날(한국)부터 보관 날 수(30)째 날 끝 |
|
||||
* | 보낸 파일 | 그 파일을 실은 살아 있는 링크가 끝나는 때 가운데 가장 늦은 것 (임시 저장 기간보다 늦으면) |
|
||||
* | 끊은 링크의 파일 · 위험 판정 파일 | 다른 쓰임이 없으면 곧 — 위험 판정 파일은 기록만 판정 뒤 30일 |
|
||||
*
|
||||
* "보낸 뒤"의 임시 저장 기간: 보낸 파일은 보낸 때까지의 임시 저장 기록으로 남지 않는다 — 링크를 끊으면 바로 빠져야 해서다.
|
||||
* 보낸 **뒤에** 다시 임시 저장에 실리면(다른 저장본 · 취소해 되돌린 발송) 그때부터 다시 임시 저장 기간이다.
|
||||
* 링크는 공유 모듈이 알려 준다(`linkedAt` — 취소하지 않은 링크의 가장 늦게 만든 때 · `linkedUntil` — 끊지 않은 링크의 가장 늦은 끝).
|
||||
*/
|
||||
export interface RetentionInput {
|
||||
readonly state: StoredFileState;
|
||||
readonly createdAt: Date;
|
||||
readonly lastChunkAt: Date | null;
|
||||
readonly completedAt: Date | null;
|
||||
readonly draftAttachedAt: Date | null;
|
||||
readonly linkedAt: Date | null;
|
||||
readonly linkedUntil: Date | null;
|
||||
readonly scanStatus: string | null;
|
||||
readonly scannedAt: Date | null;
|
||||
readonly removedAt: Date | null;
|
||||
}
|
||||
|
||||
const later = (a: Date | null, b: Date | null): Date | null =>
|
||||
a === null ? b : b === null ? a : a > b ? a : b;
|
||||
|
||||
/**
|
||||
* 이대로 두면 지워지는 때 — 지금(now)이 이 때를 지나면 보관량에서 빠지고 정리 작업이 지운다.
|
||||
* @param retentionDays 임시 저장에 실린 파일이 남는 날 수 (MAIL_LARGE_FILE_RETENTION_DAYS)
|
||||
*/
|
||||
export function fileExpiresAt(
|
||||
file: RetentionInput,
|
||||
retentionDays: number,
|
||||
now: Date,
|
||||
): Date {
|
||||
if (file.state === 'removed') return file.removedAt ?? now;
|
||||
if (file.state !== 'ready' || file.completedAt === null) {
|
||||
// 올리는 중 · 끝내는 중
|
||||
const lastActivity = file.lastChunkAt ?? file.createdAt;
|
||||
return new Date(lastActivity.getTime() + UPLOAD_IDLE_RETENTION_MS);
|
||||
}
|
||||
|
||||
let own: Date | null;
|
||||
if (file.scanStatus === 'infected') {
|
||||
// 바이트는 곧 지우고 기록(이름 · 판정)만 남긴다
|
||||
own = new Date(
|
||||
(file.scannedAt ?? now).getTime() + INFECTED_FILE_RECORD_RETENTION_MS,
|
||||
);
|
||||
} else if (
|
||||
file.draftAttachedAt !== null &&
|
||||
(file.linkedAt === null || file.draftAttachedAt > file.linkedAt)
|
||||
) {
|
||||
own = kstEndOfDayAfter(file.completedAt, retentionDays);
|
||||
} else if (file.linkedAt === null) {
|
||||
own = new Date(file.completedAt.getTime() + UNATTACHED_FILE_RETENTION_MS);
|
||||
} else {
|
||||
// 보냈고 그 뒤 임시 저장에 실리지 않았다 — 링크가 남는 동안만
|
||||
own = null;
|
||||
}
|
||||
// 아무것도 붙잡지 않으면 곧(지금) — 끊은 링크의 파일
|
||||
return later(own, file.linkedUntil) ?? now;
|
||||
}
|
||||
|
||||
/** 살아 있는 링크(끊지 않고 기간 안)에 실린 파일인가 — 지우기는 409 FILE_003, 보내지 않은 파일 목록에서 빠진다 */
|
||||
export function isOnLiveLink(
|
||||
file: Pick<RetentionInput, 'linkedUntil'>,
|
||||
now: Date,
|
||||
): boolean {
|
||||
return file.linkedUntil !== null && file.linkedUntil > now;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { ClamavUnavailableError, type ClamavVerdict } from './clamav-client';
|
||||
import type { FileBlobStore } from './file-blob-store';
|
||||
import { FileScanService, type FileScanner } from './file-scan.service';
|
||||
import {
|
||||
FILE_SCAN_ERROR_AFTER_ATTEMPTS,
|
||||
FILE_SCAN_RETRY_BASE_MS,
|
||||
} from './file-storage.constants';
|
||||
import type { FileStorageRepository } from './file-storage.repository';
|
||||
import type { FileStorageSettings } from './file-storage.settings';
|
||||
import type { FileScanVerdictEvent } from './file-storage.types';
|
||||
import type { StoredFileEntity, StoredFileState } from './stored-file.entity';
|
||||
|
||||
const NOW = new Date();
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const file = (
|
||||
id: string,
|
||||
overrides: Partial<StoredFileEntity> = {},
|
||||
): StoredFileEntity => ({
|
||||
id,
|
||||
ownerEmail: 'ara.kim@arasystem.test',
|
||||
purpose: 'mail',
|
||||
name: `${id}.zip`,
|
||||
size: 10,
|
||||
chunkSize: 16,
|
||||
chunkCount: 1,
|
||||
lastModified: null,
|
||||
state: 'ready',
|
||||
removedReason: null,
|
||||
firstChunkSha256: null,
|
||||
scanStatus: 'pending',
|
||||
scanSignature: null,
|
||||
scanAttempts: 0,
|
||||
scanNextAt: new Date(NOW.getTime() - 1),
|
||||
scannedAt: null,
|
||||
createdAt: new Date(NOW.getTime() - 60_000),
|
||||
lastChunkAt: new Date(NOW.getTime() - 60_000),
|
||||
completedAt: new Date(NOW.getTime() - 30_000),
|
||||
completionStartedAt: null,
|
||||
draftAttachedAt: null,
|
||||
linkedAt: null,
|
||||
linkedUntil: null,
|
||||
expiresAt: new Date(NOW.getTime() + DAY),
|
||||
removedAt: null,
|
||||
bytesDeletedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** 검사 차례 표 대역 — 검사를 기다리는 파일(다 올림 · 검사 중 · 바이트 있음 · 때가 됨)을 먼저 끝낸 차례로 */
|
||||
class FakeRepository {
|
||||
readonly rows = new Map<string, StoredFileEntity>();
|
||||
|
||||
findScanCandidates(now: Date, exclude: readonly string[], limit: number) {
|
||||
return Promise.resolve(
|
||||
[...this.rows.values()]
|
||||
.filter(
|
||||
(row) =>
|
||||
row.state === 'ready' &&
|
||||
row.scanStatus === 'pending' &&
|
||||
row.bytesDeletedAt === null &&
|
||||
row.scanNextAt !== null &&
|
||||
row.scanNextAt <= now &&
|
||||
!exclude.includes(row.id),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.completedAt?.getTime() ?? 0) - (b.completedAt?.getTime() ?? 0),
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((row) => ({ ...row })),
|
||||
);
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
const row = this.rows.get(id);
|
||||
return Promise.resolve(row ? { ...row } : null);
|
||||
}
|
||||
|
||||
transition(
|
||||
id: string,
|
||||
from: readonly StoredFileState[],
|
||||
changes: Partial<StoredFileEntity>,
|
||||
) {
|
||||
const row = this.rows.get(id);
|
||||
if (!row || !from.includes(row.state)) return Promise.resolve(false);
|
||||
Object.assign(row, changes);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
update(id: string, changes: Partial<StoredFileEntity>) {
|
||||
const row = this.rows.get(id);
|
||||
if (row) Object.assign(row, changes);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const settings = { retentionDays: 30 } as FileStorageSettings;
|
||||
|
||||
describe('FileScanService — 악성 검사 차례 (11단계)', () => {
|
||||
let repository: FakeRepository;
|
||||
let blobs: { openRead: jest.Mock; remove: jest.Mock };
|
||||
let verdicts: Map<string, ClamavVerdict | Error>;
|
||||
let scanned: string[];
|
||||
let service: FileScanService;
|
||||
let warn: jest.SpyInstance;
|
||||
let error: jest.SpyInstance;
|
||||
|
||||
const scanner: FileScanner = async (source) => {
|
||||
const parts: Buffer[] = [];
|
||||
for await (const part of source) parts.push(part as Buffer);
|
||||
const id = Buffer.concat(parts).toString();
|
||||
scanned.push(id);
|
||||
const verdict = verdicts.get(id) ?? { kind: 'clean' };
|
||||
if (verdict instanceof Error) throw verdict;
|
||||
return verdict;
|
||||
};
|
||||
|
||||
const build = (withScanner: FileScanner | null = scanner) => {
|
||||
service = new FileScanService(
|
||||
repository as unknown as FileStorageRepository,
|
||||
blobs as unknown as FileBlobStore,
|
||||
settings,
|
||||
withScanner,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new FakeRepository();
|
||||
verdicts = new Map();
|
||||
scanned = [];
|
||||
blobs = {
|
||||
// 내용 = 파일 id (가짜 검사기가 그것으로 판정을 고른다)
|
||||
openRead: jest.fn((id: string) =>
|
||||
Promise.resolve(Readable.from([Buffer.from(id)])),
|
||||
),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
build();
|
||||
warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
error = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.onApplicationShutdown();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
service.kick();
|
||||
await service.settle();
|
||||
};
|
||||
|
||||
it('먼저 끝낸 것부터 검사한다 — 깨끗함 · 검사하지 못함(암호 압축 · 압축 안의 검사 한도 — 로그 없음)', async () => {
|
||||
repository.rows.set(
|
||||
'b',
|
||||
file('b', { completedAt: new Date(NOW.getTime() - 10_000) }),
|
||||
);
|
||||
repository.rows.set(
|
||||
'a',
|
||||
file('a', { completedAt: new Date(NOW.getTime() - 20_000) }),
|
||||
);
|
||||
repository.rows.set(
|
||||
'c',
|
||||
file('c', { completedAt: new Date(NOW.getTime() - 5_000) }),
|
||||
);
|
||||
verdicts.set('b', {
|
||||
kind: 'unscannable',
|
||||
signature: 'Heuristics.Encrypted.Zip',
|
||||
});
|
||||
verdicts.set('c', {
|
||||
kind: 'unscannable',
|
||||
signature: 'Heuristics.Limits.Exceeded.MaxScanSize',
|
||||
});
|
||||
|
||||
await run();
|
||||
expect(scanned.slice(0, 2).sort()).toEqual(['a', 'b']);
|
||||
expect(scanned).toHaveLength(3);
|
||||
expect(repository.rows.get('a')).toMatchObject({
|
||||
scanStatus: 'clean',
|
||||
scanNextAt: null,
|
||||
});
|
||||
expect(repository.rows.get('b')).toMatchObject({
|
||||
scanStatus: 'unscannable',
|
||||
scanSignature: 'Heuristics.Encrypted.Zip',
|
||||
});
|
||||
expect(repository.rows.get('c')?.scanStatus).toBe('unscannable');
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('흐름 크기 한도(clamd 의 StreamMaxLength — 설정 오류)는 판정이 아니다 — 검사 중 그대로 두고 처음부터 error, 같은 간격으로 다시', async () => {
|
||||
repository.rows.set('big', file('big', { size: 1_500_000_000 }));
|
||||
verdicts.set(
|
||||
'big',
|
||||
new ClamavUnavailableError(
|
||||
'clamd 의 흐름 크기 한도(StreamMaxLength)에 걸렸습니다',
|
||||
'misconfigured',
|
||||
),
|
||||
);
|
||||
|
||||
await run();
|
||||
const row = repository.rows.get('big');
|
||||
expect(row).toMatchObject({ scanStatus: 'pending', scanAttempts: 1 });
|
||||
expect((row?.scanNextAt?.getTime() ?? 0) - Date.now()).toBeGreaterThan(
|
||||
FILE_SCAN_RETRY_BASE_MS - 5_000,
|
||||
);
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('StreamMaxLength'),
|
||||
);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('판정 글 전에 끊김(RST · 빈 응답 · keepalive 의 ETIMEDOUT)도 다시 한다 — 처음에는 warn', async () => {
|
||||
repository.rows.set('cut', file('cut'));
|
||||
repository.rows.set(
|
||||
'gone',
|
||||
file('gone', { completedAt: new Date(NOW.getTime() - 10_000) }),
|
||||
);
|
||||
verdicts.set(
|
||||
'cut',
|
||||
new ClamavUnavailableError(
|
||||
'clamd 가 판정 글을 보내기 전에 연결을 닫았습니다 — 다 보낸 뒤(보낸 3바이트), 받은 0바이트 (read ECONNRESET)',
|
||||
),
|
||||
);
|
||||
// keepalive 가 사라진 clamd 를 끊은 것 — clamav-client 가 같은 까닭(unavailable)과 같은 글로 낸다
|
||||
verdicts.set(
|
||||
'gone',
|
||||
new ClamavUnavailableError(
|
||||
'clamd 가 판정 글을 보내기 전에 연결을 닫았습니다 — 다 보낸 뒤(보낸 4바이트), 받은 0바이트 (read ETIMEDOUT)',
|
||||
),
|
||||
);
|
||||
await run();
|
||||
for (const id of ['cut', 'gone']) {
|
||||
expect({ id, ...repository.rows.get(id) }).toMatchObject({
|
||||
id,
|
||||
scanStatus: 'pending',
|
||||
scanAttempts: 1,
|
||||
});
|
||||
}
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('ETIMEDOUT'));
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('크기 한도를 RST 로 끊어 오류 글이 오지 않으면(흘려보내는 중 끊김) 설정 오류로 단정하지 않는다 — 다른 끊김처럼 미루고, 이어지면 error (단계 · 보낸 바이트 수가 남는다)', async () => {
|
||||
repository.rows.set('huge', file('huge', { size: 2_000_000_000 }));
|
||||
verdicts.set(
|
||||
'huge',
|
||||
new ClamavUnavailableError(
|
||||
'clamd 가 판정 글을 보내기 전에 연결을 닫았습니다 — 흘려보내는 중(보낸 1,073,741,824바이트), 받은 0바이트 (write EPIPE)',
|
||||
),
|
||||
);
|
||||
|
||||
await run();
|
||||
const row = repository.rows.get('huge');
|
||||
expect(row).toMatchObject({ scanStatus: 'pending', scanAttempts: 1 });
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('흘려보내는 중(보낸 1,073,741,824바이트)'),
|
||||
);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
|
||||
if (row) {
|
||||
row.scanAttempts = FILE_SCAN_ERROR_AFTER_ATTEMPTS - 1;
|
||||
row.scanNextAt = new Date(NOW.getTime() - 1);
|
||||
}
|
||||
await run();
|
||||
expect(repository.rows.get('huge')?.scanStatus).toBe('pending');
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('흘려보내는 중(보낸 1,073,741,824바이트)'),
|
||||
);
|
||||
});
|
||||
|
||||
it('위험 — 바이트를 곧 지우고 기록은 남긴다(판정 뒤 30일), warn 하고 주인에게 알린다', async () => {
|
||||
repository.rows.set('bad', file('bad'));
|
||||
verdicts.set('bad', {
|
||||
kind: 'infected',
|
||||
signature: 'Win.Test.EICAR_HDB-1',
|
||||
});
|
||||
const events: FileScanVerdictEvent[] = [];
|
||||
service.onVerdict((event) => events.push(event));
|
||||
|
||||
await run();
|
||||
const row = repository.rows.get('bad');
|
||||
expect(row).toMatchObject({
|
||||
state: 'ready',
|
||||
scanStatus: 'infected',
|
||||
scanSignature: 'Win.Test.EICAR_HDB-1',
|
||||
});
|
||||
expect(row?.bytesDeletedAt).not.toBeNull();
|
||||
expect(blobs.remove).toHaveBeenCalledWith('bad');
|
||||
expect((row?.expiresAt.getTime() ?? 0) - Date.now()).toBeGreaterThan(
|
||||
29 * DAY,
|
||||
);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
ownerEmail: 'ara.kim@arasystem.test',
|
||||
fileId: 'bad',
|
||||
name: 'bad.zip',
|
||||
scanStatus: 'infected',
|
||||
},
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Win.Test.EICAR_HDB-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('검사기에 닿지 못하면 검사 중 그대로 두고 1분부터 늘려 다시 — 이어서 실패하면 error (닫힌 쪽)', async () => {
|
||||
repository.rows.set('x', file('x'));
|
||||
verdicts.set('x', new ClamavUnavailableError('connect ECONNREFUSED'));
|
||||
|
||||
await run();
|
||||
const row = repository.rows.get('x');
|
||||
expect(row).toMatchObject({ scanStatus: 'pending', scanAttempts: 1 });
|
||||
expect((row?.scanNextAt?.getTime() ?? 0) - Date.now()).toBeGreaterThan(
|
||||
FILE_SCAN_RETRY_BASE_MS - 5_000,
|
||||
);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
|
||||
// 이어서 실패한 끝 — error 로 남긴다
|
||||
if (row) {
|
||||
row.scanAttempts = FILE_SCAN_ERROR_AFTER_ATTEMPTS - 1;
|
||||
row.scanNextAt = new Date(NOW.getTime() - 1);
|
||||
}
|
||||
await run();
|
||||
expect(repository.rows.get('x')?.scanAttempts).toBe(
|
||||
FILE_SCAN_ERROR_AFTER_ATTEMPTS,
|
||||
);
|
||||
expect(error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('바이트가 저장소에 없으면 검사하지 않고 사라진 것으로 적는다', async () => {
|
||||
repository.rows.set('gone', file('gone'));
|
||||
blobs.openRead.mockRejectedValue(
|
||||
Object.assign(new Error('없음'), { code: 'ENOENT' }),
|
||||
);
|
||||
await run();
|
||||
expect(repository.rows.get('gone')?.bytesDeletedAt).not.toBeNull();
|
||||
expect(scanned).toEqual([]);
|
||||
});
|
||||
|
||||
it('그사이 지운 파일에는 판정을 적지 않는다', async () => {
|
||||
repository.rows.set('d', file('d'));
|
||||
blobs.openRead.mockImplementation((id: string) => {
|
||||
const row = repository.rows.get(id);
|
||||
if (row) row.state = 'removed';
|
||||
return Promise.resolve(Readable.from([Buffer.from(id)]));
|
||||
});
|
||||
verdicts.set('d', { kind: 'infected', signature: 'X' });
|
||||
const events: FileScanVerdictEvent[] = [];
|
||||
service.onVerdict((event) => events.push(event));
|
||||
await run();
|
||||
expect(repository.rows.get('d')?.scanStatus).toBe('pending');
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('검사기를 쓰지 않으면(CLAMAV_HOST 비움) 아무것도 하지 않는다', async () => {
|
||||
build(null);
|
||||
repository.rows.set('a', file('a'));
|
||||
service.onModuleInit();
|
||||
await run();
|
||||
expect(scanned).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnApplicationShutdown,
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { ClamavUnavailableError, type ClamavVerdict } from './clamav-client';
|
||||
import {
|
||||
FILE_BLOB_STORE,
|
||||
isMissingFile,
|
||||
type FileBlobStore,
|
||||
} from './file-blob-store';
|
||||
import { fileExpiresAt } from './file-retention';
|
||||
import {
|
||||
FILE_SCAN_CONCURRENCY,
|
||||
FILE_SCAN_ERROR_AFTER_ATTEMPTS,
|
||||
FILE_SCAN_POLL_MS,
|
||||
FILE_SCAN_RETRY_BASE_MS,
|
||||
FILE_SCAN_RETRY_MAX_MS,
|
||||
} from './file-storage.constants';
|
||||
import { FileStorageRepository } from './file-storage.repository';
|
||||
import {
|
||||
FILE_STORAGE_SETTINGS,
|
||||
type FileStorageSettings,
|
||||
} from './file-storage.settings';
|
||||
import type { FileScanVerdictEvent } from './file-storage.types';
|
||||
import type { StoredFileEntity } from './stored-file.entity';
|
||||
|
||||
/** 내용을 검사기에 보내 판정을 받는 함수 — 검사기를 쓰지 않으면(CLAMAV_HOST 비움) null. 테스트는 대역을 넣는다 */
|
||||
export const FILE_SCANNER = Symbol('FILE_SCANNER');
|
||||
export type FileScanner = (
|
||||
source: Readable,
|
||||
signal: AbortSignal,
|
||||
) => Promise<ClamavVerdict>;
|
||||
|
||||
/** 위험 판정 소식을 받는 쪽 (메일 모듈의 실시간 알림) — 던지지 않아야 한다 */
|
||||
export type FileScanListener = (event: FileScanVerdictEvent) => void;
|
||||
|
||||
interface ActiveScan {
|
||||
readonly controller: AbortController;
|
||||
readonly done: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 악성 검사 차례 (계약 4절 "악성 검사") — 다 올린 파일을 서버 전체에서 한 번에 FILE_SCAN_CONCURRENCY 개, 먼저 끝낸 것부터 검사한다.
|
||||
* 차례는 DB 다(`scan_status = 'pending'` · `scan_next_at`) — 서버를 다시 띄워도 이어진다.
|
||||
*
|
||||
* - 깨끗함 → clean, 검사하지 못함(암호 압축 · 압축 안의 검사 한도) → unscannable(받을 수 있음 — 글자만)
|
||||
* - 위험 → infected: 바이트를 곧 지우고(보관량에서 바로 빠진다) 기록(이름 · 판정 이름)은 남긴다, warn(판정 이름 · 파일 id), 주인에게 알린다(실시간 알림)
|
||||
* - 검사기에 닿지 못함 · 시간 초과 · 판정 전에 끊김 · 그 밖의 오류 → pending 그대로 두고 1분부터 두 배씩 1시간까지 미뤄 다시 한다.
|
||||
* FILE_SCAN_ERROR_AFTER_ATTEMPTS 번째부터 error(운영 알림). 흐름 크기 한도(clamd 의 StreamMaxLength — 설정 오류)는 처음부터 error 다.
|
||||
* **판정 없이 받을 수 있게 되는 일은 없다**(닫힌 쪽)
|
||||
*
|
||||
* ⚠️ 한 인스턴스 전제 — 인스턴스를 늘리면 두 인스턴스가 같은 파일을 함께 검사할 수 있다(결과는 같고 낭비) → 그때 행 잠금으로 (nestjs 스킬 "확장 때 옮길 것")
|
||||
*/
|
||||
@Injectable()
|
||||
export class FileScanService implements OnModuleInit, OnApplicationShutdown {
|
||||
private readonly logger = new Logger(FileScanService.name);
|
||||
private readonly active = new Map<string, ActiveScan>();
|
||||
private readonly listeners = new Set<FileScanListener>();
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private filling = false;
|
||||
private kickAgain = false;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private readonly repository: FileStorageRepository,
|
||||
@Inject(FILE_BLOB_STORE) private readonly blobs: FileBlobStore,
|
||||
@Inject(FILE_STORAGE_SETTINGS)
|
||||
private readonly settings: FileStorageSettings,
|
||||
@Inject(FILE_SCANNER) private readonly scanner: FileScanner | null,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.scanner === null) return;
|
||||
this.timer = setInterval(() => this.kick(), FILE_SCAN_POLL_MS);
|
||||
this.timer.unref();
|
||||
this.kick();
|
||||
}
|
||||
|
||||
/** 서버 종료 — 진행 중인 검사를 멈춘다(그 파일은 검사 중 그대로 — 다음 기동에 다시 한다) */
|
||||
onApplicationShutdown(): void {
|
||||
this.stopped = true;
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
for (const scan of this.active.values()) scan.controller.abort();
|
||||
}
|
||||
|
||||
/** 위험 판정 소식을 받는다 — 돌려준 함수로 그만 받는다 */
|
||||
onVerdict(listener: FileScanListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** 검사 차례를 깨운다 — 다 올린 직후 · 주기마다 · 검사 하나가 끝날 때 */
|
||||
kick(): void {
|
||||
if (this.scanner === null || this.stopped) return;
|
||||
if (this.filling) {
|
||||
this.kickAgain = true;
|
||||
return;
|
||||
}
|
||||
this.filling = true;
|
||||
void this.fillSlots()
|
||||
.catch((error: unknown) => {
|
||||
this.logger.error(
|
||||
`[SYS_001] 악성 검사 차례를 읽지 못했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.filling = false;
|
||||
if (this.kickAgain) {
|
||||
this.kickAgain = false;
|
||||
this.kick();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 테스트 · 종료용 — 지금 진행 중인 검사가 모두 끝날 때까지 (새로 깨운 것까지) */
|
||||
async settle(): Promise<void> {
|
||||
for (;;) {
|
||||
const pending = [...this.active.values()].map((scan) => scan.done);
|
||||
if (pending.length === 0 && !this.filling) return;
|
||||
await Promise.all(pending);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
private async fillSlots(): Promise<void> {
|
||||
while (
|
||||
this.scanner !== null &&
|
||||
!this.stopped &&
|
||||
this.active.size < FILE_SCAN_CONCURRENCY
|
||||
) {
|
||||
const [next] = await this.repository.findScanCandidates(
|
||||
new Date(),
|
||||
[...this.active.keys()],
|
||||
1,
|
||||
);
|
||||
if (!next) return;
|
||||
const controller = new AbortController();
|
||||
const done = this.scanOne(next, this.scanner, controller.signal)
|
||||
.catch((error: unknown) => {
|
||||
this.logger.error(
|
||||
`[SYS_001] 악성 검사 결과를 적지 못했습니다: id=${next.id}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.active.delete(next.id);
|
||||
this.kick();
|
||||
});
|
||||
this.active.set(next.id, { controller, done });
|
||||
}
|
||||
}
|
||||
|
||||
private async scanOne(
|
||||
file: StoredFileEntity,
|
||||
scanner: FileScanner,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
let source: Readable;
|
||||
try {
|
||||
source = await this.blobs.openRead(file.id);
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) {
|
||||
// 바이트가 없다(저장소 복원 등) — 받을 수도 없으니 검사하지 않는다 (내려받기는 "찾을 수 없음")
|
||||
await this.repository.update(file.id, {
|
||||
bytesDeletedAt: new Date(),
|
||||
scanNextAt: null,
|
||||
});
|
||||
this.logger.warn(
|
||||
`[SYS_001] 검사할 파일의 바이트가 저장소에 없습니다: id=${file.id}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.defer(file, error);
|
||||
return;
|
||||
}
|
||||
|
||||
let verdict: ClamavVerdict;
|
||||
try {
|
||||
verdict = await scanner(source, signal);
|
||||
} catch (error) {
|
||||
if (!source.destroyed) source.destroy();
|
||||
// 서버 종료로 멈춘 검사는 검사 중 그대로 둔다 — 다음 기동에 다시 한다
|
||||
if (signal.aborted) return;
|
||||
await this.defer(file, error);
|
||||
return;
|
||||
}
|
||||
await this.applyVerdict(file, verdict);
|
||||
}
|
||||
|
||||
/** 판정을 적는다 — 그사이 지운 파일이면 적지 않는다(다 올림 상태일 때만) */
|
||||
private async applyVerdict(
|
||||
file: StoredFileEntity,
|
||||
verdict: ClamavVerdict,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
if (verdict.kind === 'clean') {
|
||||
await this.repository.transition(file.id, ['ready'], {
|
||||
scanStatus: 'clean',
|
||||
scanSignature: null,
|
||||
scannedAt: now,
|
||||
scanNextAt: null,
|
||||
scanAttempts: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (verdict.kind === 'unscannable') {
|
||||
await this.repository.transition(file.id, ['ready'], {
|
||||
scanStatus: 'unscannable',
|
||||
scanSignature: verdict.signature,
|
||||
scannedAt: now,
|
||||
scanNextAt: null,
|
||||
scanAttempts: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 위험 — 판정 뒤 기록만 남고(30일 또는 링크가 끝날 때까지) 보관량에서 바로 빠진다
|
||||
const current = (await this.repository.findById(file.id)) ?? file;
|
||||
const infected = {
|
||||
...current,
|
||||
scanStatus: 'infected' as const,
|
||||
scannedAt: now,
|
||||
};
|
||||
const changed = await this.repository.transition(file.id, ['ready'], {
|
||||
scanStatus: 'infected',
|
||||
scanSignature: verdict.signature,
|
||||
scannedAt: now,
|
||||
scanNextAt: null,
|
||||
scanAttempts: 0,
|
||||
expiresAt: fileExpiresAt(infected, this.settings.retentionDays, now),
|
||||
});
|
||||
this.logger.warn(
|
||||
`[FILE_007] 대용량 첨부에서 위험한 내용을 찾아 막았습니다: id=${file.id}, 판정=${verdict.signature}, user=${maskEmail(file.ownerEmail)}`,
|
||||
);
|
||||
try {
|
||||
await this.blobs.remove(file.id);
|
||||
await this.repository.update(file.id, { bytesDeletedAt: new Date() });
|
||||
} catch (error) {
|
||||
// 정리 작업이 다시 지운다 (위험 판정 파일의 바이트)
|
||||
this.logger.error(
|
||||
`[SYS_001] 위험 판정 파일의 바이트를 지우지 못했습니다 — 정리 작업이 다시 지웁니다: id=${file.id}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
if (!changed) return;
|
||||
const event: FileScanVerdictEvent = {
|
||||
ownerEmail: file.ownerEmail,
|
||||
fileId: file.id,
|
||||
name: file.name,
|
||||
scanStatus: 'infected',
|
||||
};
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 위험 판정 소식을 전하지 못했습니다: id=${file.id}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 판정을 받지 못했다 — 검사 중 그대로 두고 간격을 늘려 다시 한다.
|
||||
* 흐름 크기 한도(misconfigured — clamd 의 StreamMaxLength 가 한 파일 한도보다 작다)는 다시 해도 같으니 처음부터 error 로 남긴다(운영이 설정을 고친다)
|
||||
*/
|
||||
private async defer(file: StoredFileEntity, error: unknown): Promise<void> {
|
||||
const attempts = file.scanAttempts + 1;
|
||||
const delay = Math.min(
|
||||
FILE_SCAN_RETRY_BASE_MS * 2 ** (attempts - 1),
|
||||
FILE_SCAN_RETRY_MAX_MS,
|
||||
);
|
||||
const next = new Date(Date.now() + delay);
|
||||
await this.repository.update(file.id, {
|
||||
scanAttempts: attempts,
|
||||
scanNextAt: next,
|
||||
});
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
error instanceof ClamavUnavailableError &&
|
||||
error.failure === 'misconfigured'
|
||||
) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 악성 검사기의 흐름 크기 한도(StreamMaxLength)가 이 파일보다 작아 검사하지 못했습니다 — 받기는 검사 중으로 막아 두고 다시 합니다. clamd 의 StreamMaxLength · MaxFileSize · MaxScanSize 를 한 파일 한도 이상으로 두세요: id=${file.id}, 크기=${file.size}, 이어서 ${attempts}번째, 다음=${next.toISOString()}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message = `[SYS_001] 악성 검사기에서 판정을 받지 못해 미룹니다 — 받기는 검사 중으로 막아 둡니다: id=${file.id}, 이어서 ${attempts}번째, 다음=${next.toISOString()}, reason=${reason}`;
|
||||
if (attempts >= FILE_SCAN_ERROR_AFTER_ATTEMPTS) this.logger.error(message);
|
||||
else this.logger.warn(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import type { FileBlobStore, StoredBlob } from './file-blob-store';
|
||||
import { FileStorageCleanup } from './file-storage.cleanup';
|
||||
import type { FileStorageRepository } from './file-storage.repository';
|
||||
import type { FileStorageSettings } from './file-storage.settings';
|
||||
import type { StoredFileEntity, StoredFileState } from './stored-file.entity';
|
||||
|
||||
const NOW = new Date('2026-11-10T00:00:00.000Z');
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
const row = (id: string, overrides: Partial<StoredFileEntity> = {}) =>
|
||||
({
|
||||
id,
|
||||
ownerEmail: 'ara.kim@arasystem.test',
|
||||
state: 'ready',
|
||||
size: 10,
|
||||
scanStatus: 'clean',
|
||||
createdAt: new Date(NOW.getTime() - 3 * DAY),
|
||||
lastChunkAt: new Date(NOW.getTime() - 3 * DAY),
|
||||
completedAt: new Date(NOW.getTime() - 3 * DAY),
|
||||
completionStartedAt: null,
|
||||
draftAttachedAt: null,
|
||||
linkedAt: null,
|
||||
linkedUntil: null,
|
||||
scannedAt: null,
|
||||
expiresAt: new Date(NOW.getTime() + DAY),
|
||||
removedAt: null,
|
||||
bytesDeletedAt: null,
|
||||
...overrides,
|
||||
}) as StoredFileEntity;
|
||||
|
||||
class FakeRepository {
|
||||
readonly rows = new Map<string, StoredFileEntity>();
|
||||
|
||||
findExpiredAlive(now: Date) {
|
||||
return Promise.resolve(
|
||||
[...this.rows.values()].filter(
|
||||
(file) => file.state !== 'removed' && file.expiresAt <= now,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
expireIfDue(id: string, now: Date) {
|
||||
const file = this.rows.get(id);
|
||||
if (!file || file.state === 'removed' || file.expiresAt > now) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
Object.assign(file, {
|
||||
state: 'removed',
|
||||
removedReason: 'expired',
|
||||
removedAt: now,
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
findBytesToDelete() {
|
||||
return Promise.resolve(
|
||||
[...this.rows.values()].filter(
|
||||
(file) =>
|
||||
file.bytesDeletedAt === null &&
|
||||
(file.state === 'removed' || file.scanStatus === 'infected'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
findStaleCompletions(before: Date) {
|
||||
return Promise.resolve(
|
||||
[...this.rows.values()].filter(
|
||||
(file) =>
|
||||
file.state === 'completing' &&
|
||||
(file.completionStartedAt ?? file.lastChunkAt ?? file.createdAt) <
|
||||
before,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
readonly chunkRowsDeleted: string[] = [];
|
||||
|
||||
deleteChunks(fileId: string) {
|
||||
this.chunkRowsDeleted.push(fileId);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
deleteRemovedBefore(before: Date) {
|
||||
let count = 0;
|
||||
for (const [id, file] of this.rows) {
|
||||
if (
|
||||
file.state === 'removed' &&
|
||||
file.removedAt !== null &&
|
||||
file.removedAt < before &&
|
||||
file.bytesDeletedAt !== null
|
||||
) {
|
||||
this.rows.delete(id);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return Promise.resolve(count);
|
||||
}
|
||||
|
||||
idsWithBytes() {
|
||||
return Promise.resolve(
|
||||
new Set(
|
||||
[...this.rows.values()]
|
||||
.filter((file) => file.bytesDeletedAt === null)
|
||||
.map((file) => file.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
transition(
|
||||
id: string,
|
||||
from: readonly StoredFileState[],
|
||||
changes: Partial<StoredFileEntity>,
|
||||
) {
|
||||
const file = this.rows.get(id);
|
||||
if (!file || !from.includes(file.state)) return Promise.resolve(false);
|
||||
Object.assign(file, changes);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
update(id: string, changes: Partial<StoredFileEntity>) {
|
||||
const file = this.rows.get(id);
|
||||
if (file) Object.assign(file, changes);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileStorageCleanup — 대용량 첨부 정리 (11단계)', () => {
|
||||
let repository: FakeRepository;
|
||||
let blobs: {
|
||||
remove: jest.Mock;
|
||||
list: jest.Mock<Promise<StoredBlob[]>>;
|
||||
isStored: jest.Mock;
|
||||
};
|
||||
let cleanup: FileStorageCleanup;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new FakeRepository();
|
||||
blobs = {
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
list: jest.fn<Promise<StoredBlob[]>, []>().mockResolvedValue([]),
|
||||
isStored: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
cleanup = new FileStorageCleanup(
|
||||
repository as unknown as FileStorageRepository,
|
||||
blobs as unknown as FileBlobStore,
|
||||
{ retentionDays: 30, clamav: null } as FileStorageSettings,
|
||||
);
|
||||
jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('기간이 지난 파일을 지움(기간 지남)으로 바꾸고 바이트를 지운다 — 기간 안의 파일은 그대로', async () => {
|
||||
repository.rows.set(
|
||||
'old',
|
||||
row('old', { expiresAt: new Date(NOW.getTime() - 1) }),
|
||||
);
|
||||
repository.rows.set(
|
||||
'upload',
|
||||
row('upload', {
|
||||
state: 'uploading',
|
||||
completedAt: null,
|
||||
expiresAt: new Date(NOW.getTime() - HOUR),
|
||||
}),
|
||||
);
|
||||
repository.rows.set('fresh', row('fresh'));
|
||||
|
||||
const result = await cleanup.run(NOW);
|
||||
expect(result).toMatchObject({ expired: 2 });
|
||||
expect(repository.rows.get('old')).toMatchObject({
|
||||
state: 'removed',
|
||||
removedReason: 'expired',
|
||||
});
|
||||
expect(repository.rows.get('old')?.bytesDeletedAt).not.toBeNull();
|
||||
expect(repository.rows.get('fresh')?.state).toBe('ready');
|
||||
expect(blobs.remove.mock.calls.map(([id]: [string]) => id).sort()).toEqual([
|
||||
'old',
|
||||
'upload',
|
||||
]);
|
||||
});
|
||||
|
||||
it('지웠거나 위험 판정인데 바이트가 남은 파일의 바이트를 지운다 · 30일 지난 기록을 지운다', async () => {
|
||||
repository.rows.set(
|
||||
'deleted',
|
||||
row('deleted', {
|
||||
state: 'removed',
|
||||
removedAt: new Date(NOW.getTime() - HOUR),
|
||||
expiresAt: new Date(NOW.getTime() - HOUR),
|
||||
}),
|
||||
);
|
||||
repository.rows.set(
|
||||
'infected',
|
||||
row('infected', { scanStatus: 'infected' }),
|
||||
);
|
||||
repository.rows.set(
|
||||
'ancient',
|
||||
row('ancient', {
|
||||
state: 'removed',
|
||||
removedAt: new Date(NOW.getTime() - 31 * DAY),
|
||||
expiresAt: new Date(NOW.getTime() - 31 * DAY),
|
||||
bytesDeletedAt: new Date(NOW.getTime() - 31 * DAY),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await cleanup.run(NOW);
|
||||
expect(result).toMatchObject({ bytesDeleted: 2, purged: 1 });
|
||||
expect(repository.rows.has('ancient')).toBe(false);
|
||||
expect(repository.rows.get('infected')?.state).toBe('ready');
|
||||
expect(repository.rows.get('infected')?.bytesDeletedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('끝내기를 시작한 지 오래된 파일 — 다 옮겼으면 다 올림(조각 기록도 지운다), 아니면 올리는 중으로 되살린다', async () => {
|
||||
const stale = new Date(NOW.getTime() - HOUR);
|
||||
repository.rows.set(
|
||||
'moved',
|
||||
row('moved', { state: 'completing', completionStartedAt: stale }),
|
||||
);
|
||||
repository.rows.set(
|
||||
'notyet',
|
||||
row('notyet', { state: 'completing', completionStartedAt: stale }),
|
||||
);
|
||||
blobs.isStored.mockImplementation((id: string) =>
|
||||
Promise.resolve(id === 'moved'),
|
||||
);
|
||||
|
||||
const result = await cleanup.run(NOW);
|
||||
expect(result.recovered).toBe(2);
|
||||
expect(repository.rows.get('moved')).toMatchObject({
|
||||
state: 'ready',
|
||||
scanStatus: 'unscannable',
|
||||
completionStartedAt: null,
|
||||
});
|
||||
expect(repository.rows.get('notyet')).toMatchObject({
|
||||
state: 'uploading',
|
||||
completionStartedAt: null,
|
||||
});
|
||||
expect(repository.chunkRowsDeleted).toEqual(['moved']);
|
||||
});
|
||||
|
||||
it('방금 끝내기를 시작한 파일은 마지막 조각이 오래됐어도 건드리지 않는다 — 시작한 때로 본다 (2026-10-30 리뷰 L4)', async () => {
|
||||
repository.rows.set(
|
||||
'busy',
|
||||
row('busy', {
|
||||
state: 'completing',
|
||||
lastChunkAt: new Date(NOW.getTime() - 20 * HOUR),
|
||||
completionStartedAt: new Date(NOW.getTime() - 60_000),
|
||||
}),
|
||||
);
|
||||
// 이 칸을 더하기 전에 시작한 끝내기(시작한 때 없음)는 마지막 조각의 때로 본다
|
||||
repository.rows.set(
|
||||
'legacy',
|
||||
row('legacy', {
|
||||
state: 'completing',
|
||||
lastChunkAt: new Date(NOW.getTime() - HOUR),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await cleanup.run(NOW);
|
||||
expect(result.recovered).toBe(1);
|
||||
expect(repository.rows.get('busy')?.state).toBe('completing');
|
||||
expect(repository.rows.get('legacy')?.state).toBe('uploading');
|
||||
expect(blobs.isStored).not.toHaveBeenCalledWith('busy');
|
||||
});
|
||||
|
||||
it('표에 없는 저장소 파일은 하루 지난 것만 지운다', async () => {
|
||||
repository.rows.set('known', row('known'));
|
||||
blobs.list.mockResolvedValue([
|
||||
{
|
||||
key: 'known',
|
||||
stage: 'stored',
|
||||
modifiedAt: new Date(NOW.getTime() - 3 * DAY),
|
||||
},
|
||||
{
|
||||
key: 'orphan-old',
|
||||
stage: 'incoming',
|
||||
modifiedAt: new Date(NOW.getTime() - 2 * DAY),
|
||||
},
|
||||
{
|
||||
key: 'orphan-new',
|
||||
stage: 'incoming',
|
||||
modifiedAt: new Date(NOW.getTime() - HOUR),
|
||||
},
|
||||
]);
|
||||
const result = await cleanup.run(NOW);
|
||||
expect(result.orphans).toBe(1);
|
||||
expect(blobs.remove).toHaveBeenCalledWith('orphan-old');
|
||||
expect(blobs.remove).not.toHaveBeenCalledWith('orphan-new');
|
||||
expect(blobs.remove).not.toHaveBeenCalledWith('known');
|
||||
});
|
||||
|
||||
it('실패해도 앱은 계속 돈다 — error 로그 뒤 0', async () => {
|
||||
const error = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
blobs.list.mockRejectedValue(new Error('disk gone'));
|
||||
await expect(cleanup.run(NOW)).resolves.toEqual({
|
||||
expired: 0,
|
||||
bytesDeleted: 0,
|
||||
recovered: 0,
|
||||
purged: 0,
|
||||
orphans: 0,
|
||||
});
|
||||
expect(error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { FILE_BLOB_STORE, type FileBlobStore } from './file-blob-store';
|
||||
import { fileExpiresAt } from './file-retention';
|
||||
import {
|
||||
ORPHAN_BLOB_AGE_MS,
|
||||
REMOVED_FILE_RECORD_RETENTION_MS,
|
||||
STALE_COMPLETION_MS,
|
||||
} from './file-storage.constants';
|
||||
import { FileStorageRepository } from './file-storage.repository';
|
||||
import {
|
||||
FILE_STORAGE_SETTINGS,
|
||||
type FileStorageSettings,
|
||||
} from './file-storage.settings';
|
||||
import type { StoredFileEntity } from './stored-file.entity';
|
||||
|
||||
/** 한 번에 다루는 파일 수 — 남으면 다음 주기에 */
|
||||
const CLEANUP_BATCH = 500;
|
||||
|
||||
/** 정리 결과 (로그 · 테스트) */
|
||||
export interface FileCleanupResult {
|
||||
/** 기간이 지나 지운 파일 */
|
||||
readonly expired: number;
|
||||
/** 바이트를 지운 파일 (지움 · 기간 지남 · 위험 판정) */
|
||||
readonly bytesDeleted: number;
|
||||
/** 끊긴 끝내기를 되살린 파일 */
|
||||
readonly recovered: number;
|
||||
/** 지운 기록 */
|
||||
readonly purged: number;
|
||||
/** 표에 없어 지운 저장소 파일 */
|
||||
readonly orphans: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대용량 첨부 정리 — 매시간 (계약 4절 "기간 · 보관 · 정리"). 기록을 먼저 바꾸고 실제로 바꾼 것만 지운다 (서명 그림 정리와 같은 결).
|
||||
* 1) 기간이 지난 파일(끊긴 올리기 24시간 · 보내지 않은 파일 · 끝난 링크의 파일)을 지움(expired)으로 → 바이트를 지운다
|
||||
* 2) 지웠거나 위험 판정인데 바이트가 남은 파일의 바이트를 지운다 (요청 중 지우기에 실패한 것)
|
||||
* 3) 끝내는 중에 멈춘 파일(서버가 그 사이 꺼짐)을 되살린다 — 다 옮겼으면 다 올림, 아니면 올리는 중
|
||||
* 4) 지운 지 30일이 지난 기록을 지운다 — 그 뒤 임시 저장본은 그 id 를 버린다
|
||||
* 5) 표에 없는 저장소 파일 가운데 하루 지난 것을 지운다
|
||||
*
|
||||
* 보관량은 이 작업을 기다리지 않는다 — 요청 때 상태(expires_at)로 계산한다.
|
||||
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 일을 한다 (결과는 같지만 낭비).
|
||||
*/
|
||||
@Injectable()
|
||||
export class FileStorageCleanup {
|
||||
private readonly logger = new Logger(FileStorageCleanup.name);
|
||||
|
||||
constructor(
|
||||
private readonly repository: FileStorageRepository,
|
||||
@Inject(FILE_BLOB_STORE) private readonly blobs: FileBlobStore,
|
||||
@Inject(FILE_STORAGE_SETTINGS)
|
||||
private readonly settings: FileStorageSettings,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_HOUR, { name: 'file-storage-cleanup' })
|
||||
async run(now: Date = new Date()): Promise<FileCleanupResult> {
|
||||
try {
|
||||
const expired = await this.expire(now);
|
||||
const bytesDeleted = await this.deleteBytes();
|
||||
const recovered = await this.recoverStaleCompletions(now);
|
||||
const purged = await this.repository.deleteRemovedBefore(
|
||||
new Date(now.getTime() - REMOVED_FILE_RECORD_RETENTION_MS),
|
||||
);
|
||||
const orphans = await this.removeOrphans(now);
|
||||
const result = { expired, bytesDeleted, recovered, purged, orphans };
|
||||
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
|
||||
if (Object.values(result).some((count) => count > 0)) {
|
||||
this.logger.debug(
|
||||
`대용량 첨부 정리: 기간 지남 ${expired} · 바이트 ${bytesDeleted} · 끝내기 되살림 ${recovered} · 기록 ${purged} · 표에 없는 파일 ${orphans}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다
|
||||
this.logger.error(
|
||||
`[SYS_001] 대용량 첨부 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return {
|
||||
expired: 0,
|
||||
bytesDeleted: 0,
|
||||
recovered: 0,
|
||||
purged: 0,
|
||||
orphans: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 기간이 지난 파일을 지움으로 — 그사이 기간이 늘었으면(임시 저장 · 링크) 바꾸지 않는다 */
|
||||
private async expire(now: Date): Promise<number> {
|
||||
let count = 0;
|
||||
for (const file of await this.repository.findExpiredAlive(
|
||||
now,
|
||||
CLEANUP_BATCH,
|
||||
)) {
|
||||
if (!(await this.repository.expireIfDue(file.id, now))) continue;
|
||||
count += 1;
|
||||
await this.removeBytes(file);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async deleteBytes(): Promise<number> {
|
||||
let count = 0;
|
||||
for (const file of await this.repository.findBytesToDelete(CLEANUP_BATCH)) {
|
||||
if (await this.removeBytes(file)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async removeBytes(file: StoredFileEntity): Promise<boolean> {
|
||||
try {
|
||||
await this.blobs.remove(file.id);
|
||||
await this.repository.update(file.id, { bytesDeletedAt: new Date() });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001] 파일 바이트를 지우지 못했습니다 — 다음 주기에 다시 지웁니다: id=${file.id}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 끝내기를 시작한 지 오래된(STALE_COMPLETION_MS) 끝내기를 되살린다 — 다 옮겼으면 다 올림, 아니면 올리는 중. 바꾼 것만 센다 */
|
||||
private async recoverStaleCompletions(now: Date): Promise<number> {
|
||||
let count = 0;
|
||||
for (const file of await this.repository.findStaleCompletions(
|
||||
new Date(now.getTime() - STALE_COMPLETION_MS),
|
||||
)) {
|
||||
if (await this.blobs.isStored(file.id)) {
|
||||
const scanStatus = this.settings.clamav ? 'pending' : 'unscannable';
|
||||
const ready: StoredFileEntity = {
|
||||
...file,
|
||||
state: 'ready',
|
||||
completedAt: now,
|
||||
scanStatus,
|
||||
scanNextAt: scanStatus === 'pending' ? now : null,
|
||||
};
|
||||
if (
|
||||
await this.repository.transition(file.id, ['completing'], {
|
||||
state: 'ready',
|
||||
completedAt: now,
|
||||
completionStartedAt: null,
|
||||
scanStatus,
|
||||
scanNextAt: ready.scanNextAt,
|
||||
expiresAt: fileExpiresAt(ready, this.settings.retentionDays, now),
|
||||
})
|
||||
) {
|
||||
count += 1;
|
||||
await this.repository.deleteChunks(file.id);
|
||||
}
|
||||
} else if (
|
||||
await this.repository.transition(file.id, ['completing'], {
|
||||
state: 'uploading',
|
||||
completionStartedAt: null,
|
||||
})
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private async removeOrphans(now: Date): Promise<number> {
|
||||
const known = await this.repository.idsWithBytes();
|
||||
const before = now.getTime() - ORPHAN_BLOB_AGE_MS;
|
||||
let count = 0;
|
||||
for (const blob of await this.blobs.list()) {
|
||||
if (known.has(blob.key) || blob.modifiedAt.getTime() >= before) continue;
|
||||
await this.blobs.remove(blob.key);
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
CLAMAV_IDLE_TIMEOUT_MS,
|
||||
CLAMAV_KEEPALIVE_INITIAL_DELAY_MS,
|
||||
CLAMAV_VERDICT_TIMEOUT_BASE_MS,
|
||||
CLAMAV_VERDICT_TIMEOUT_PER_MB_MS,
|
||||
clamavVerdictTimeoutMs,
|
||||
} from './file-storage.constants';
|
||||
import {
|
||||
LARGE_FILE_MAX_SIZE_MB_LIMIT,
|
||||
MEGABYTE,
|
||||
} from './file-storage.settings';
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
|
||||
/** 2026-10-30 실측 (ClamAV 1.4.6, 개발 PC 코어 하나) — 2,047MB 무작위 파일을 다 보낸 뒤 판정까지 296초 */
|
||||
const MEASURED_SECONDS_PER_MB = 296 / 2047;
|
||||
/** clamd 의 MaxScanSize (MB) — 7z · tar.gz 는 MaxScanTime 없이 여기까지 푼다 (운영 설정 — 계약 4절) */
|
||||
const MAX_SCAN_SIZE_MB = 4094;
|
||||
|
||||
describe('clamavVerdictTimeoutMs (다 보낸 뒤 판정을 기다리는 시간)', () => {
|
||||
it('30분 + 보낸 1MB(올림)마다 1초', () => {
|
||||
expect(CLAMAV_VERDICT_TIMEOUT_BASE_MS).toBe(30 * MINUTE);
|
||||
expect(CLAMAV_VERDICT_TIMEOUT_PER_MB_MS).toBe(SECOND);
|
||||
expect(clamavVerdictTimeoutMs(0)).toBe(30 * MINUTE);
|
||||
expect(clamavVerdictTimeoutMs(1)).toBe(30 * MINUTE + SECOND);
|
||||
expect(clamavVerdictTimeoutMs(MEGABYTE)).toBe(30 * MINUTE + SECOND);
|
||||
expect(clamavVerdictTimeoutMs(MEGABYTE + 1)).toBe(30 * MINUTE + 2 * SECOND);
|
||||
expect(clamavVerdictTimeoutMs(100 * MEGABYTE)).toBe(
|
||||
30 * MINUTE + 100 * SECOND,
|
||||
);
|
||||
// 음수는 오지 않지만 0 처럼
|
||||
expect(clamavVerdictTimeoutMs(-1)).toBe(30 * MINUTE);
|
||||
});
|
||||
|
||||
it('한 파일 한도의 상한(2,047MB)이면 3,847초(약 64분)', () => {
|
||||
expect(
|
||||
clamavVerdictTimeoutMs(LARGE_FILE_MAX_SIZE_MB_LIMIT * MEGABYTE),
|
||||
).toBe(3_847_000);
|
||||
});
|
||||
|
||||
it('파일 크기와 상관없는 몫(30분)은 작은 압축 파일이 MaxScanSize(4,094MB)까지 풀리는 것을 실측 빠르기의 3배 느린 CPU 로 검사하는 시간보다 길다', () => {
|
||||
const slowestScanMs =
|
||||
MAX_SCAN_SIZE_MB * MEASURED_SECONDS_PER_MB * 3 * SECOND;
|
||||
expect(slowestScanMs).toBeGreaterThan(25 * MINUTE);
|
||||
expect(clamavVerdictTimeoutMs(1)).toBeGreaterThan(slowestScanMs);
|
||||
// 흘려보내는 동안의 무소식 상한(10분)보다도 길다 — 판정 대기만 늘렸다
|
||||
expect(CLAMAV_IDLE_TIMEOUT_MS).toBe(10 * MINUTE);
|
||||
expect(clamavVerdictTimeoutMs(1)).toBeGreaterThan(CLAMAV_IDLE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it('keepalive 는 60초 뒤부터 묻는다 — 사라진 상대(1초 간격 10번)를 판정 대기보다 훨씬 일찍 알아차린다', () => {
|
||||
expect(CLAMAV_KEEPALIVE_INITIAL_DELAY_MS).toBe(60 * SECOND);
|
||||
const detectionMs = CLAMAV_KEEPALIVE_INITIAL_DELAY_MS + 10 * SECOND;
|
||||
expect(detectionMs * 10).toBeLessThan(CLAMAV_VERDICT_TIMEOUT_BASE_MS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 파일 저장 · 올리기 상수 (11단계 대용량 첨부 — 계약 4절 "한도" · "올리기" · "기간 · 보관 · 정리").
|
||||
* env 로 바꾸는 한도는 file-storage.settings.ts, 여기는 계약 상수다.
|
||||
*/
|
||||
|
||||
/** 경로 — `/api/file-uploads` · `/api/file-quota` (`/api/files` 는 템플릿 업로드 예제가 쓴다) */
|
||||
export const FILE_UPLOADS_ROUTE = 'file-uploads';
|
||||
export const FILE_QUOTA_ROUTE = 'file-quota';
|
||||
|
||||
/**
|
||||
* 조각 크기 — 16 MiB 고정(계약 상수). 앞단 nginx 의 이 경로 본문 한도 17m 과 짝이다 (release-ops — frontend/nginx.conf).
|
||||
* 바꾸면 계약 · nginx · 화면을 함께 바꾼다. 응답의 chunkSize 로 클라이언트가 읽는다
|
||||
*/
|
||||
export const FILE_CHUNK_SIZE = 16 * 1024 * 1024;
|
||||
|
||||
/** 쓰임 — 11단계는 메일만 (2부 파일 저장소가 'drive' 를 더한다) */
|
||||
export const FILE_PURPOSES = ['mail'] as const;
|
||||
export type FilePurpose = (typeof FILE_PURPOSES)[number];
|
||||
|
||||
/** 직원당 보내지 않은 파일 수 (올리는 중 포함, 위험 판정 파일은 세지 않는다) — 넘으면 409 FILE_005 */
|
||||
export const MAX_UNSENT_FILES_PER_USER = 50;
|
||||
|
||||
/** 이름 최대 길이 — JS 문자열 길이 (계약 4절 FileUploadCreate) */
|
||||
export const MAX_UPLOAD_NAME_LENGTH = 255;
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
/** 올리는 중 — 마지막 조각(없으면 만든 때) 뒤 이만큼 지나면 지운다 */
|
||||
export const UPLOAD_IDLE_RETENTION_MS = DAY_MS;
|
||||
|
||||
/** 다 올렸지만 어디에도(임시 저장 · 발송) 실리지 않은 파일 — 다 올린 뒤 이만큼 */
|
||||
export const UNATTACHED_FILE_RETENTION_MS = DAY_MS;
|
||||
|
||||
/** 지운 · 기간이 지난 파일의 기록(이름 · 크기)을 남기는 기간 — 임시 저장본이 "지운 파일" · "기간 지남"으로 보이는 동안 */
|
||||
export const REMOVED_FILE_RECORD_RETENTION_MS = 30 * DAY_MS;
|
||||
|
||||
/** 위험 판정 파일의 기록 — 판정 뒤 이만큼 (바이트는 판정 즉시 지운다) */
|
||||
export const INFECTED_FILE_RECORD_RETENTION_MS = 30 * DAY_MS;
|
||||
|
||||
/** 끊긴 끝내기(completing 그대로 남은 것)를 정리 작업이 되살리는 나이 */
|
||||
export const STALE_COMPLETION_MS = 10 * 60 * 1000;
|
||||
|
||||
/** 표에 없는 저장소 파일을 지우는 나이 — 진행 중인 올리기 · 끝내기와 겹치지 않게 넉넉히 */
|
||||
export const ORPHAN_BLOB_AGE_MS = DAY_MS;
|
||||
|
||||
/**
|
||||
* 조각 PUT 의 요청 수 한도 — IP 마다 1분 1,200회 (이 경로만 `@Throttle` — 두 번째 limiter 가 아니다).
|
||||
* 16MiB 조각은 한 사람이 1분에 수십 번 보내고 사무실은 IP 하나로 나간다 (계약 4절 "요청 수 · 동시 수")
|
||||
*/
|
||||
export const FILE_CHUNK_THROTTLE_TTL_MS = 60_000;
|
||||
export const FILE_CHUNK_THROTTLE_LIMIT = 1_200;
|
||||
|
||||
/** 동시에 받는 조각 — 직원마다 4개 · 서버 전체 32개. 넘으면 기다리지 않고 429 REQ_001 (한 인스턴스 전제 — 메모리) */
|
||||
export const FILE_CHUNK_CONCURRENCY_PER_USER = 4;
|
||||
export const FILE_CHUNK_CONCURRENCY = 32;
|
||||
|
||||
/**
|
||||
* 악성 검사 (계약 4절 "악성 검사") — 서버 전체에서 한 번에 2개, 먼저 끝낸 것부터.
|
||||
* 2GB 는 분 단위가 걸려 1개면 작은 파일이 큰 파일 뒤에서 오래 기다린다
|
||||
*/
|
||||
export const FILE_SCAN_CONCURRENCY = 2;
|
||||
/** 검사를 기다리는 파일을 찾는 간격 — 끝내기 직후에는 바로 깨운다 */
|
||||
export const FILE_SCAN_POLL_MS = 30_000;
|
||||
/** 검사기에 닿지 못한 뒤 다시 하는 간격 — 1분부터 두 배씩, 1시간까지 */
|
||||
export const FILE_SCAN_RETRY_BASE_MS = 60_000;
|
||||
export const FILE_SCAN_RETRY_MAX_MS = HOUR_MS;
|
||||
/** 이 횟수부터 검사기 장애를 error 로 남긴다 (운영 알림) — 그 전은 warn */
|
||||
export const FILE_SCAN_ERROR_AFTER_ATTEMPTS = 3;
|
||||
|
||||
/** clamd 에 닿기까지 */
|
||||
export const CLAMAV_CONNECT_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
* 흘려보내는 동안 아무것도 오가지 않는 시간의 상한 — clamd 는 INSTREAM 을 받는 대로 임시 파일에 적고 다 받은 뒤에야 검사하므로,
|
||||
* 이만큼 받지 않으면 이상이다(다 보낸 뒤의 기다림은 아래 clamavVerdictTimeoutMs)
|
||||
*/
|
||||
export const CLAMAV_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 다 보낸 뒤 판정을 기다리는 시간 = 30분 + 보낸 1MB(1,048,576바이트 — 한 파일 한도와 같은 단위, 올림)마다 1초 — clamavVerdictTimeoutMs.
|
||||
* 2,047MB(한 파일 한도의 상한)면 3,847초 ≈ 64분.
|
||||
*
|
||||
* 근거 — 2026-10-30 실측(ClamAV 1.4.6, 개발 PC 코어 하나): 2,047MB 무작위 파일을 다 보낸 뒤 판정까지 296초(약 7MB/s).
|
||||
* - 30분은 파일 크기와 상관없는 몫이다: 7z · tar.gz 등은 MaxScanTime 없이(1.4 에서는 ZIP 에만 걸린다) MaxScanSize(4,094MB — 한 파일 한도의 2배)까지
|
||||
* 풀어 검사한다 — 작은 압축 파일도 그렇다. 4,094MB 를 7MB/s 로 약 10분, 운영 CPU 가 2~3배 느리면 20~30분이다
|
||||
* - 1MB 마다 1초는 파일 자체의 몫이다 — 실측(1MB 에 약 0.145초)의 약 7배
|
||||
* 넘으면 판정을 받지 못한 것으로 보고 미뤄서 다시 한다 — clamd 는 처음부터 다시 검사하므로, 판정이 끝내 오지 않아 같은 파일을 끝없이 다시 검사하는 것보다
|
||||
* 한 번 오래 기다리는 편이 낫다. 길게 잡은 값은 clamd 가 살아 있는 채 멈췄을 때 검사 자리(FILE_SCAN_CONCURRENCY)를 그만큼 붙잡는 값이기도 하다 —
|
||||
* 상대가 컨테이너째 사라진 경우는 아래 keepalive 가 따로 알아차린다
|
||||
*/
|
||||
export const CLAMAV_VERDICT_TIMEOUT_BASE_MS = 30 * 60 * 1000;
|
||||
export const CLAMAV_VERDICT_TIMEOUT_PER_MB_MS = 1_000;
|
||||
/** 위 "1MB" 의 바이트 수 — 한 파일 한도(MAIL_LARGE_FILE_MAX_SIZE_MB)와 같은 단위 (file-storage.settings.ts 의 MEGABYTE) */
|
||||
const VERDICT_TIMEOUT_UNIT_BYTES = 1024 * 1024;
|
||||
|
||||
/** 보낸 바이트 수 → 다 보낸 뒤 판정을 기다리는 시간(ms) (순수 함수 — 위 근거) */
|
||||
export function clamavVerdictTimeoutMs(streamedBytes: number): number {
|
||||
const megabytes = Math.ceil(
|
||||
Math.max(0, streamedBytes) / VERDICT_TIMEOUT_UNIT_BYTES,
|
||||
);
|
||||
return (
|
||||
CLAMAV_VERDICT_TIMEOUT_BASE_MS +
|
||||
megabytes * CLAMAV_VERDICT_TIMEOUT_PER_MB_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* clamd 소켓의 TCP keepalive — 마지막으로 오간 뒤 이만큼 지나면 커널이 상대에게 묻기 시작한다.
|
||||
* Node 24(libuv 1.52)는 그 뒤 1초 간격으로 10번 묻고 답이 없으면 `read ETIMEDOUT` 으로 끊는다 — 컨테이너째 사라져 RST 도 오지 않는 clamd 를
|
||||
* 약 70초 안에 알아차린다(2026-10-30 실측: 첫 대기 5초로 두고 상대 네트워크를 내리자 15.4초에 끊김). 끊김은 다른 끊김처럼 미뤄서 다시 한다.
|
||||
* 살아 있는 clamd 는 검사 중에도 커널이 답하므로 긴 검사를 끊지 않는다
|
||||
*/
|
||||
export const CLAMAV_KEEPALIVE_INITIAL_DELAY_MS = 60_000;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { scanWithClamav } from './clamav-client';
|
||||
import { FILE_BLOB_STORE } from './file-blob-store';
|
||||
import {
|
||||
FILE_SCANNER,
|
||||
FileScanService,
|
||||
type FileScanner,
|
||||
} from './file-scan.service';
|
||||
import { FileStorageCleanup } from './file-storage.cleanup';
|
||||
import {
|
||||
CLAMAV_CONNECT_TIMEOUT_MS,
|
||||
CLAMAV_IDLE_TIMEOUT_MS,
|
||||
CLAMAV_KEEPALIVE_INITIAL_DELAY_MS,
|
||||
clamavVerdictTimeoutMs,
|
||||
} from './file-storage.constants';
|
||||
import { FileStorageRepository } from './file-storage.repository';
|
||||
import { FileStorageService } from './file-storage.service';
|
||||
import {
|
||||
FILE_STORAGE_SETTINGS,
|
||||
type FileStorageSettings,
|
||||
buildFileStorageSettings,
|
||||
} from './file-storage.settings';
|
||||
import {
|
||||
FileQuotaController,
|
||||
FileUploadController,
|
||||
} from './file-upload.controller';
|
||||
import { LocalDiskBlobStore } from './local-disk-blob-store';
|
||||
import { StoredFileChunkEntity } from './stored-file-chunk.entity';
|
||||
import { StoredFileEntity } from './stored-file.entity';
|
||||
|
||||
/** 검사기 연결 — CLAMAV_HOST 가 비면(개발) 검사하지 않는다 */
|
||||
function createScanner(settings: FileStorageSettings): FileScanner | null {
|
||||
const { clamav } = settings;
|
||||
if (clamav === null) return null;
|
||||
return (source, signal) =>
|
||||
scanWithClamav(
|
||||
{
|
||||
host: clamav.host,
|
||||
port: clamav.port,
|
||||
connectTimeoutMs: CLAMAV_CONNECT_TIMEOUT_MS,
|
||||
idleTimeoutMs: CLAMAV_IDLE_TIMEOUT_MS,
|
||||
verdictTimeoutMs: clamavVerdictTimeoutMs,
|
||||
keepAliveInitialDelayMs: CLAMAV_KEEPALIVE_INITIAL_DELAY_MS,
|
||||
},
|
||||
source,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 저장 모듈 (11단계 대용량 첨부 — 계약 4절) — 파일 · 조각 올리기 · 악성 검사 · 정리.
|
||||
* 바이트는 저장 포트(FILE_BLOB_STORE — 지금은 로컬 디스크), 정보는 DB. 저장을 바꿀 때는 FILE_BLOB_STORE 의 구현만 바꾼다.
|
||||
*
|
||||
* 의존 방향은 메일 → 공유(file-sharing) → 저장(이 모듈) 하나다 — 이 모듈은 메일 · 공유를 가져오지 않는다.
|
||||
* 2부 파일 저장소가 같은 파일 · 올리기 API 를 쓴다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([StoredFileEntity, StoredFileChunkEntity]),
|
||||
],
|
||||
controllers: [FileUploadController, FileQuotaController],
|
||||
providers: [
|
||||
{
|
||||
provide: FILE_STORAGE_SETTINGS,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): FileStorageSettings =>
|
||||
buildFileStorageSettings((key) => config.get<string>(key)),
|
||||
},
|
||||
{
|
||||
provide: FILE_BLOB_STORE,
|
||||
inject: [FILE_STORAGE_SETTINGS],
|
||||
useFactory: (settings: FileStorageSettings) =>
|
||||
new LocalDiskBlobStore(settings.dir),
|
||||
},
|
||||
{
|
||||
provide: FILE_SCANNER,
|
||||
inject: [FILE_STORAGE_SETTINGS],
|
||||
useFactory: createScanner,
|
||||
},
|
||||
FileStorageRepository,
|
||||
FileScanService,
|
||||
FileStorageService,
|
||||
FileStorageCleanup,
|
||||
],
|
||||
exports: [FileStorageService, FileScanService, FILE_STORAGE_SETTINGS],
|
||||
})
|
||||
export class FileStorageModule {}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, type EntityManager, type Repository } from 'typeorm';
|
||||
|
||||
import { StoredFileChunkEntity } from './stored-file-chunk.entity';
|
||||
import { StoredFileEntity, type StoredFileState } from './stored-file.entity';
|
||||
|
||||
/**
|
||||
* 파일 만들기를 차례대로 — `pg_advisory_xact_lock(이 값)` (트랜잭션이 끝나면 풀린다). 직원 보관량 · 서버 총량 · 디스크 여유를 본 뒤 넣기까지를
|
||||
* 한 번에 하나만 한다 (계약 4절 "한도" — 동시에 둘이 한도를 넘지 않게). 만들기는 드물어 서버 전체 잠금 하나로 충분하다.
|
||||
* DB 잠금이라 인스턴스를 늘려도 그대로 맞다. 이름표 — 'FIL1' 의 ASCII (서명 저장 잠금 'SIG1' 과 섞이지 않게)
|
||||
*/
|
||||
const FILE_CREATE_LOCK = 0x46494c31;
|
||||
|
||||
/** 살아 있는 파일 — 지우지 않았고(상태) 기간 안이다 */
|
||||
const ALIVE = `"state" <> 'removed' AND "expires_at" > $1`;
|
||||
/** 보관량에 드는 파일 — 살아 있고 위험 판정이 아니다 (위험 판정 파일은 바이트를 곧 지운다) */
|
||||
const COUNTED = `${ALIVE} AND "scan_status" IS DISTINCT FROM 'infected'`;
|
||||
/** 살아 있는 링크에 실리지 않은 파일 */
|
||||
const UNSENT = `("linked_until" IS NULL OR "linked_until" <= $1)`;
|
||||
|
||||
/** 만들기 전에 보는 셈 — 지금 상태로 계산한다 (정리 작업을 기다리지 않는다) */
|
||||
export interface CreationStats {
|
||||
/** 그 직원의 보관량 (올리는 중은 선언한 크기) */
|
||||
readonly userUsedBytes: number;
|
||||
/** 그 직원의 보내지 않은 파일 수 (올리는 중 포함, 위험 판정 제외) */
|
||||
readonly userUnsentCount: number;
|
||||
/** 모든 직원의 보관량 */
|
||||
readonly serverUsedBytes: number;
|
||||
/** 진행 중인 올리기가 아직 받지 않은 바이트 (디스크 여유에서 뺀다) */
|
||||
readonly outstandingUploadBytes: number;
|
||||
}
|
||||
|
||||
/** 받은 조각 하나 */
|
||||
export interface ReceivedChunk {
|
||||
readonly index: number;
|
||||
readonly size: number;
|
||||
readonly sha256: Buffer;
|
||||
}
|
||||
|
||||
/** 받은 조각을 적을 때 함께 바꾸는 파일 값 */
|
||||
export interface ChunkRecord {
|
||||
readonly fileId: string;
|
||||
readonly index: number;
|
||||
readonly size: number;
|
||||
readonly sha256: Buffer;
|
||||
readonly receivedAt: Date;
|
||||
/** 이 조각을 받은 뒤의 보관 끝 (올리는 중 — 마지막 조각 뒤 24시간) */
|
||||
readonly expiresAt: Date;
|
||||
}
|
||||
|
||||
const toNumber = (value: unknown): number => Number(value ?? 0);
|
||||
|
||||
/**
|
||||
* 파일 · 조각 표 접근 — 판단(한도 · 상태 · 기간)은 FileStorageService 가 한다.
|
||||
* 주인 조건은 서비스가 함께 본다 (남의 파일 id 로는 아무것도 하지 않는다).
|
||||
*/
|
||||
@Injectable()
|
||||
export class FileStorageRepository {
|
||||
constructor(
|
||||
@InjectRepository(StoredFileEntity)
|
||||
private readonly files: Repository<StoredFileEntity>,
|
||||
@InjectRepository(StoredFileChunkEntity)
|
||||
private readonly chunks: Repository<StoredFileChunkEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 만들기 — 잠근 뒤 셈을 보고(`build` 가 한도를 넘으면 던진다) 넣는다. 한 트랜잭션이다
|
||||
*/
|
||||
createLocked(
|
||||
owner: string,
|
||||
now: Date,
|
||||
build: (stats: CreationStats) => Promise<StoredFileEntity>,
|
||||
): Promise<StoredFileEntity> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query('SELECT pg_advisory_xact_lock($1)', [
|
||||
FILE_CREATE_LOCK,
|
||||
]);
|
||||
const row = await build(await this.creationStats(manager, owner, now));
|
||||
await manager.insert(StoredFileEntity, row);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
findById(id: string): Promise<StoredFileEntity | null> {
|
||||
return this.files.findOneBy({ id });
|
||||
}
|
||||
|
||||
findByIds(ids: readonly string[]): Promise<StoredFileEntity[]> {
|
||||
if (ids.length === 0) return Promise.resolve([]);
|
||||
return this.files.findBy({ id: In([...ids]) });
|
||||
}
|
||||
|
||||
/** 받은 조각 — 파일마다 번호 차례 */
|
||||
async receivedChunks(fileId: string): Promise<ReceivedChunk[]> {
|
||||
const rows = await this.chunks.find({
|
||||
where: { fileId },
|
||||
order: { chunkIndex: 'ASC' },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
index: row.chunkIndex,
|
||||
size: row.size,
|
||||
sha256: row.sha256,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 받은 조각 번호 — 여러 파일 (목록용) */
|
||||
async chunkIndexes(
|
||||
fileIds: readonly string[],
|
||||
): Promise<Map<string, number[]>> {
|
||||
const result = new Map<string, number[]>();
|
||||
if (fileIds.length === 0) return result;
|
||||
const rows = await this.chunks.find({
|
||||
select: { fileId: true, chunkIndex: true },
|
||||
where: { fileId: In([...fileIds]) },
|
||||
order: { chunkIndex: 'ASC' },
|
||||
});
|
||||
for (const row of rows) {
|
||||
const list = result.get(row.fileId) ?? [];
|
||||
list.push(row.chunkIndex);
|
||||
result.set(row.fileId, list);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은 조각을 적는다 — 파일이 아직 올리는 중일 때만(그사이 지웠으면 적지 않는다). 같은 조각을 이미 적었으면 그대로 둔다.
|
||||
* @returns 적었으면 true
|
||||
*/
|
||||
recordChunk(record: ChunkRecord): Promise<boolean> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const updated = await manager.query<[unknown[], number]>(
|
||||
`UPDATE "stored_files"
|
||||
SET "last_chunk_at" = $2,
|
||||
"expires_at" = $3,
|
||||
"first_chunk_sha256" = CASE WHEN $4::int = 0 THEN $5 ELSE "first_chunk_sha256" END
|
||||
WHERE "id" = $1 AND "state" = 'uploading'
|
||||
RETURNING "id"`,
|
||||
[
|
||||
record.fileId,
|
||||
record.receivedAt,
|
||||
record.expiresAt,
|
||||
record.index,
|
||||
record.sha256.toString('base64'),
|
||||
],
|
||||
);
|
||||
if (updated[0].length === 0) return false;
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(StoredFileChunkEntity)
|
||||
.values({
|
||||
fileId: record.fileId,
|
||||
chunkIndex: record.index,
|
||||
size: record.size,
|
||||
sha256: record.sha256,
|
||||
receivedAt: record.receivedAt,
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태를 바꾼다 — 지금 상태가 `from` 일 때만 (한 번만 되는 전이 — 끝내기 · 지우기).
|
||||
* @returns 바꿨으면 true
|
||||
*/
|
||||
async transition(
|
||||
id: string,
|
||||
from: readonly StoredFileState[],
|
||||
changes: Partial<StoredFileEntity>,
|
||||
): Promise<boolean> {
|
||||
const result = await this.files
|
||||
.createQueryBuilder()
|
||||
.update(StoredFileEntity)
|
||||
.set(changes)
|
||||
.where('id = :id', { id })
|
||||
.andWhere('state IN (:...from)', { from: [...from] })
|
||||
.execute();
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간이 지난 파일을 지움(기간 지남)으로 — 그 순간에도 기간이 지났을 때만(그사이 임시 저장 · 링크로 늘었으면 그대로 둔다).
|
||||
* @returns 바꿨으면 true
|
||||
*/
|
||||
async expireIfDue(id: string, now: Date): Promise<boolean> {
|
||||
const result = await this.files
|
||||
.createQueryBuilder()
|
||||
.update(StoredFileEntity)
|
||||
.set({ state: 'removed', removedReason: 'expired', removedAt: now })
|
||||
.where('id = :id', { id })
|
||||
.andWhere(`state <> 'removed'`)
|
||||
.andWhere('expires_at <= :now', { now })
|
||||
.execute();
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 값을 바꾼다 (상태 조건 없이 — 보관 기간 · 검사 결과 · 바이트를 지운 때) */
|
||||
async update(id: string, changes: Partial<StoredFileEntity>): Promise<void> {
|
||||
await this.files.update({ id }, changes);
|
||||
}
|
||||
|
||||
/** 그 직원의 보관량 — 지금 상태로 */
|
||||
async usedBytes(owner: string, now: Date): Promise<number> {
|
||||
const rows = await this.dataSource.query<{ used: string }[]>(
|
||||
`SELECT COALESCE(SUM("size"), 0) AS "used" FROM "stored_files" WHERE "owner_email" = $2 AND ${COUNTED}`,
|
||||
[now, owner],
|
||||
);
|
||||
return toNumber(rows[0]?.used);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내지 않은 파일 — 살아 있고 살아 있는 링크에 실리지 않은 것(올리는 중 포함 · 위험 판정 포함), 만든 때 최근부터
|
||||
*/
|
||||
async listUnsent(
|
||||
owner: string,
|
||||
now: Date,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Promise<{ rows: StoredFileEntity[]; total: number }> {
|
||||
const where = `"owner_email" = :owner AND "state" <> 'removed' AND "expires_at" > :now AND ("linked_until" IS NULL OR "linked_until" <= :now)`;
|
||||
const [rows, total] = await this.files
|
||||
.createQueryBuilder('file')
|
||||
.where(where, { owner, now })
|
||||
.orderBy('file.createdAt', 'DESC')
|
||||
.addOrderBy('file.id', 'DESC')
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.getManyAndCount();
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
/** 검사를 기다리는 파일 — 다 올린 차례(먼저 끝낸 것부터) */
|
||||
findScanCandidates(
|
||||
now: Date,
|
||||
exclude: readonly string[],
|
||||
limit: number,
|
||||
): Promise<StoredFileEntity[]> {
|
||||
const query = this.files
|
||||
.createQueryBuilder('file')
|
||||
.where(`file.state = 'ready'`)
|
||||
.andWhere(`file.scan_status = 'pending'`)
|
||||
.andWhere('file.bytes_deleted_at IS NULL')
|
||||
.andWhere('file.scan_next_at <= :now', { now })
|
||||
.orderBy('file.completedAt', 'ASC')
|
||||
.limit(limit);
|
||||
if (exclude.length > 0) {
|
||||
query.andWhere('file.id NOT IN (:...exclude)', { exclude: [...exclude] });
|
||||
}
|
||||
return query.getMany();
|
||||
}
|
||||
|
||||
/** 기간이 지났는데 아직 지우지 않은 파일 (정리 작업) */
|
||||
findExpiredAlive(now: Date, limit: number): Promise<StoredFileEntity[]> {
|
||||
return this.files
|
||||
.createQueryBuilder('file')
|
||||
.where(`file.state <> 'removed'`)
|
||||
.andWhere('file.expires_at <= :now', { now })
|
||||
.orderBy('file.expiresAt', 'ASC')
|
||||
.limit(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** 바이트를 지워야 하는데 아직 남은 파일 — 지웠거나 위험 판정 (정리 작업) */
|
||||
findBytesToDelete(limit: number): Promise<StoredFileEntity[]> {
|
||||
return this.files
|
||||
.createQueryBuilder('file')
|
||||
.where('file.bytes_deleted_at IS NULL')
|
||||
.andWhere(`(file.state = 'removed' OR file.scan_status = 'infected')`)
|
||||
.limit(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝내는 중에 멈춘 파일 (정리 작업 — 서버가 끝내기 도중에 꺼진 경우) — 끝내기를 시작한 지 오래된 것만.
|
||||
* 시작한 때가 없는 행(이 칸을 더하기 전에 끝내기를 시작한 것)은 마지막 조각 · 만든 때로 본다
|
||||
*/
|
||||
findStaleCompletions(before: Date): Promise<StoredFileEntity[]> {
|
||||
return this.files
|
||||
.createQueryBuilder('file')
|
||||
.where(`file.state = 'completing'`)
|
||||
.andWhere(
|
||||
'COALESCE(file.completion_started_at, file.last_chunk_at, file.created_at) < :before',
|
||||
{ before },
|
||||
)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** 다 올린 파일의 조각 기록을 지운다 — 다 올린 뒤에는 쓰지 않는다 (조각 표가 쌓이지 않게) */
|
||||
async deleteChunks(fileId: string): Promise<void> {
|
||||
await this.chunks.delete({ fileId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 지운 지 오래된 기록을 지운다 (정리 작업 — 조각은 함께 지워진다).
|
||||
* @returns 지운 수
|
||||
*/
|
||||
async deleteRemovedBefore(before: Date): Promise<number> {
|
||||
const result = await this.files
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(StoredFileEntity)
|
||||
.where(`state = 'removed'`)
|
||||
.andWhere('removed_at < :before', { before })
|
||||
.andWhere('bytes_deleted_at IS NOT NULL')
|
||||
.execute();
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/** 바이트가 남아 있어야 하는 파일 id (정리 작업이 표에 없는 저장소 파일을 찾는다) */
|
||||
async idsWithBytes(): Promise<Set<string>> {
|
||||
const rows = await this.files
|
||||
.createQueryBuilder('file')
|
||||
.select('file.id', 'id')
|
||||
.where('file.bytes_deleted_at IS NULL')
|
||||
.getRawMany<{ id: string }>();
|
||||
return new Set(rows.map((row) => row.id));
|
||||
}
|
||||
|
||||
private async creationStats(
|
||||
manager: EntityManager,
|
||||
owner: string,
|
||||
now: Date,
|
||||
): Promise<CreationStats> {
|
||||
const [user] = await manager.query<{ used: string; unsent: string }[]>(
|
||||
`SELECT COALESCE(SUM("size"), 0) AS "used",
|
||||
COUNT(*) FILTER (WHERE ${UNSENT}) AS "unsent"
|
||||
FROM "stored_files" WHERE "owner_email" = $2 AND ${COUNTED}`,
|
||||
[now, owner],
|
||||
);
|
||||
const [server] = await manager.query<{ used: string }[]>(
|
||||
`SELECT COALESCE(SUM("size"), 0) AS "used" FROM "stored_files" WHERE ${COUNTED}`,
|
||||
[now],
|
||||
);
|
||||
const [outstanding] = await manager.query<{ left: string }[]>(
|
||||
`SELECT COALESCE(SUM(f."size" - COALESCE(c."received", 0)), 0) AS "left"
|
||||
FROM "stored_files" f
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(ch."size") AS "received" FROM "stored_file_chunks" ch WHERE ch."file_id" = f."id"
|
||||
) c ON TRUE
|
||||
WHERE f."state" IN ('uploading', 'completing') AND f."expires_at" > $1`,
|
||||
[now],
|
||||
);
|
||||
return {
|
||||
userUsedBytes: toNumber(user?.used),
|
||||
userUnsentCount: toNumber(user?.unsent),
|
||||
serverUsedBytes: toNumber(server?.used),
|
||||
outstandingUploadBytes: toNumber(outstanding?.left),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isOnLiveLink } from './file-retention';
|
||||
import type { FileScanService } from './file-scan.service';
|
||||
import {
|
||||
FILE_CHUNK_SIZE,
|
||||
MAX_UNSENT_FILES_PER_USER,
|
||||
} from './file-storage.constants';
|
||||
import type {
|
||||
ChunkRecord,
|
||||
CreationStats,
|
||||
FileStorageRepository,
|
||||
ReceivedChunk,
|
||||
} from './file-storage.repository';
|
||||
import { FileStorageService } from './file-storage.service';
|
||||
import type { FileStorageSettings } from './file-storage.settings';
|
||||
import type { ChunkUpload } from './file-storage.types';
|
||||
import { LocalDiskBlobStore } from './local-disk-blob-store';
|
||||
import type { StoredFileEntity, StoredFileState } from './stored-file.entity';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const DOYUN = 'doyun.lee@arasystem.test';
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 표 대역 — FileStorageRepository 와 같은 판단(지금 상태로 센 보관량 · 보내지 않은 파일 · 상태 조건)을 메모리에서 한다.
|
||||
* 실제 SQL(잠금 · 부분 인덱스 · RETURNING)은 e2e 가 본다
|
||||
*/
|
||||
class FakeRepository {
|
||||
readonly rows = new Map<string, StoredFileEntity>();
|
||||
readonly chunks = new Map<string, ReceivedChunk[]>();
|
||||
|
||||
private alive(row: StoredFileEntity, now: Date): boolean {
|
||||
return row.state !== 'removed' && row.expiresAt > now;
|
||||
}
|
||||
|
||||
stats(owner: string, now: Date): CreationStats {
|
||||
const counted = [...this.rows.values()].filter(
|
||||
(row) => this.alive(row, now) && row.scanStatus !== 'infected',
|
||||
);
|
||||
const mine = counted.filter((row) => row.ownerEmail === owner);
|
||||
return {
|
||||
userUsedBytes: mine.reduce((sum, row) => sum + row.size, 0),
|
||||
userUnsentCount: mine.filter((row) => !isOnLiveLink(row, now)).length,
|
||||
serverUsedBytes: counted.reduce((sum, row) => sum + row.size, 0),
|
||||
outstandingUploadBytes: counted
|
||||
.filter((row) => row.state === 'uploading')
|
||||
.reduce(
|
||||
(sum, row) =>
|
||||
sum +
|
||||
row.size -
|
||||
(this.chunks.get(row.id) ?? []).reduce((s, c) => s + c.size, 0),
|
||||
0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async createLocked(
|
||||
owner: string,
|
||||
now: Date,
|
||||
build: (stats: CreationStats) => Promise<StoredFileEntity>,
|
||||
): Promise<StoredFileEntity> {
|
||||
const row = await build(this.stats(owner, now));
|
||||
this.rows.set(row.id, { ...row });
|
||||
return row;
|
||||
}
|
||||
|
||||
findById(id: string) {
|
||||
const row = this.rows.get(id);
|
||||
return Promise.resolve(row ? { ...row } : null);
|
||||
}
|
||||
|
||||
findByIds(ids: readonly string[]) {
|
||||
return Promise.resolve(
|
||||
ids.flatMap((id) => {
|
||||
const row = this.rows.get(id);
|
||||
return row ? [{ ...row }] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
receivedChunks(fileId: string) {
|
||||
return Promise.resolve(
|
||||
[...(this.chunks.get(fileId) ?? [])].sort((a, b) => a.index - b.index),
|
||||
);
|
||||
}
|
||||
|
||||
chunkIndexes(ids: readonly string[]) {
|
||||
return Promise.resolve(
|
||||
new Map(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
(this.chunks.get(id) ?? []).map((chunk) => chunk.index).sort(),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
recordChunk(record: ChunkRecord) {
|
||||
const row = this.rows.get(record.fileId);
|
||||
if (!row || row.state !== 'uploading') return Promise.resolve(false);
|
||||
row.lastChunkAt = record.receivedAt;
|
||||
row.expiresAt = record.expiresAt;
|
||||
if (record.index === 0) {
|
||||
row.firstChunkSha256 = record.sha256.toString('base64');
|
||||
}
|
||||
const list = this.chunks.get(record.fileId) ?? [];
|
||||
if (!list.some((chunk) => chunk.index === record.index)) {
|
||||
list.push({
|
||||
index: record.index,
|
||||
size: record.size,
|
||||
sha256: record.sha256,
|
||||
});
|
||||
}
|
||||
this.chunks.set(record.fileId, list);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
transition(
|
||||
id: string,
|
||||
from: readonly StoredFileState[],
|
||||
changes: Partial<StoredFileEntity>,
|
||||
) {
|
||||
const row = this.rows.get(id);
|
||||
if (!row || !from.includes(row.state)) return Promise.resolve(false);
|
||||
Object.assign(row, changes);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
update(id: string, changes: Partial<StoredFileEntity>) {
|
||||
const row = this.rows.get(id);
|
||||
if (row) Object.assign(row, changes);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
deleteChunks(fileId: string) {
|
||||
this.chunks.delete(fileId);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
usedBytes(owner: string, now: Date) {
|
||||
return Promise.resolve(this.stats(owner, now).userUsedBytes);
|
||||
}
|
||||
|
||||
listUnsent(owner: string, now: Date, offset: number, limit: number) {
|
||||
const all = [...this.rows.values()]
|
||||
.filter(
|
||||
(row) =>
|
||||
row.ownerEmail === owner &&
|
||||
this.alive(row, now) &&
|
||||
!isOnLiveLink(row, now),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
return Promise.resolve({
|
||||
rows: all.slice(offset, offset + limit),
|
||||
total: all.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 디스크 여유를 테스트가 정하는 저장소 — 그 밖은 진짜 로컬 디스크(임시 폴더) */
|
||||
class TestBlobStore extends LocalDiskBlobStore {
|
||||
free: number | null = 100 * GB;
|
||||
override freeBytes(): Promise<number | null> {
|
||||
return Promise.resolve(this.free);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = (
|
||||
overrides: Partial<FileStorageSettings> = {},
|
||||
): FileStorageSettings => ({
|
||||
dir: '',
|
||||
maxFileBytes: 2047 * 1024 * 1024,
|
||||
maxFilesPerMessage: 10,
|
||||
userQuotaBytes: 10 * GB,
|
||||
retentionDays: 30,
|
||||
serverMaxTotalBytes: null,
|
||||
minFreeBytes: 10 * GB,
|
||||
clamav: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const sha = (data: Buffer) => createHash('sha256').update(data).digest();
|
||||
|
||||
/** 조각 요청 — 웹처럼 바이트 본문 · 길이 · 다이제스트 */
|
||||
const chunkUpload = (
|
||||
data: Buffer,
|
||||
overrides: Partial<ChunkUpload> = {},
|
||||
): ChunkUpload => ({
|
||||
contentType: 'application/octet-stream',
|
||||
contentEncoding: undefined,
|
||||
contentLength: String(data.length),
|
||||
transferEncoding: undefined,
|
||||
contentDigest: `sha-256=:${sha(data).toString('base64')}:`,
|
||||
body: Readable.from([data]),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
|
||||
try {
|
||||
await run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileStorageService — 대용량 첨부 올리기 (11단계)', () => {
|
||||
let root: string;
|
||||
let repository: FakeRepository;
|
||||
let blobs: TestBlobStore;
|
||||
let scanner: { kick: jest.Mock };
|
||||
let service: FileStorageService;
|
||||
let errorSpy: jest.SpyInstance;
|
||||
|
||||
const build = (overrides: Partial<FileStorageSettings> = {}) => {
|
||||
service = new FileStorageService(
|
||||
repository as unknown as FileStorageRepository,
|
||||
blobs,
|
||||
settings(overrides),
|
||||
scanner as unknown as FileScanService,
|
||||
);
|
||||
return service;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'file-storage-'));
|
||||
repository = new FakeRepository();
|
||||
blobs = new TestBlobStore(root);
|
||||
scanner = { kick: jest.fn() };
|
||||
build();
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const create = (size: number, name = '도면.dwg', owner = ARA) =>
|
||||
service.create(owner, { name, size, purpose: 'mail', lastModified: 1 });
|
||||
|
||||
/** 작은 파일을 다 올린다 (조각 하나) */
|
||||
async function upload(data: Buffer, owner = ARA) {
|
||||
const file = await create(data.length, '도면.dwg', owner);
|
||||
await service.writeChunk(owner, file.id, 0, chunkUpload(data));
|
||||
return service.complete(owner, file.id);
|
||||
}
|
||||
|
||||
describe('만들기', () => {
|
||||
it('이름을 정리하고 조각 계획을 준다 — 올리는 중 · 받은 조각 없음 · 24시간 뒤 지워짐', async () => {
|
||||
const before = Date.now();
|
||||
const file = await service.create(ARA, {
|
||||
name: 'C:\\Users\\kim\\현장\u202E영상.mp4',
|
||||
size: FILE_CHUNK_SIZE * 2 + 5,
|
||||
purpose: 'mail',
|
||||
lastModified: 123,
|
||||
});
|
||||
expect(file).toMatchObject({
|
||||
name: '현장영상.mp4',
|
||||
status: 'uploading',
|
||||
chunkSize: FILE_CHUNK_SIZE,
|
||||
chunkCount: 3,
|
||||
receivedChunks: [],
|
||||
firstChunkSha256: null,
|
||||
scanStatus: null,
|
||||
lastModified: 123,
|
||||
});
|
||||
const expires = Date.parse(file.expiresAt);
|
||||
expect(expires - before).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000);
|
||||
expect(repository.rows.get(file.id)?.ownerEmail).toBe(ARA);
|
||||
});
|
||||
|
||||
it('형식(확장자 허용 목록) → 크기 → 직원 한도 → 서버 공간 차례로 막는다', async () => {
|
||||
expect(await codeOf(() => create(10, 'setup.exe'))).toBe('FILE_001');
|
||||
expect(await codeOf(() => create(10, 'disk.iso'))).toBe('FILE_001');
|
||||
expect(await codeOf(() => create(2048 * 1024 * 1024))).toBe('FILE_002');
|
||||
|
||||
build({ userQuotaBytes: 3 * GB });
|
||||
await create(Math.floor(1.9 * GB));
|
||||
expect(await codeOf(() => create(Math.floor(1.2 * GB)))).toBe('FILE_005');
|
||||
// 다른 직원은 따로 센다
|
||||
expect(
|
||||
await codeOf(() => create(Math.floor(1.2 * GB), '도면.dwg', DOYUN)),
|
||||
).toBeNull();
|
||||
|
||||
build({ userQuotaBytes: 3 * GB, serverMaxTotalBytes: 4 * GB });
|
||||
expect(await codeOf(() => create(GB, '도면.dwg', DOYUN))).toBe(
|
||||
'FILE_006',
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('보내지 않은 파일은 직원마다 50개까지 (올리는 중 포함)', async () => {
|
||||
for (let index = 0; index < MAX_UNSENT_FILES_PER_USER; index += 1) {
|
||||
await create(1);
|
||||
}
|
||||
expect(await codeOf(() => create(1))).toBe('FILE_005');
|
||||
});
|
||||
|
||||
it('디스크 여유 — 만든 뒤에도 남길 여유 + 진행 중인 올리기가 아직 받지 않은 바이트를 빼고 (error 로그)', async () => {
|
||||
blobs.free = 13 * GB;
|
||||
await create(Math.floor(1.5 * GB));
|
||||
// 남음 13 − 받을 몫 1.5 − 요청 1.6 = 9.9 < 10
|
||||
expect(await codeOf(() => create(Math.floor(1.6 * GB)))).toBe('FILE_006');
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('조각', () => {
|
||||
it('받은 조각만 적고 0번의 SHA-256 을 준다 · 다시 보낸 같은 조각은 그대로 200, 다른 내용은 409 FILE_003', async () => {
|
||||
const data = Buffer.from('조각 하나짜리 파일');
|
||||
const file = await create(data.length);
|
||||
const first = await service.writeChunk(
|
||||
ARA,
|
||||
file.id,
|
||||
0,
|
||||
chunkUpload(data),
|
||||
);
|
||||
expect(first.receivedChunks).toEqual([0]);
|
||||
expect(first.firstChunkSha256).toBe(sha(data).toString('base64'));
|
||||
|
||||
await expect(
|
||||
service.writeChunk(ARA, file.id, 0, chunkUpload(data)),
|
||||
).resolves.toMatchObject({ receivedChunks: [0] });
|
||||
const other = Buffer.from('다른 내용인 같은 길이!!');
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.writeChunk(
|
||||
ARA,
|
||||
file.id,
|
||||
0,
|
||||
chunkUpload(other.subarray(0, data.length)),
|
||||
),
|
||||
),
|
||||
).toBe('FILE_003');
|
||||
});
|
||||
|
||||
it('다이제스트가 받은 바이트와 다르면 400 FILE_004 — 그 조각을 받지 않았다', async () => {
|
||||
const data = Buffer.from('abc');
|
||||
const file = await create(3);
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.writeChunk(
|
||||
ARA,
|
||||
file.id,
|
||||
0,
|
||||
chunkUpload(data, {
|
||||
contentDigest: `sha-256=:${sha(Buffer.from('xyz')).toString('base64')}:`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBe('FILE_004');
|
||||
expect(repository.chunks.get(file.id)).toBeUndefined();
|
||||
// 다이제스트 없이도 받는다 (다른 클라이언트)
|
||||
await expect(
|
||||
service.writeChunk(
|
||||
ARA,
|
||||
file.id,
|
||||
0,
|
||||
chunkUpload(data, { contentDigest: undefined }),
|
||||
),
|
||||
).resolves.toMatchObject({ receivedChunks: [0] });
|
||||
});
|
||||
|
||||
it('머리글 — 바이트 본문 · 압축 없이 · 길이가 조각 길이와 같아야 · 번호 범위 (400 VAL_001), 남의 올리기 404', async () => {
|
||||
const data = Buffer.from('abc');
|
||||
const file = await create(3);
|
||||
for (const overrides of [
|
||||
{ contentType: 'application/json' },
|
||||
{ contentEncoding: 'gzip' },
|
||||
{ contentLength: undefined },
|
||||
{ contentLength: '4' },
|
||||
{ transferEncoding: 'chunked' },
|
||||
{ contentDigest: 'sha-256=:bad:' },
|
||||
] as Partial<ChunkUpload>[]) {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.writeChunk(ARA, file.id, 0, chunkUpload(data, overrides)),
|
||||
),
|
||||
).toBe('VAL_001');
|
||||
}
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.writeChunk(ARA, file.id, 1, chunkUpload(data)),
|
||||
),
|
||||
).toBe('VAL_001');
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.writeChunk(DOYUN, file.id, 0, chunkUpload(data)),
|
||||
),
|
||||
).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('끝낸 올리기에 조각을 보내면 409 FILE_003', async () => {
|
||||
const done = await upload(Buffer.from('abc'));
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.writeChunk(ARA, done.id, 0, chunkUpload(Buffer.from('abc'))),
|
||||
),
|
||||
).toBe('FILE_003');
|
||||
});
|
||||
});
|
||||
|
||||
describe('끝내기 · 지우기', () => {
|
||||
it('빠진 조각이 있으면 409 FILE_003, 모두 있으면 ready + 검사 차례(검사기 없으면 unscannable) · 다시 부르면 그대로', async () => {
|
||||
const file = await create(FILE_CHUNK_SIZE + 3);
|
||||
expect(await codeOf(() => service.complete(ARA, file.id))).toBe(
|
||||
'FILE_003',
|
||||
);
|
||||
await service.writeChunk(
|
||||
ARA,
|
||||
file.id,
|
||||
1,
|
||||
chunkUpload(Buffer.from('xyz')),
|
||||
);
|
||||
const big = Buffer.alloc(FILE_CHUNK_SIZE, 7);
|
||||
await service.writeChunk(ARA, file.id, 0, chunkUpload(big));
|
||||
|
||||
const ready = await service.complete(ARA, file.id);
|
||||
expect(ready).toMatchObject({
|
||||
status: 'ready',
|
||||
receivedChunks: [0, 1],
|
||||
scanStatus: 'unscannable',
|
||||
});
|
||||
expect(scanner.kick).toHaveBeenCalled();
|
||||
await expect(service.complete(ARA, file.id)).resolves.toMatchObject({
|
||||
status: 'ready',
|
||||
});
|
||||
// 바이트는 다 올린 자리로 옮겨졌다 — 차례 없이 받은 조각이 제자리에
|
||||
const stream = await blobs.openRead(file.id, {
|
||||
start: FILE_CHUNK_SIZE - 1,
|
||||
end: FILE_CHUNK_SIZE + 2,
|
||||
});
|
||||
const parts: Buffer[] = [];
|
||||
for await (const part of stream) parts.push(part as Buffer);
|
||||
expect(Buffer.concat(parts)).toEqual(Buffer.from([7, 0x78, 0x79, 0x7a]));
|
||||
});
|
||||
|
||||
it('끝내기를 시작한 때를 적고, 다 올리면 지운다 · 다 올린 파일의 조각 기록도 지운다(다 올린 뒤에는 쓰지 않는다)', async () => {
|
||||
const file = await create(3);
|
||||
await service.writeChunk(
|
||||
ARA,
|
||||
file.id,
|
||||
0,
|
||||
chunkUpload(Buffer.from('abc')),
|
||||
);
|
||||
const transition = jest.spyOn(repository, 'transition');
|
||||
|
||||
await service.complete(ARA, file.id);
|
||||
const [, from, changes] = transition.mock.calls[0];
|
||||
expect(from).toEqual(['uploading']);
|
||||
expect(changes).toMatchObject({ state: 'completing' });
|
||||
expect(changes.completionStartedAt).toBeInstanceOf(Date);
|
||||
expect(repository.rows.get(file.id)).toMatchObject({
|
||||
state: 'ready',
|
||||
completionStartedAt: null,
|
||||
});
|
||||
expect(repository.chunks.has(file.id)).toBe(false);
|
||||
// 조각 기록이 없어도 다 올린 파일은 모든 조각을 받은 것으로 보인다
|
||||
await expect(service.get(ARA, file.id)).resolves.toMatchObject({
|
||||
receivedChunks: [0],
|
||||
});
|
||||
});
|
||||
|
||||
it('끝내는 사이 상태가 바뀌었으면 — 정리 작업이 먼저 끝냈으면 그 결과를, 되돌렸으면 409 FILE_003 (상태 전이의 결과를 본다)', async () => {
|
||||
const transitionTo = (state: StoredFileState) => {
|
||||
const original = repository.transition.bind(repository);
|
||||
jest
|
||||
.spyOn(repository, 'transition')
|
||||
.mockImplementation((id, from, changes) => {
|
||||
const row = repository.rows.get(id);
|
||||
// 다 올림으로 바꾸기 바로 앞에 정리 작업이 먼저 바꿨다
|
||||
if (changes.state === 'ready' && row) {
|
||||
Object.assign(row, {
|
||||
state,
|
||||
completionStartedAt: null,
|
||||
...(state === 'ready'
|
||||
? { completedAt: new Date(), scanStatus: 'unscannable' }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return original(id, from, changes);
|
||||
});
|
||||
};
|
||||
|
||||
const recovered = await create(3);
|
||||
await service.writeChunk(
|
||||
ARA,
|
||||
recovered.id,
|
||||
0,
|
||||
chunkUpload(Buffer.from('abc')),
|
||||
);
|
||||
transitionTo('ready');
|
||||
await expect(service.complete(ARA, recovered.id)).resolves.toMatchObject({
|
||||
id: recovered.id,
|
||||
status: 'ready',
|
||||
});
|
||||
jest.restoreAllMocks();
|
||||
|
||||
const reverted = await create(3);
|
||||
await service.writeChunk(
|
||||
ARA,
|
||||
reverted.id,
|
||||
0,
|
||||
chunkUpload(Buffer.from('abc')),
|
||||
);
|
||||
transitionTo('uploading');
|
||||
expect(await codeOf(() => service.complete(ARA, reverted.id))).toBe(
|
||||
'FILE_003',
|
||||
);
|
||||
});
|
||||
|
||||
it('검사기가 있으면 다 올린 파일은 검사 중(pending)', async () => {
|
||||
build({ clamav: { host: 'clamav', port: 3310 } });
|
||||
await expect(upload(Buffer.from('abc'))).resolves.toMatchObject({
|
||||
scanStatus: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('지우기 — 보관량에서 바로 빠지고 바이트도 지운다 · 살아 있는 링크에 실린 파일은 409 FILE_003', async () => {
|
||||
const done = await upload(Buffer.from('abc'));
|
||||
expect(await service.quota(ARA)).toMatchObject({ usedBytes: 3 });
|
||||
|
||||
const row = repository.rows.get(done.id);
|
||||
if (row) row.linkedUntil = new Date(Date.now() + 60_000);
|
||||
expect(await codeOf(() => service.remove(ARA, done.id))).toBe('FILE_003');
|
||||
if (row) row.linkedUntil = null;
|
||||
|
||||
await service.remove(ARA, done.id);
|
||||
expect(await service.quota(ARA)).toMatchObject({ usedBytes: 0 });
|
||||
expect(repository.rows.get(done.id)).toMatchObject({
|
||||
state: 'removed',
|
||||
removedReason: 'deleted',
|
||||
});
|
||||
expect(await blobs.isStored(done.id)).toBe(false);
|
||||
expect(await codeOf(() => service.get(ARA, done.id))).toBe('RES_001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일 · 공유가 쓰는 것', () => {
|
||||
it('checkUse — 보낼 때는 다 올림 · 위험 아님, 저장은 올리는 중 · 위험도 된다. 남의 것 · 없음 · 지움 · 기간 지남은 까닭을 준다', async () => {
|
||||
const ready = await upload(Buffer.from('abc'));
|
||||
const uploading = await create(10);
|
||||
const infected = await upload(Buffer.from('def'));
|
||||
const infectedRow = repository.rows.get(infected.id);
|
||||
if (infectedRow) infectedRow.scanStatus = 'infected';
|
||||
const deleted = await upload(Buffer.from('ghi'));
|
||||
await service.remove(ARA, deleted.id);
|
||||
const theirs = await upload(Buffer.from('jkl'), DOYUN);
|
||||
const missing = '11111111-2222-4333-8444-555555555555';
|
||||
|
||||
const ids = [
|
||||
ready.id,
|
||||
uploading.id,
|
||||
infected.id,
|
||||
deleted.id,
|
||||
theirs.id,
|
||||
missing,
|
||||
];
|
||||
const send = await service.checkUse(ARA, ids, 'send');
|
||||
expect(send.usable.map((file) => file.id)).toEqual([ready.id]);
|
||||
expect(Object.fromEntries(send.problems)).toEqual({
|
||||
[uploading.id]: 'uploading',
|
||||
[infected.id]: 'infected',
|
||||
[deleted.id]: 'deleted',
|
||||
[theirs.id]: 'not-found',
|
||||
[missing]: 'not-found',
|
||||
});
|
||||
const draft = await service.checkUse(ARA, ids, 'draft');
|
||||
expect(draft.usable.map((file) => file.id)).toEqual([
|
||||
ready.id,
|
||||
uploading.id,
|
||||
infected.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('임시 저장에 실리면 다 올린 날(한국)부터 30일째 날 끝까지 남는다', async () => {
|
||||
const done = await upload(Buffer.from('abc'));
|
||||
const before = Date.parse(done.expiresAt);
|
||||
await service.attachToDraft(ARA, [done.id]);
|
||||
const after = repository.rows.get(done.id)?.expiresAt.getTime() ?? 0;
|
||||
expect(after - before).toBeGreaterThan(28 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('draftFiles — 그 직원의 것만, 지움 · 기간 지남은 MissingLargeFile, 남의 id · 기록이 없는 id 는 버린다', async () => {
|
||||
const ready = await upload(Buffer.from('abc'));
|
||||
const deleted = await upload(Buffer.from('def'));
|
||||
await service.remove(ARA, deleted.id);
|
||||
const expired = await upload(Buffer.from('ghi'));
|
||||
const expiredRow = repository.rows.get(expired.id);
|
||||
if (expiredRow) expiredRow.expiresAt = new Date(Date.now() - 1);
|
||||
const theirs = await upload(Buffer.from('jkl'), DOYUN);
|
||||
|
||||
const files = await service.draftFiles(ARA, [
|
||||
ready.id,
|
||||
deleted.id,
|
||||
expired.id,
|
||||
theirs.id,
|
||||
'11111111-2222-4333-8444-555555555555',
|
||||
]);
|
||||
expect(files.map((file) => [file.id, file.status])).toEqual([
|
||||
[ready.id, 'ready'],
|
||||
[deleted.id, 'missing'],
|
||||
[expired.id, 'missing'],
|
||||
]);
|
||||
expect(files[1]).toMatchObject({
|
||||
reason: 'deleted',
|
||||
name: '도면.dwg',
|
||||
size: 3,
|
||||
});
|
||||
expect(files[2]).toMatchObject({ reason: 'expired' });
|
||||
});
|
||||
|
||||
it('applyLinkRetention — 링크가 끝나는 때까지 남고, 링크를 끊으면(보낸 뒤 임시 저장에 실리지 않음) 곧 끝난다', async () => {
|
||||
const done = await upload(Buffer.from('abc'));
|
||||
const linkEnd = new Date(Date.now() + 40 * 24 * 60 * 60 * 1000);
|
||||
await service.applyLinkRetention([
|
||||
{ fileId: done.id, linkedAt: new Date(), linkedUntil: linkEnd },
|
||||
]);
|
||||
expect(repository.rows.get(done.id)?.expiresAt).toEqual(linkEnd);
|
||||
// 살아 있는 링크의 파일은 보내지 않은 파일 목록에 없다
|
||||
expect((await service.listUnsent(ARA, { limit: 20 })).items).toEqual([]);
|
||||
|
||||
await service.applyLinkRetention([
|
||||
{ fileId: done.id, linkedAt: new Date(), linkedUntil: null },
|
||||
]);
|
||||
expect(
|
||||
repository.rows.get(done.id)?.expiresAt.getTime() ?? Infinity,
|
||||
).toBeLessThanOrEqual(Date.now());
|
||||
expect(await service.quota(ARA)).toMatchObject({ usedBytes: 0 });
|
||||
});
|
||||
|
||||
it('openContent — 바이트가 없으면 사라진 것으로 적는다', async () => {
|
||||
const done = await upload(Buffer.from('abc'));
|
||||
await blobs.remove(done.id);
|
||||
await expect(service.openContent(done.id)).rejects.toThrow();
|
||||
expect(repository.rows.get(done.id)?.bytesDeletedAt).not.toBeNull();
|
||||
expect(
|
||||
(await service.views([done.id])).get(done.id)?.bytesAvailable,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('사용량 · 한도 (GET /api/file-quota)', async () => {
|
||||
await create(5);
|
||||
expect(await service.quota(ARA)).toEqual({
|
||||
usedBytes: 5,
|
||||
limitBytes: 10 * GB,
|
||||
maxFileBytes: 2047 * 1024 * 1024,
|
||||
maxFilesPerMessage: 10,
|
||||
chunkSize: FILE_CHUNK_SIZE,
|
||||
retentionDays: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('보내지 않은 파일 — 만든 때 최근부터, 커서로 이어 받는다 · 깨진 커서는 400 VAL_001', async () => {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const file = await create(1, `파일${index}.pdf`);
|
||||
const row = repository.rows.get(file.id);
|
||||
if (row) row.createdAt = new Date(Date.now() + index * 1000);
|
||||
}
|
||||
const first = await service.listUnsent(ARA, { limit: 2 });
|
||||
expect(first.items.map((file) => file.name)).toEqual([
|
||||
'파일2.pdf',
|
||||
'파일1.pdf',
|
||||
]);
|
||||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 3 });
|
||||
const second = await service.listUnsent(ARA, {
|
||||
limit: 2,
|
||||
cursor: first.pageInfo.nextCursor ?? undefined,
|
||||
});
|
||||
expect(second.items.map((file) => file.name)).toEqual(['파일0.pdf']);
|
||||
expect(
|
||||
await codeOf(() => service.listUnsent(ARA, { limit: 2, cursor: '!!' })),
|
||||
).toBe('VAL_001');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,865 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { sanitizeFileName } from '@/shared/utils/file-name';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { isStreamAbortError } from '@/shared/utils/stream-abort';
|
||||
import {
|
||||
decodeOffsetCursor,
|
||||
encodeOffsetCursor,
|
||||
} from '@/shared/utils/offset-cursor';
|
||||
import {
|
||||
ChunkGate,
|
||||
chunkLength,
|
||||
digestStream,
|
||||
parseContentDigest,
|
||||
} from './chunk-upload';
|
||||
import {
|
||||
ChunkLengthMismatchError,
|
||||
FILE_BLOB_STORE,
|
||||
isDiskFull,
|
||||
isMissingFile,
|
||||
type BlobRange,
|
||||
type FileBlobStore,
|
||||
} from './file-blob-store';
|
||||
import { fileExpiresAt, isOnLiveLink } from './file-retention';
|
||||
import { FileScanService } from './file-scan.service';
|
||||
import {
|
||||
FILE_CHUNK_CONCURRENCY,
|
||||
FILE_CHUNK_CONCURRENCY_PER_USER,
|
||||
FILE_CHUNK_SIZE,
|
||||
FILE_PURPOSES,
|
||||
MAX_UNSENT_FILES_PER_USER,
|
||||
UPLOAD_IDLE_RETENTION_MS,
|
||||
} from './file-storage.constants';
|
||||
import { FileStorageRepository } from './file-storage.repository';
|
||||
import {
|
||||
FILE_STORAGE_SETTINGS,
|
||||
type FileStorageSettings,
|
||||
} from './file-storage.settings';
|
||||
import type {
|
||||
ChunkUpload,
|
||||
FileLinkRetention,
|
||||
FileQuota,
|
||||
FileUploadCreate,
|
||||
FileUseProblem,
|
||||
LargeFile,
|
||||
MissingLargeFile,
|
||||
StoredFileView,
|
||||
} from './file-storage.types';
|
||||
import { isAllowedLargeFileName } from './large-file.policy';
|
||||
import { StoredFileEntity } from './stored-file.entity';
|
||||
|
||||
/** 보내지 않은 파일 목록의 커서 범위 (shared/utils/offset-cursor.ts) */
|
||||
const UNSENT_SCOPE = 'file-uploads';
|
||||
|
||||
const OCTET_STREAM = 'application/octet-stream';
|
||||
|
||||
/** 쓰려는 파일을 견주는 때 — 보낼 때는 다 올렸고 위험이 아니어야 한다 · 임시 저장은 올리는 중 · 위험도 된다 */
|
||||
export type FileUse = 'send' | 'draft';
|
||||
|
||||
/** 바이트가 저장소에 없다 (저장소 복원 등) — 내려받기는 "찾을 수 없음" */
|
||||
export class FileContentMissingError extends Error {}
|
||||
|
||||
function toView(file: StoredFileEntity, now: Date): StoredFileView {
|
||||
const state = file.state === 'completing' ? 'uploading' : file.state;
|
||||
return {
|
||||
id: file.id,
|
||||
ownerEmail: file.ownerEmail,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
state,
|
||||
removedReason: file.removedReason,
|
||||
scanStatus: file.scanStatus,
|
||||
completedAt: file.completedAt,
|
||||
expiresAt: file.expiresAt,
|
||||
alive: file.state !== 'removed' && file.expiresAt > now,
|
||||
bytesAvailable:
|
||||
file.state === 'ready' &&
|
||||
file.bytesDeletedAt === null &&
|
||||
file.scanStatus !== 'infected',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 대용량 첨부 파일 · 올리기 (11단계 — 계약 4절 "올리기" · "한도" · "기간 · 보관 · 정리"). 바이트는 저장 포트, 정보는 DB.
|
||||
* 메일 모듈(발송 · 임시 저장 · 읽기)과 공유 모듈(링크 · 공개 내려받기)이 이 서비스를 쓴다 — 이 모듈은 그 둘을 모른다(메일 → 공유 → 저장 한 방향).
|
||||
*
|
||||
* 로그(계약 4절 "로그"): 파일 이름은 남기지 않는다 — 파일 id · 크기만, 직원 주소는 가린다. 다 올림 · 보내지 않은 파일 지우기는 log,
|
||||
* 조각은 남기지 않는다(반복되는 정상 흐름), 디스크가 모자라면 error.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FileStorageService {
|
||||
private readonly logger = new Logger(FileStorageService.name);
|
||||
|
||||
/** 동시에 받는 조각 — 서버 32 · 직원 4 · 같은 조각 하나 (프로세스 메모리 — 한 인스턴스 전제) */
|
||||
private readonly gate = new ChunkGate(
|
||||
FILE_CHUNK_CONCURRENCY,
|
||||
FILE_CHUNK_CONCURRENCY_PER_USER,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly repository: FileStorageRepository,
|
||||
@Inject(FILE_BLOB_STORE) private readonly blobs: FileBlobStore,
|
||||
@Inject(FILE_STORAGE_SETTINGS)
|
||||
private readonly settings: FileStorageSettings,
|
||||
private readonly scanner: FileScanService,
|
||||
) {}
|
||||
|
||||
// ── 올리기 API (/api/file-uploads · /api/file-quota) ─────────────────────────
|
||||
|
||||
/**
|
||||
* 만들기 — 형식(정리한 이름의 확장자, 400 FILE_001) → 크기(413 FILE_002) → 직원 한도(409 FILE_005) → 서버 총량 · 디스크 여유(507 FILE_006).
|
||||
* 한도는 선언한 크기로 미리 잡는다 — 조각을 보내는 동안에는 다시 보지 않는다. 만들기는 서버 전체에서 하나씩(DB 잠금)
|
||||
*/
|
||||
async create(owner: string, input: FileUploadCreate): Promise<LargeFile> {
|
||||
const name = sanitizeFileName(input.name);
|
||||
if (!isAllowedLargeFileName(name)) {
|
||||
throw new BusinessException('FILE_001', {
|
||||
context: { reason: 'large-file-type-not-allowed' },
|
||||
});
|
||||
}
|
||||
if (!(FILE_PURPOSES as readonly string[]).includes(input.purpose)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'unknown-file-purpose' },
|
||||
});
|
||||
}
|
||||
if (input.size > this.settings.maxFileBytes) {
|
||||
throw new BusinessException('FILE_002', {
|
||||
context: { reason: 'large-file-too-big', size: input.size },
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const ownerEmail = owner.toLowerCase();
|
||||
const row = await this.repository.createLocked(
|
||||
ownerEmail,
|
||||
now,
|
||||
async (stats) => {
|
||||
if (
|
||||
stats.userUsedBytes + input.size > this.settings.userQuotaBytes ||
|
||||
stats.userUnsentCount >= MAX_UNSENT_FILES_PER_USER
|
||||
) {
|
||||
throw new BusinessException('FILE_005', {
|
||||
context: {
|
||||
reason: 'user-file-quota',
|
||||
used: stats.userUsedBytes,
|
||||
unsent: stats.userUnsentCount,
|
||||
size: input.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
const { serverMaxTotalBytes } = this.settings;
|
||||
if (
|
||||
serverMaxTotalBytes !== null &&
|
||||
stats.serverUsedBytes + input.size > serverMaxTotalBytes
|
||||
) {
|
||||
this.logger.error(
|
||||
`[FILE_006]${traceTag()} 서버 전체 대용량 첨부 보관량 한도에 닿아 올리기를 거절합니다: 지금=${stats.serverUsedBytes}, 한도=${serverMaxTotalBytes}, 요청=${input.size}`,
|
||||
);
|
||||
throw new BusinessException('FILE_006', {
|
||||
context: { reason: 'server-file-quota' },
|
||||
});
|
||||
}
|
||||
const free = await this.blobs.freeBytes();
|
||||
if (
|
||||
free !== null &&
|
||||
free - stats.outstandingUploadBytes - input.size <
|
||||
this.settings.minFreeBytes
|
||||
) {
|
||||
this.logger.error(
|
||||
`[FILE_006]${traceTag()} 파일 저장소의 디스크 여유가 모자라 올리기를 거절합니다: 남음=${free}, 받을 몫=${stats.outstandingUploadBytes}, 요청=${input.size}, 남길 여유=${this.settings.minFreeBytes}`,
|
||||
);
|
||||
throw new BusinessException('FILE_006', {
|
||||
context: { reason: 'disk-free-space' },
|
||||
});
|
||||
}
|
||||
|
||||
const row = new StoredFileEntity();
|
||||
Object.assign(row, {
|
||||
id: randomUUID(),
|
||||
ownerEmail,
|
||||
purpose: input.purpose,
|
||||
name,
|
||||
size: input.size,
|
||||
chunkSize: FILE_CHUNK_SIZE,
|
||||
chunkCount: Math.ceil(input.size / FILE_CHUNK_SIZE),
|
||||
lastModified: input.lastModified,
|
||||
state: 'uploading',
|
||||
removedReason: null,
|
||||
firstChunkSha256: null,
|
||||
scanStatus: null,
|
||||
scanSignature: null,
|
||||
scanAttempts: 0,
|
||||
scanNextAt: null,
|
||||
scannedAt: null,
|
||||
createdAt: now,
|
||||
lastChunkAt: null,
|
||||
completedAt: null,
|
||||
completionStartedAt: null,
|
||||
draftAttachedAt: null,
|
||||
linkedAt: null,
|
||||
linkedUntil: null,
|
||||
expiresAt: new Date(now.getTime() + UPLOAD_IDLE_RETENTION_MS),
|
||||
removedAt: null,
|
||||
bytesDeletedAt: null,
|
||||
} satisfies Partial<StoredFileEntity>);
|
||||
return row;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await this.blobs.begin(row.id, row.size);
|
||||
} catch (error) {
|
||||
await this.repository.transition(row.id, ['uploading'], {
|
||||
state: 'removed',
|
||||
removedReason: 'expired',
|
||||
removedAt: new Date(),
|
||||
expiresAt: new Date(),
|
||||
bytesDeletedAt: new Date(),
|
||||
});
|
||||
if (isDiskFull(error)) throw this.diskFull(row.id);
|
||||
throw error;
|
||||
}
|
||||
return this.toLargeFile(row, []);
|
||||
}
|
||||
|
||||
/** 상태 — 남의 것 · 지운 것 · 기간이 지난 것 · 없는 것은 모두 404 RES_001 */
|
||||
async get(owner: string, id: string): Promise<LargeFile> {
|
||||
const file = await this.findVisible(owner, id, new Date());
|
||||
return this.present(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조각 하나 — 계약 4절 "올리기" 2. 본문은 이 처리기가 요청 스트림을 직접 읽는다(본문 파서를 지나지 않는다).
|
||||
* 머리글 검사(형식 · 길이 · 압축 · 다이제스트 모양, 400 VAL_001) → 자리(동시 수 429 REQ_001 · 같은 조각 409 FILE_003) →
|
||||
* 파일(404) → 받은 조각이면 견주기(같으면 200, 다르면 409 FILE_003) · 아니면 쓰고(datasync) 다이제스트를 보고(400 FILE_004) 적는다
|
||||
*/
|
||||
async writeChunk(
|
||||
owner: string,
|
||||
id: string,
|
||||
index: number,
|
||||
upload: ChunkUpload,
|
||||
): Promise<LargeFile> {
|
||||
const declaredLength = this.checkChunkHeaders(upload);
|
||||
const digest = parseContentDigest(upload.contentDigest);
|
||||
if (digest === 'invalid') {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'chunk-digest-malformed' },
|
||||
});
|
||||
}
|
||||
|
||||
const ticket = this.gate.tryEnter(owner.toLowerCase(), id, index);
|
||||
if (ticket === 'chunk-busy') {
|
||||
throw new BusinessException('FILE_003', {
|
||||
context: { reason: 'same-chunk-in-progress', id, index },
|
||||
});
|
||||
}
|
||||
if (typeof ticket === 'string') {
|
||||
throw new BusinessException('REQ_001', {
|
||||
context: { reason: `chunk-${ticket}`, user: maskEmail(owner) },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const file = await this.findVisible(owner, id, new Date());
|
||||
if (index >= file.chunkCount) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'chunk-index-out-of-range', index },
|
||||
});
|
||||
}
|
||||
const expected = chunkLength(file, index);
|
||||
if (declaredLength !== expected) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'chunk-length-mismatch',
|
||||
declared: declaredLength,
|
||||
expected,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (file.state !== 'uploading') {
|
||||
throw new BusinessException('FILE_003', {
|
||||
context: { reason: 'upload-not-open', id, state: file.state },
|
||||
});
|
||||
}
|
||||
|
||||
const received = (await this.repository.receivedChunks(id)).find(
|
||||
(chunk) => chunk.index === index,
|
||||
);
|
||||
if (received) {
|
||||
// 이미 받은 조각 — 쓰지 않고 내용을 견준다 (응답을 받지 못한 재시도)
|
||||
const sha256 = await this.readChunkDigest(upload.body, expected);
|
||||
if (digest !== null && !digest.equals(sha256)) {
|
||||
throw this.chunkCorrupted(id, index);
|
||||
}
|
||||
if (!sha256.equals(received.sha256)) {
|
||||
throw new BusinessException('FILE_003', {
|
||||
context: { reason: 'chunk-content-differs', id, index },
|
||||
});
|
||||
}
|
||||
return this.present(file);
|
||||
}
|
||||
|
||||
const written = await this.storeChunk(file, index, expected, upload.body);
|
||||
if (digest !== null && !digest.equals(written)) {
|
||||
throw this.chunkCorrupted(id, index);
|
||||
}
|
||||
const now = new Date();
|
||||
const recorded = await this.repository.recordChunk({
|
||||
fileId: id,
|
||||
index,
|
||||
size: expected,
|
||||
sha256: written,
|
||||
receivedAt: now,
|
||||
expiresAt: new Date(now.getTime() + UPLOAD_IDLE_RETENTION_MS),
|
||||
});
|
||||
if (!recorded) {
|
||||
// 그사이 지웠거나(404) 끝냈다(409)
|
||||
const current = await this.findVisible(owner, id, new Date());
|
||||
throw new BusinessException('FILE_003', {
|
||||
context: {
|
||||
reason: 'upload-closed-while-writing',
|
||||
state: current.state,
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.present(await this.findVisible(owner, id, new Date()));
|
||||
} finally {
|
||||
ticket.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝내기 — 조각이 모두 있으면 파일로 만들고(`ready`) 곧바로 악성 검사 차례에 넣는다. 이미 끝냈으면 그대로 200(응답을 못 받은 재시도).
|
||||
* 빠진 조각 · 진행 중(끝내기 · 조각)이면 409 FILE_003
|
||||
*/
|
||||
async complete(owner: string, id: string): Promise<LargeFile> {
|
||||
const file = await this.findVisible(owner, id, new Date());
|
||||
if (file.state === 'ready') return this.present(file);
|
||||
if (file.state === 'completing') {
|
||||
throw new BusinessException('FILE_003', {
|
||||
context: { reason: 'completion-in-progress', id },
|
||||
});
|
||||
}
|
||||
const chunks = await this.repository.receivedChunks(id);
|
||||
if (chunks.length < file.chunkCount) {
|
||||
throw new BusinessException('FILE_003', {
|
||||
context: {
|
||||
reason: 'chunks-missing',
|
||||
id,
|
||||
received: chunks.length,
|
||||
total: file.chunkCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
this.gate.isActive(id) ||
|
||||
!(await this.repository.transition(id, ['uploading'], {
|
||||
state: 'completing',
|
||||
completionStartedAt: new Date(),
|
||||
}))
|
||||
) {
|
||||
throw new BusinessException('FILE_003', {
|
||||
context: { reason: 'completion-in-progress', id },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await this.blobs.complete(id);
|
||||
} catch (error) {
|
||||
// 되돌린다 — 그사이 정리 작업이 먼저 되돌렸으면(멈춘 끝내기로 보고) 그대로 둔다
|
||||
await this.repository.transition(id, ['completing'], {
|
||||
state: 'uploading',
|
||||
completionStartedAt: null,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const scanStatus = this.settings.clamav ? 'pending' : 'unscannable';
|
||||
const ready: StoredFileEntity = {
|
||||
...file,
|
||||
state: 'ready',
|
||||
completedAt: now,
|
||||
scanStatus,
|
||||
scanNextAt: scanStatus === 'pending' ? now : null,
|
||||
};
|
||||
ready.expiresAt = fileExpiresAt(ready, this.settings.retentionDays, now);
|
||||
const finished = await this.repository.transition(id, ['completing'], {
|
||||
state: 'ready',
|
||||
completedAt: now,
|
||||
completionStartedAt: null,
|
||||
scanStatus,
|
||||
scanNextAt: ready.scanNextAt,
|
||||
expiresAt: ready.expiresAt,
|
||||
});
|
||||
if (!finished) {
|
||||
// 그사이 상태가 바뀌었다 — 정리 작업이 먼저 끝냈으면(ready) 그것을 주고, 되돌렸거나 지웠으면 409 (다다음 요청이 지금 상태를 본다)
|
||||
const current = await this.repository.findById(id);
|
||||
if (
|
||||
current?.state === 'ready' &&
|
||||
this.isOwnedAlive(current, owner, now)
|
||||
) {
|
||||
return this.present(current);
|
||||
}
|
||||
throw new BusinessException('FILE_003', {
|
||||
context: {
|
||||
reason: 'completion-state-changed',
|
||||
id,
|
||||
state: current?.state ?? 'missing',
|
||||
},
|
||||
});
|
||||
}
|
||||
// 다 올린 뒤에는 조각 기록을 쓰지 않는다 — 지워도 되고, 실패해도 그대로 둔다(셈은 올리는 중인 파일만 본다)
|
||||
await this.repository
|
||||
.deleteChunks(id)
|
||||
.catch((error: unknown) =>
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 다 올린 파일의 조각 기록을 지우지 못했습니다: id=${id}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
this.logger.log(
|
||||
`${traceTag()} 대용량 첨부를 다 올렸습니다: user=${maskEmail(owner)}, id=${id}, 크기=${file.size}, 검사=${scanStatus}`,
|
||||
);
|
||||
this.scanner.kick();
|
||||
return this.present(ready);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내지 않은 내 파일 — 살아 있는 링크에 실리지 않은 것(올리는 중 · 다 올림 · 위험 판정), 만든 때 최근부터, total 포함.
|
||||
* 커서는 위치다 (지우는 사이 한두 개가 겹치거나 빠질 수 있다)
|
||||
*/
|
||||
async listUnsent(
|
||||
owner: string,
|
||||
query: { readonly cursor?: string; readonly limit: number },
|
||||
): Promise<PaginatedResult<LargeFile>> {
|
||||
let offset = 0;
|
||||
if (query.cursor !== undefined) {
|
||||
const reading = decodeOffsetCursor(query.cursor, UNSENT_SCOPE);
|
||||
if ('problem' in reading) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-file-cursor:${reading.problem}` },
|
||||
});
|
||||
}
|
||||
offset = reading.offset;
|
||||
}
|
||||
const { rows, total } = await this.repository.listUnsent(
|
||||
owner.toLowerCase(),
|
||||
new Date(),
|
||||
offset,
|
||||
query.limit,
|
||||
);
|
||||
const indexes = await this.repository.chunkIndexes(
|
||||
rows.filter((row) => row.state !== 'ready').map((row) => row.id),
|
||||
);
|
||||
const next = offset + rows.length;
|
||||
const hasNext = next < total;
|
||||
return toPaginatedResult(
|
||||
rows.map((row) => this.toLargeFile(row, indexes.get(row.id) ?? [])),
|
||||
{
|
||||
hasNext,
|
||||
nextCursor: hasNext ? encodeOffsetCursor(next, UNSENT_SCOPE) : null,
|
||||
total,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그만두기 · 지우기 — 올리는 중이면 그만두고, 다 올렸어도 살아 있는 링크에 실리지 않았으면 지운다(보관량에서 바로 빠진다).
|
||||
* 살아 있는 링크에 실린 파일 · 끝내는 중이면 409 FILE_003
|
||||
*/
|
||||
async remove(owner: string, id: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const file = await this.findVisible(owner, id, now);
|
||||
if (isOnLiveLink(file, now) || file.state === 'completing') {
|
||||
throw new BusinessException('FILE_003', {
|
||||
context: {
|
||||
reason:
|
||||
file.state === 'completing'
|
||||
? 'completion-in-progress'
|
||||
: 'file-on-live-link',
|
||||
id,
|
||||
},
|
||||
});
|
||||
}
|
||||
const removed = await this.repository.transition(
|
||||
id,
|
||||
['uploading', 'ready'],
|
||||
{
|
||||
state: 'removed',
|
||||
removedReason: 'deleted',
|
||||
removedAt: now,
|
||||
expiresAt: now,
|
||||
},
|
||||
);
|
||||
if (!removed) {
|
||||
throw new BusinessException('FILE_003', {
|
||||
context: { reason: 'file-state-changed', id },
|
||||
});
|
||||
}
|
||||
await this.deleteBytes(id);
|
||||
this.logger.log(
|
||||
`${traceTag()} 보내지 않은 대용량 첨부를 지웠습니다: user=${maskEmail(owner)}, id=${id}, 크기=${file.size}, 올리는 중=${file.state === 'uploading'}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 사용량 · 한도 (GET /api/file-quota) — 지금 상태로 계산한다 */
|
||||
async quota(owner: string): Promise<FileQuota> {
|
||||
return {
|
||||
usedBytes: await this.repository.usedBytes(
|
||||
owner.toLowerCase(),
|
||||
new Date(),
|
||||
),
|
||||
limitBytes: this.settings.userQuotaBytes,
|
||||
maxFileBytes: this.settings.maxFileBytes,
|
||||
maxFilesPerMessage: this.settings.maxFilesPerMessage,
|
||||
chunkSize: FILE_CHUNK_SIZE,
|
||||
retentionDays: this.settings.retentionDays,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 다른 모듈(메일 · 공유)이 쓰는 것 ─────────────────────────────────────────
|
||||
|
||||
/** 설정 (메일 한 통의 개수 · 보관 날 수) */
|
||||
get limits(): Pick<
|
||||
FileStorageSettings,
|
||||
'maxFilesPerMessage' | 'retentionDays'
|
||||
> {
|
||||
return {
|
||||
maxFilesPerMessage: this.settings.maxFilesPerMessage,
|
||||
retentionDays: this.settings.retentionDays,
|
||||
};
|
||||
}
|
||||
|
||||
/** 파일 보기 — 없는 id 는 빠진다 (주인은 부르는 쪽이 본다) */
|
||||
async views(ids: readonly string[]): Promise<Map<string, StoredFileView>> {
|
||||
const now = new Date();
|
||||
const rows = await this.repository.findByIds([...new Set(ids)]);
|
||||
return new Map(rows.map((row) => [row.id, toView(row, now)]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내기 · 임시 저장에 쓸 수 있는지 — 내 것이고 살아 있어야 한다(없음 · 남의 것은 not-found, 지움 deleted, 기간 지남 expired).
|
||||
* 보낼 때는 다 올렸고(uploading) 위험이 아니어야 한다(infected). 검사 중 · 검사하지 못함은 보낸다.
|
||||
* @returns 쓸 수 있는 파일(요청 차례)과 쓸 수 없는 파일의 까닭
|
||||
*/
|
||||
async checkUse(
|
||||
owner: string,
|
||||
ids: readonly string[],
|
||||
use: FileUse,
|
||||
): Promise<{
|
||||
readonly usable: StoredFileView[];
|
||||
readonly problems: Map<string, FileUseProblem>;
|
||||
}> {
|
||||
const found = await this.views(ids);
|
||||
const usable: StoredFileView[] = [];
|
||||
const problems = new Map<string, FileUseProblem>();
|
||||
for (const id of ids) {
|
||||
const view = found.get(id);
|
||||
const problem = this.useProblem(owner, view, use);
|
||||
if (problem !== null) problems.set(id, problem);
|
||||
else if (view) usable.push(view);
|
||||
}
|
||||
return { usable, problems };
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장에 실렸다 — 다 올린 파일의 남는 기간이 24시간에서 다 올린 날부터 보관 날 수로 는다 (올리는 중은 그대로 마지막 조각 뒤 24시간).
|
||||
* 취소해 되돌린 발송의 파일도 이것으로 임시 저장 기간에 돌아간다. 내 것 · 살아 있는 것만
|
||||
*/
|
||||
async attachToDraft(owner: string, ids: readonly string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const now = new Date();
|
||||
const rows = await this.repository.findByIds([...new Set(ids)]);
|
||||
for (const row of rows) {
|
||||
if (!this.isOwnedAlive(row, owner, now)) continue;
|
||||
const next = { ...row, draftAttachedAt: now };
|
||||
await this.repository.update(row.id, {
|
||||
draftAttachedAt: now,
|
||||
expiresAt: fileExpiresAt(next, this.settings.retentionDays, now),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 링크 상태가 바뀌었다(공유 모듈 — 만들기 · 취소 · 끊기) — 파일마다 링크의 때를 적고 남는 기간을 다시 계산한다.
|
||||
* 지운 파일은 그대로 둔다
|
||||
*/
|
||||
async applyLinkRetention(
|
||||
entries: readonly FileLinkRetention[],
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
const now = new Date();
|
||||
const rows = new Map(
|
||||
(
|
||||
await this.repository.findByIds(entries.map((entry) => entry.fileId))
|
||||
).map((row) => [row.id, row]),
|
||||
);
|
||||
for (const entry of entries) {
|
||||
const row = rows.get(entry.fileId);
|
||||
if (!row || row.state === 'removed') continue;
|
||||
const next = {
|
||||
...row,
|
||||
linkedAt: entry.linkedAt,
|
||||
linkedUntil: entry.linkedUntil,
|
||||
};
|
||||
await this.repository.update(row.id, {
|
||||
linkedAt: entry.linkedAt,
|
||||
linkedUntil: entry.linkedUntil,
|
||||
expiresAt: fileExpiresAt(next, this.settings.retentionDays, now),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 내려받을 내용을 연다 — 바이트가 저장소에 없으면(복원 등) 사라진 것으로 적고 FileContentMissingError (내려받기는 410 FILE_007 "찾을 수 없음")
|
||||
*/
|
||||
async openContent(id: string, range?: BlobRange): Promise<Readable> {
|
||||
try {
|
||||
return await this.blobs.openRead(id, range);
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
await this.repository.update(id, { bytesDeletedAt: new Date() });
|
||||
this.logger.warn(
|
||||
`[FILE_007]${traceTag()} 내려받을 파일의 바이트가 저장소에 없어 "찾을 수 없음"으로 둡니다: id=${id}`,
|
||||
);
|
||||
throw new FileContentMissingError(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장본의 대용량 파일 (GET /api/drafts/{id} 의 largeFiles) — 그 직원의 것만, 요청 차례.
|
||||
* 살아 있으면 LargeFile, 지웠거나 기간이 지났으면 MissingLargeFile, 남의 id · 기록까지 지워진 id 는 버린다
|
||||
*/
|
||||
async draftFiles(
|
||||
owner: string,
|
||||
ids: readonly string[],
|
||||
): Promise<(LargeFile | MissingLargeFile)[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const now = new Date();
|
||||
const rows = new Map(
|
||||
(await this.repository.findByIds([...new Set(ids)])).map((row) => [
|
||||
row.id,
|
||||
row,
|
||||
]),
|
||||
);
|
||||
const visible = [...rows.values()].filter(
|
||||
(row) => this.sameOwner(row, owner) && this.isVisible(row, now),
|
||||
);
|
||||
const indexes = await this.repository.chunkIndexes(
|
||||
visible.filter((row) => row.state !== 'ready').map((row) => row.id),
|
||||
);
|
||||
const result: (LargeFile | MissingLargeFile)[] = [];
|
||||
for (const id of new Set(ids)) {
|
||||
const row = rows.get(id);
|
||||
if (!row || !this.sameOwner(row, owner)) continue;
|
||||
if (this.isVisible(row, now)) {
|
||||
result.push(this.toLargeFile(row, indexes.get(row.id) ?? []));
|
||||
} else {
|
||||
result.push({
|
||||
id: row.id,
|
||||
status: 'missing',
|
||||
reason: row.removedReason === 'deleted' ? 'deleted' : 'expired',
|
||||
name: row.name,
|
||||
size: row.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 내부 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 머리글 — 바이트 본문만(400), 압축 없이(400), 길이가 있어야(400 — 청크 전송 아님). 길이를 돌려준다 */
|
||||
private checkChunkHeaders(upload: ChunkUpload): number {
|
||||
const mediaType = (upload.contentType ?? '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (mediaType !== OCTET_STREAM) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'chunk-content-type' },
|
||||
});
|
||||
}
|
||||
const encoding = upload.contentEncoding?.trim().toLowerCase();
|
||||
if (encoding && encoding !== 'identity') {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'chunk-content-encoding' },
|
||||
});
|
||||
}
|
||||
const length = upload.contentLength?.trim();
|
||||
if (
|
||||
upload.transferEncoding !== undefined ||
|
||||
length === undefined ||
|
||||
!/^\d{1,10}$/.test(length)
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'chunk-content-length' },
|
||||
});
|
||||
}
|
||||
return Number(length);
|
||||
}
|
||||
|
||||
private async readChunkDigest(
|
||||
body: Readable,
|
||||
expected: number,
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
return await digestStream(body, expected);
|
||||
} catch (error) {
|
||||
throw this.chunkReadFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 조각을 저장소에 쓴다 — 디스크가 차면 507 FILE_006(error), 그사이 지운 올리기 404, 끊긴 본문 400 */
|
||||
private async storeChunk(
|
||||
file: StoredFileEntity,
|
||||
index: number,
|
||||
expected: number,
|
||||
body: Readable,
|
||||
): Promise<Buffer> {
|
||||
try {
|
||||
const written = await this.blobs.writeChunk(
|
||||
file.id,
|
||||
index * file.chunkSize,
|
||||
expected,
|
||||
body,
|
||||
);
|
||||
return written.sha256;
|
||||
} catch (error) {
|
||||
if (isDiskFull(error)) throw this.diskFull(file.id);
|
||||
if (isMissingFile(error)) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'upload-removed-while-writing', id: file.id },
|
||||
});
|
||||
}
|
||||
throw this.chunkReadFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문을 다 받지 못했다(길이가 다름 · 클라이언트가 끊음) — 받지 않은 조각이다(400). 그 밖의 오류(디스크 등)는 그대로(500 — error 로그).
|
||||
* 스트림이 destroyed 인지로 가르지 않는다 — 쓰다 실패해도 `for await` 가 본문을 destroy 한다 (shared/utils/stream-abort.ts)
|
||||
*/
|
||||
private chunkReadFailure(error: unknown): unknown {
|
||||
if (
|
||||
error instanceof ChunkLengthMismatchError ||
|
||||
isStreamAbortError(error)
|
||||
) {
|
||||
return new BusinessException('VAL_001', {
|
||||
context: { reason: 'chunk-incomplete' },
|
||||
});
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
private chunkCorrupted(id: string, index: number): BusinessException {
|
||||
return new BusinessException('FILE_004', {
|
||||
context: { reason: 'chunk-digest-mismatch', id, index },
|
||||
});
|
||||
}
|
||||
|
||||
private diskFull(id: string): BusinessException {
|
||||
this.logger.error(
|
||||
`[FILE_006]${traceTag()} 파일 저장소의 디스크가 찼습니다 (ENOSPC): id=${id}`,
|
||||
);
|
||||
return new BusinessException('FILE_006', {
|
||||
context: { reason: 'disk-full', id },
|
||||
});
|
||||
}
|
||||
|
||||
/** 바이트를 지운다 — 실패해도 던지지 않는다(정리 작업이 다시 지운다) */
|
||||
private async deleteBytes(id: string): Promise<void> {
|
||||
try {
|
||||
await this.blobs.remove(id);
|
||||
await this.repository.update(id, { bytesDeletedAt: new Date() });
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 파일 바이트를 지우지 못했습니다 — 정리 작업이 다시 지웁니다: id=${id}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private sameOwner(file: StoredFileEntity, owner: string): boolean {
|
||||
return file.ownerEmail.toLowerCase() === owner.toLowerCase();
|
||||
}
|
||||
|
||||
/** 보이는 파일 — 지우지 않았고 기간 안 */
|
||||
private isVisible(file: StoredFileEntity, now: Date): boolean {
|
||||
return file.state !== 'removed' && file.expiresAt > now;
|
||||
}
|
||||
|
||||
private isOwnedAlive(file: StoredFileEntity, owner: string, now: Date) {
|
||||
return this.sameOwner(file, owner) && this.isVisible(file, now);
|
||||
}
|
||||
|
||||
/** 그 직원의 보이는 파일 — 아니면 404 RES_001 (남의 것 · 지운 것 · 기간이 지난 것 · 없는 것을 가리지 않는다) */
|
||||
private async findVisible(
|
||||
owner: string,
|
||||
id: string,
|
||||
now: Date,
|
||||
): Promise<StoredFileEntity> {
|
||||
const file = await this.repository.findById(id);
|
||||
if (!file || !this.isOwnedAlive(file, owner, now)) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'file-not-found', id },
|
||||
});
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private useProblem(
|
||||
owner: string,
|
||||
view: StoredFileView | undefined,
|
||||
use: FileUse,
|
||||
): FileUseProblem | null {
|
||||
if (!view || view.ownerEmail.toLowerCase() !== owner.toLowerCase()) {
|
||||
return 'not-found';
|
||||
}
|
||||
if (!view.alive) {
|
||||
return view.removedReason === 'deleted' ? 'deleted' : 'expired';
|
||||
}
|
||||
if (use === 'send') {
|
||||
if (view.state !== 'ready') return 'uploading';
|
||||
if (view.scanStatus === 'infected') return 'infected';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async present(file: StoredFileEntity): Promise<LargeFile> {
|
||||
if (file.state === 'ready') return this.toLargeFile(file, []);
|
||||
const indexes = await this.repository.chunkIndexes([file.id]);
|
||||
return this.toLargeFile(file, indexes.get(file.id) ?? []);
|
||||
}
|
||||
|
||||
private toLargeFile(
|
||||
file: StoredFileEntity,
|
||||
receivedChunks: number[],
|
||||
): LargeFile {
|
||||
const ready = file.state === 'ready';
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: ready ? 'ready' : 'uploading',
|
||||
chunkSize: file.chunkSize,
|
||||
chunkCount: file.chunkCount,
|
||||
receivedChunks: ready
|
||||
? Array.from({ length: file.chunkCount }, (_, index) => index)
|
||||
: [...receivedChunks].sort((a, b) => a - b),
|
||||
firstChunkSha256: file.firstChunkSha256,
|
||||
scanStatus: ready ? file.scanStatus : null,
|
||||
createdAt: file.createdAt.toISOString(),
|
||||
expiresAt: file.expiresAt.toISOString(),
|
||||
lastModified: file.lastModified,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
|
||||
/**
|
||||
* 대용량 첨부 · 파일 저장 설정 — 루트 env 에서 compose 가 넣는다 (계약 4절 "환경 변수"). 검사는 env.validation.ts 가 기동 때 한다.
|
||||
* 여기는 값이 비었거나(compose 의 빈 문자열) 형식이 틀리면 기본값으로 — 검사를 건너뛴 경우의 방어선이다.
|
||||
*/
|
||||
export const FILE_STORAGE_SETTINGS = Symbol('FILE_STORAGE_SETTINGS');
|
||||
|
||||
export interface ClamavSettings {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
}
|
||||
|
||||
export interface FileStorageSettings {
|
||||
/** 파일을 두는 곳 — 운영은 named volume `<PROJECT_NAME>_files` → `/app/files` (compose 가 FILE_STORAGE_DIR 로 고정) */
|
||||
readonly dir: string;
|
||||
/** 한 파일 (바이트) — ClamAV 가 검사할 수 있는 2GiB 아래 */
|
||||
readonly maxFileBytes: number;
|
||||
/** 메일 한 통에 새로 싣는 대용량 파일 수 */
|
||||
readonly maxFilesPerMessage: number;
|
||||
/** 직원당 보관량 (바이트) */
|
||||
readonly userQuotaBytes: number;
|
||||
/** 보낸 날(한국)부터 · 임시 저장에 실린 파일이 남는 날 수 */
|
||||
readonly retentionDays: number;
|
||||
/** 서버 전체 보관량 (바이트) — null 이면 한도 없음(디스크 여유만 본다) */
|
||||
readonly serverMaxTotalBytes: number | null;
|
||||
/** 만든 뒤에도 남길 디스크 여유 (바이트) */
|
||||
readonly minFreeBytes: number;
|
||||
/** 악성 검사기(clamd) — null 이면 검사하지 않는다(개발 — 다 올리자마자 unscannable). 운영은 env 검증이 필수로 막는다 */
|
||||
readonly clamav: ClamavSettings | null;
|
||||
}
|
||||
|
||||
export const MEGABYTE = 1024 * 1024;
|
||||
export const GIGABYTE = 1024 * MEGABYTE;
|
||||
|
||||
/** 한 파일 한도의 상한(MB) — ClamAV 가 검사할 수 있는 2GiB 아래 (2,047MB = 2,146,435,072바이트) */
|
||||
export const LARGE_FILE_MAX_SIZE_MB_LIMIT = 2047;
|
||||
/** 메일 한 통의 대용량 파일 수 상한 — 발송 · 임시 저장 본문의 largeFileIds 길이 상한(남용 방지)과 같다 */
|
||||
export const LARGE_FILE_MAX_COUNT_LIMIT = 20;
|
||||
export const LARGE_FILE_USER_QUOTA_GB_LIMIT = 1_000;
|
||||
export const LARGE_FILE_RETENTION_DAYS_LIMIT = 90;
|
||||
|
||||
export const DEFAULT_LARGE_FILE_MAX_SIZE_MB = LARGE_FILE_MAX_SIZE_MB_LIMIT;
|
||||
export const DEFAULT_LARGE_FILE_MAX_COUNT = 10;
|
||||
export const DEFAULT_LARGE_FILE_USER_QUOTA_GB = 10;
|
||||
export const DEFAULT_LARGE_FILE_RETENTION_DAYS = 30;
|
||||
export const DEFAULT_FILE_STORAGE_MIN_FREE_GB = 10;
|
||||
export const DEFAULT_CLAMAV_PORT = 3310;
|
||||
/** 컨테이너의 작업 폴더(/app) 기준 — 운영은 compose 가 `/app/files` 로 넣는다. 호스트 개발에서는 backend/files (gitignore) */
|
||||
export const DEFAULT_FILE_STORAGE_DIR = './files';
|
||||
|
||||
function readTrimmed(
|
||||
read: (key: string) => string | undefined,
|
||||
key: string,
|
||||
): string | null {
|
||||
const value = read(key)?.trim();
|
||||
return value ? value : null;
|
||||
}
|
||||
|
||||
/** 양의 정수 env — 비었거나 틀리면 기본값, 상한을 넘으면 상한 */
|
||||
function boundedInt(
|
||||
read: (key: string) => string | undefined,
|
||||
key: string,
|
||||
fallback: number,
|
||||
max: number,
|
||||
): number {
|
||||
return Math.min(parsePositiveInt(read(key), fallback), max);
|
||||
}
|
||||
|
||||
/** env 값을 읽어 설정을 만든다 (순수 함수 — 읽기 함수를 주입받는다) */
|
||||
export function buildFileStorageSettings(
|
||||
read: (key: string) => string | undefined,
|
||||
): FileStorageSettings {
|
||||
const totalGb = readTrimmed(read, 'FILE_STORAGE_MAX_TOTAL_GB');
|
||||
const parsedTotalGb = totalGb === null ? null : parsePositiveInt(totalGb, 0);
|
||||
const clamavHost = readTrimmed(read, 'CLAMAV_HOST');
|
||||
|
||||
return {
|
||||
dir: readTrimmed(read, 'FILE_STORAGE_DIR') ?? DEFAULT_FILE_STORAGE_DIR,
|
||||
maxFileBytes:
|
||||
boundedInt(
|
||||
read,
|
||||
'MAIL_LARGE_FILE_MAX_SIZE_MB',
|
||||
DEFAULT_LARGE_FILE_MAX_SIZE_MB,
|
||||
LARGE_FILE_MAX_SIZE_MB_LIMIT,
|
||||
) * MEGABYTE,
|
||||
maxFilesPerMessage: boundedInt(
|
||||
read,
|
||||
'MAIL_LARGE_FILE_MAX_COUNT',
|
||||
DEFAULT_LARGE_FILE_MAX_COUNT,
|
||||
LARGE_FILE_MAX_COUNT_LIMIT,
|
||||
),
|
||||
userQuotaBytes:
|
||||
boundedInt(
|
||||
read,
|
||||
'MAIL_LARGE_FILE_USER_QUOTA_GB',
|
||||
DEFAULT_LARGE_FILE_USER_QUOTA_GB,
|
||||
LARGE_FILE_USER_QUOTA_GB_LIMIT,
|
||||
) * GIGABYTE,
|
||||
retentionDays: boundedInt(
|
||||
read,
|
||||
'MAIL_LARGE_FILE_RETENTION_DAYS',
|
||||
DEFAULT_LARGE_FILE_RETENTION_DAYS,
|
||||
LARGE_FILE_RETENTION_DAYS_LIMIT,
|
||||
),
|
||||
serverMaxTotalBytes:
|
||||
parsedTotalGb === null || parsedTotalGb === 0
|
||||
? null
|
||||
: parsedTotalGb * GIGABYTE,
|
||||
minFreeBytes:
|
||||
parsePositiveInt(
|
||||
read('FILE_STORAGE_MIN_FREE_GB'),
|
||||
DEFAULT_FILE_STORAGE_MIN_FREE_GB,
|
||||
) * GIGABYTE,
|
||||
clamav: clamavHost
|
||||
? {
|
||||
host: clamavHost,
|
||||
port: Math.min(
|
||||
parsePositiveInt(read('CLAMAV_PORT'), DEFAULT_CLAMAV_PORT),
|
||||
65_535,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
/**
|
||||
* 파일 저장 도메인 타입 — 계약 4절 "타입" 과 같은 모양이다 (11단계 대용량 첨부, 2부 파일 저장소가 같은 파일 · 올리기를 쓴다).
|
||||
*/
|
||||
|
||||
/** 검사 중 · 깨끗함 · 위험 · 검사하지 못함 */
|
||||
export type ScanStatus = 'pending' | 'clean' | 'infected' | 'unscannable';
|
||||
|
||||
/** 내가 올린 대용량 파일 — 올리기 API 의 응답 · 보내지 않은 파일 목록 · Draft.largeFiles */
|
||||
export interface LargeFile {
|
||||
/** 올리기 id = 다 올린 뒤의 파일 id (바뀌지 않는다) */
|
||||
readonly id: string;
|
||||
/** 서버가 정리한 이름 */
|
||||
readonly name: string;
|
||||
/** 바이트 */
|
||||
readonly size: number;
|
||||
readonly status: 'uploading' | 'ready';
|
||||
readonly chunkSize: number;
|
||||
readonly chunkCount: number;
|
||||
/** 받은 조각 번호(0 부터, 오름차순) — ready 면 모두 */
|
||||
readonly receivedChunks: number[];
|
||||
/** 받은 0번 조각의 SHA-256 (base64) — 받기 전이면 null */
|
||||
readonly firstChunkSha256: string | null;
|
||||
/** ready 일 때만 (uploading 은 null) */
|
||||
readonly scanStatus: ScanStatus | null;
|
||||
readonly createdAt: string;
|
||||
/** 이대로 두면 지워지는 때 */
|
||||
readonly expiresAt: string;
|
||||
/** 만들 때 받은 File.lastModified (ms) */
|
||||
readonly lastModified: number | null;
|
||||
}
|
||||
|
||||
/** 임시 저장본이 가리키는데 이제 없는 파일 */
|
||||
export interface MissingLargeFile {
|
||||
readonly id: string;
|
||||
readonly status: 'missing';
|
||||
/** 지운 파일 · 기간이 지남 */
|
||||
readonly reason: 'deleted' | 'expired';
|
||||
readonly name: string | null;
|
||||
readonly size: number | null;
|
||||
}
|
||||
|
||||
/** GET /api/file-quota */
|
||||
export interface FileQuota {
|
||||
readonly usedBytes: number;
|
||||
readonly limitBytes: number;
|
||||
readonly maxFileBytes: number;
|
||||
readonly maxFilesPerMessage: number;
|
||||
readonly chunkSize: number;
|
||||
readonly retentionDays: number;
|
||||
}
|
||||
|
||||
/** 만들기 요청 (서비스가 받는 값 — DTO 가 형식을 본 뒤) */
|
||||
export interface FileUploadCreate {
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
readonly purpose: string;
|
||||
readonly lastModified: number | null;
|
||||
}
|
||||
|
||||
/** 조각 요청 — 컨트롤러가 요청 머리글과 본문 스트림을 그대로 넘긴다 (본문 파서를 지나지 않는다) */
|
||||
export interface ChunkUpload {
|
||||
readonly contentType: string | undefined;
|
||||
readonly contentEncoding: string | undefined;
|
||||
readonly contentLength: string | undefined;
|
||||
readonly transferEncoding: string | undefined;
|
||||
readonly contentDigest: string | undefined;
|
||||
readonly body: Readable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 모듈(공유 · 메일)이 보는 파일 하나 — 주인 · 상태 · 검사 결과 (내용 없이).
|
||||
* `alive` 는 지우지 않았고 기간 안이다(위험 판정 파일은 기록이 남는 동안 alive), `bytesAvailable` 은 내려줄 바이트가 저장소에 있다(위험 · 지움 · 사라짐이 아님)
|
||||
*/
|
||||
export interface StoredFileView {
|
||||
readonly id: string;
|
||||
readonly ownerEmail: string;
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
readonly state: 'uploading' | 'ready' | 'removed';
|
||||
readonly removedReason: 'deleted' | 'expired' | null;
|
||||
readonly scanStatus: ScanStatus | null;
|
||||
readonly completedAt: Date | null;
|
||||
readonly expiresAt: Date;
|
||||
readonly alive: boolean;
|
||||
readonly bytesAvailable: boolean;
|
||||
}
|
||||
|
||||
/** 쓰려는 파일이 쓸 수 없는 까닭 — 메일이 MAIL_016 으로 바꾼다 */
|
||||
export type FileUseProblem =
|
||||
| 'not-found'
|
||||
| 'deleted'
|
||||
| 'expired'
|
||||
| 'uploading'
|
||||
| 'infected';
|
||||
|
||||
/** 링크 쪽(공유 모듈)이 알려 주는 파일의 링크 상태 — 보관 기간 계산에 쓴다 */
|
||||
export interface FileLinkRetention {
|
||||
readonly fileId: string;
|
||||
/** 취소하지 않은 링크 가운데 가장 늦게 만든 때 — 없으면 null */
|
||||
readonly linkedAt: Date | null;
|
||||
/** 취소 · 끊지 않은 링크 가운데 가장 늦게 끝나는 때 — 없으면 null */
|
||||
readonly linkedUntil: Date | null;
|
||||
}
|
||||
|
||||
/** 위험 판정 소식 — 메일 모듈이 실시간 알림(`large-file`)으로 보낸다 */
|
||||
export interface FileScanVerdictEvent {
|
||||
readonly ownerEmail: string;
|
||||
readonly fileId: string;
|
||||
readonly name: string;
|
||||
readonly scanStatus: ScanStatus;
|
||||
}
|
||||
|
||||
/** 내려받을 파일 내용 — 범위가 있으면 그 부분만 */
|
||||
export interface FileContent {
|
||||
readonly stream: Readable;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCookieAuth,
|
||||
ApiHeader,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
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 { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
FileQuotaDto,
|
||||
FileUploadCreateDto,
|
||||
LargeFileDto,
|
||||
LargeFilePageDto,
|
||||
} from './dto/file-upload.dto';
|
||||
import { ChunkIndexPipe, FileIdPipe } from './file-id.pipe';
|
||||
import {
|
||||
FILE_CHUNK_CONCURRENCY,
|
||||
FILE_CHUNK_CONCURRENCY_PER_USER,
|
||||
FILE_CHUNK_SIZE,
|
||||
FILE_CHUNK_THROTTLE_LIMIT,
|
||||
FILE_CHUNK_THROTTLE_TTL_MS,
|
||||
FILE_QUOTA_ROUTE,
|
||||
FILE_UPLOADS_ROUTE,
|
||||
MAX_UNSENT_FILES_PER_USER,
|
||||
} from './file-storage.constants';
|
||||
import { FileStorageService } from './file-storage.service';
|
||||
|
||||
/** 요청 머리글 하나 (여럿이면 첫 값) */
|
||||
function header(req: Request, name: string): string | undefined {
|
||||
const value = req.headers[name];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대용량 첨부 올리기 (11단계 — 계약 4절 "올리기") — 만들기 → 조각(16MiB, 차례 자유) → 끝내기. 로그인한 직원 자신의 파일만.
|
||||
* 2부 파일 저장소가 같은 API 를 쓴다(purpose).
|
||||
*/
|
||||
@ApiTags('Files')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(FILE_UPLOADS_ROUTE)
|
||||
export class FileUploadController {
|
||||
constructor(private readonly files: FileStorageService) {}
|
||||
|
||||
@Post()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '대용량 첨부 올리기 만들기',
|
||||
description:
|
||||
'형식은 정리한 이름의 확장자로만 본다(대용량 허용 목록 — 일반 첨부 + mp4 · mov · dwg · dxf · psd · ai · 7z, 밖이면 400 FILE_001). 브라우저가 주는 형식(MIME)은 받지 않는다. ' +
|
||||
`그다음 크기(413 FILE_002) → 직원 한도(보관량 · 보내지 않은 파일 ${MAX_UNSENT_FILES_PER_USER}개 — 409 FILE_005) → 서버 총량 · 디스크 여유(507 FILE_006) 차례로 본다. ` +
|
||||
'보관량은 선언한 크기로 미리 잡는다.',
|
||||
})
|
||||
@ApiStandardResponse(LargeFileDto, {
|
||||
status: HttpStatus.CREATED,
|
||||
description: '만든 올리기 (uploading, receivedChunks 비어 있음)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'형식 오류 (이름 1~255자 · 크기 1 이상 정수 · purpose)',
|
||||
)
|
||||
@ApiErrorResponse('FILE_001', '대용량 첨부로 받지 않는 형식')
|
||||
@ApiErrorResponse('FILE_002', '한 파일 한도 초과')
|
||||
@ApiErrorResponse('FILE_005', '직원 보관 한도 초과 — 만들지 않았다')
|
||||
@ApiErrorResponse('FILE_006', '서버 저장 공간이 모자란다 — 만들지 않았다')
|
||||
create(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: FileUploadCreateDto,
|
||||
) {
|
||||
return this.files.create(user.email, {
|
||||
name: dto.name,
|
||||
size: dto.size,
|
||||
purpose: dto.purpose,
|
||||
lastModified: dto.lastModified ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '보내지 않은 내 파일 (만든 때 최근부터, 커서 페이지네이션)',
|
||||
description:
|
||||
'살아 있는 링크(보낸 메일)에 실리지 않은 내 파일 — 올리는 중 · 다 올림(검사 상태 포함). 보내기를 취소해 되돌린 파일도 여기 든다. total 포함.',
|
||||
})
|
||||
@ApiStandardResponse(LargeFilePageDto, { description: '보내지 않은 파일' })
|
||||
@ApiErrorResponse('VAL_001', '깨진 커서 · limit 범위 밖')
|
||||
list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: CursorPaginationQueryDto,
|
||||
) {
|
||||
return this.files.listUnsent(user.email, query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '올리기 · 파일 상태',
|
||||
description:
|
||||
'끊긴 뒤 무엇을 받았는지 본다(receivedChunks). 올리는 중은 마지막 조각 뒤 24시간 지나면 404 다.',
|
||||
})
|
||||
@ApiStandardResponse(LargeFileDto, { description: '파일' })
|
||||
@ApiErrorResponse(
|
||||
'RES_001',
|
||||
'없음 · 남의 것 · 지운 것 · 기간이 지난 것 · UUID 모양이 아닌 id (가리지 않는다)',
|
||||
)
|
||||
get(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', FileIdPipe) id: string,
|
||||
) {
|
||||
return this.files.get(user.email, id);
|
||||
}
|
||||
|
||||
@Put(':id/chunks/:index')
|
||||
@NoStore()
|
||||
// 이 경로만 넉넉하게 — 16MiB 조각은 한 사람이 1분에 수십 번 보내고 사무실은 IP 하나로 나간다 (두 번째 limiter 가 아니다)
|
||||
@Throttle({
|
||||
default: {
|
||||
ttl: FILE_CHUNK_THROTTLE_TTL_MS,
|
||||
limit: FILE_CHUNK_THROTTLE_LIMIT,
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: '조각 하나 올리기 (본문 = 그 조각의 바이트)',
|
||||
description:
|
||||
`index 는 0 ~ chunkCount − 1. Content-Type: application/octet-stream 만(그 밖은 400 VAL_001), Content-Length 필수 = chunkSize(${FILE_CHUNK_SIZE.toLocaleString('ko-KR')} — 마지막 조각만 나머지, 없거나 다르면 읽기 전에 400), 압축(Content-Encoding) 400. ` +
|
||||
'선택 Content-Digest: sha-256=:<base64>: (RFC 9530) — 받은 바이트와 다르면 400 FILE_004(그 조각을 받지 않았다). ' +
|
||||
'다 받아 디스크에 쓴 뒤에만 받은 것으로 적는다. 이미 받은 조각은 쓰지 않고 견준다 — 같으면 200, 다르면 409 FILE_003. 끝낸 올리기 · 같은 조각을 동시에도 409 FILE_003. ' +
|
||||
`요청 수는 이 경로만 IP 마다 1분 ${FILE_CHUNK_THROTTLE_LIMIT}회, 동시에 직원마다 ${FILE_CHUNK_CONCURRENCY_PER_USER}개 · 서버 전체 ${FILE_CHUNK_CONCURRENCY}개 — 넘으면 429 REQ_001(잠시 뒤 같은 조각을 다시). ` +
|
||||
'디스크가 차면 507 FILE_006 — 그 조각은 받지 않았다.',
|
||||
})
|
||||
@ApiConsumes('application/octet-stream')
|
||||
@ApiParam({
|
||||
name: 'index',
|
||||
type: Number,
|
||||
description:
|
||||
'조각 번호 — 0 부터, 앞자리 0 없는 10진수 (아니면 400 VAL_001)',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'Content-Digest',
|
||||
required: false,
|
||||
description: 'sha-256=:<base64>: — 웹은 늘 보낸다',
|
||||
})
|
||||
@ApiBody({ schema: { type: 'string', format: 'binary' } })
|
||||
@ApiStandardResponse(LargeFileDto, {
|
||||
description: '받은 조각을 반영한 올리기',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'형식 · 길이 · 압축 · 번호 범위 · 다이제스트 모양 · 끊긴 본문 (400)',
|
||||
)
|
||||
@ApiErrorResponse('FILE_004', '조각이 보내는 중에 손상됨 — 다시 보낸다')
|
||||
@ApiErrorResponse('RES_001', '없는 올리기')
|
||||
@ApiErrorResponse(
|
||||
'FILE_003',
|
||||
'끝낸 올리기 · 같은 조각을 동시에 · 받은 조각과 내용이 다름 (409)',
|
||||
)
|
||||
@ApiErrorResponse('REQ_001', '요청 수 · 동시 수 한도')
|
||||
@ApiErrorResponse('FILE_006', '디스크가 찼다')
|
||||
chunk(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', FileIdPipe) id: string,
|
||||
// unknown — number 로 적으면 전역 ValidationPipe 가 이 파이프 앞에서 글을 숫자로 바꿔 `1e1` · `0x1` 을 받아 버린다 (file-id.pipe.ts)
|
||||
@Param('index', ChunkIndexPipe) index: unknown,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
// ChunkIndexPipe 가 0 이상 정수로 바꿨다
|
||||
return this.files.writeChunk(user.email, id, index as number, {
|
||||
contentType: header(req, 'content-type'),
|
||||
contentEncoding: header(req, 'content-encoding'),
|
||||
contentLength: header(req, 'content-length'),
|
||||
transferEncoding: header(req, 'transfer-encoding'),
|
||||
contentDigest: header(req, 'content-digest'),
|
||||
body: req,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/completion')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '올리기 끝내기 — 파일로 만들고 악성 검사 차례에 넣는다',
|
||||
description:
|
||||
"조각이 모두 있으면 ready (scanStatus: 'pending' — 검사기를 쓰지 않으면 'unscannable'). 빠진 조각 · 진행 중이면 409 FILE_003(GET 으로 빠진 번호를 본다). 이미 끝냈으면 그대로 200.",
|
||||
})
|
||||
@ApiStandardResponse(LargeFileDto, { description: '다 올린 파일' })
|
||||
@ApiErrorResponse('RES_001', '없는 올리기')
|
||||
@ApiErrorResponse('FILE_003', '빠진 조각 · 끝내기 · 조각 받기가 진행 중')
|
||||
complete(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', FileIdPipe) id: string,
|
||||
) {
|
||||
return this.files.complete(user.email, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '올리기 그만두기 · 보내지 않은 파일 지우기',
|
||||
description:
|
||||
'올리는 중이면 그만두고, 다 올렸어도 살아 있는 링크에 실리지 않았으면 지운다 — 보관량에서 바로 빠진다. ' +
|
||||
"그 파일을 싣고 있던 임시 저장본을 열면 MissingLargeFile(reason: 'deleted')로 온다. 살아 있는 링크에 실린 파일은 409 FILE_003(링크 끊기로).",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.NO_CONTENT,
|
||||
description: '지움 — 본문 없음',
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '없는 파일')
|
||||
@ApiErrorResponse('FILE_003', '살아 있는 링크에 실린 파일 · 끝내는 중')
|
||||
async remove(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', FileIdPipe) id: string,
|
||||
): Promise<void> {
|
||||
await this.files.remove(user.email, id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 대용량 첨부 사용량 · 한도 (계약 4절 FileQuota) — 2부 파일 저장소가 그대로 쓴다(`/api/mail-quota` 에 섞지 않는다) */
|
||||
@ApiTags('Files')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(FILE_QUOTA_ROUTE)
|
||||
export class FileQuotaController {
|
||||
constructor(private readonly files: FileStorageService) {}
|
||||
|
||||
@Get()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '대용량 첨부 사용량 · 한도',
|
||||
description:
|
||||
'쓰기 화면이 올리기 전에 막는 값(한 파일 · 메일 한 통의 개수 · 보관량)과 조각 크기 · 보관 날 수. 사용량은 지금 상태로 계산한다.',
|
||||
})
|
||||
@ApiStandardResponse(FileQuotaDto, { description: '사용량 · 한도' })
|
||||
get(@CurrentUser() user: AuthenticatedUser) {
|
||||
return this.files.quota(user.email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { fileExtension } from '@/shared/utils/file-name';
|
||||
|
||||
/**
|
||||
* 대용량 첨부로 받는 형식 (계약 4절 "한도"의 형식) — **허용 목록**만 받는다(.claude/rules/security.md "파일 업로드").
|
||||
* 일반 첨부의 허용 목록(modules/mail/attachment.policy.ts — 메일 모듈이 짝이 맞는지 테스트로 본다) + 동영상 · 설계 · 디자인 · 7z.
|
||||
* 실행 형식(exe · msi · bat · cmd · js · vbs · scr …) · 디스크 이미지(iso · img · vhd) · 브라우저가 실행할 수 있는 형식(html · svg …)은 넣지 않는다.
|
||||
*
|
||||
* 형식은 정리한 이름의 확장자로만 본다 — 브라우저가 주는 형식(MIME)은 이 경로에서 믿을 근거가 없고(만들기는 JSON 이라 파일이 오지 않는다),
|
||||
* 내려줄 때 늘 octet-stream + attachment + nosniff + sandbox 이며 내용은 악성 검사가 본다 (보안 규칙 "확장자와 MIME 둘 다"의 예외 — 계약 4절 "정한 것" 10).
|
||||
*
|
||||
* ⚠️ 짝: frontend 의 대용량 첨부 허용 형식 상수 — 쓰기 화면이 올리기 전에 같은 목록으로 막는다. 한쪽을 고치면 둘 다
|
||||
*/
|
||||
export const LARGE_FILE_EXTENSIONS: readonly string[] = [
|
||||
// 일반 첨부와 같은 형식
|
||||
'.pdf',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.ppt',
|
||||
'.pptx',
|
||||
'.hwp',
|
||||
'.hwpx',
|
||||
'.txt',
|
||||
'.csv',
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.zip',
|
||||
// 대용량 첨부에서만 — 동영상 · CAD · 디자인 · 압축
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.dwg',
|
||||
'.dxf',
|
||||
'.psd',
|
||||
'.ai',
|
||||
'.7z',
|
||||
];
|
||||
|
||||
const ALLOWED = new Set(LARGE_FILE_EXTENSIONS);
|
||||
|
||||
/** 정리한 이름의 확장자가 대용량 첨부 허용 목록에 있는가 */
|
||||
export function isAllowedLargeFileName(fileName: string): boolean {
|
||||
return ALLOWED.has(fileExtension(fileName));
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { existsSync, mkdtempSync, rmSync, statSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { ChunkLengthMismatchError, isMissingFile } from './file-blob-store';
|
||||
import { LocalDiskBlobStore } from './local-disk-blob-store';
|
||||
|
||||
async function readAll(stream: Readable): Promise<Buffer> {
|
||||
const parts: Buffer[] = [];
|
||||
for await (const part of stream) parts.push(part as Buffer);
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** 조각을 여러 토막으로 흘려보내는 본문 (요청 스트림처럼) */
|
||||
const bodyOf = (data: Buffer, pieceSize = 7) =>
|
||||
Readable.from(
|
||||
Array.from({ length: Math.ceil(data.length / pieceSize) }, (_, index) =>
|
||||
data.subarray(index * pieceSize, (index + 1) * pieceSize),
|
||||
),
|
||||
);
|
||||
|
||||
describe('LocalDiskBlobStore (로컬 디스크 저장)', () => {
|
||||
let root: string;
|
||||
let store: LocalDiskBlobStore;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'blob-store-'));
|
||||
store = new LocalDiskBlobStore(root);
|
||||
});
|
||||
|
||||
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('선언한 크기의 자리에 조각을 차례 없이 제자리에 쓰고, 끝내면 읽을 수 있다 — SHA-256 은 받은 바이트로', async () => {
|
||||
const key = randomUUID();
|
||||
const first = Buffer.from('0123456789');
|
||||
const second = Buffer.from('abcde');
|
||||
await store.begin(key, 15);
|
||||
|
||||
const written = await store.writeChunk(key, 10, 5, bodyOf(second));
|
||||
expect(written.bytes).toBe(5);
|
||||
expect(written.sha256).toEqual(
|
||||
createHash('sha256').update(second).digest(),
|
||||
);
|
||||
await store.writeChunk(key, 0, 10, bodyOf(first));
|
||||
expect(await store.isIncoming(key)).toBe(true);
|
||||
|
||||
await store.complete(key);
|
||||
expect(await store.isIncoming(key)).toBe(false);
|
||||
expect(await store.isStored(key)).toBe(true);
|
||||
expect((await readAll(await store.openRead(key))).toString()).toBe(
|
||||
'0123456789abcde',
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await readAll(await store.openRead(key, { start: 8, end: 11 }))
|
||||
).toString(),
|
||||
).toBe('89ab');
|
||||
});
|
||||
|
||||
it('조각이 선언한 길이보다 짧거나 길면 ChunkLengthMismatchError — 받은 것으로 적지 않게', async () => {
|
||||
const key = randomUUID();
|
||||
await store.begin(key, 10);
|
||||
await expect(
|
||||
store.writeChunk(key, 0, 10, bodyOf(Buffer.from('short'))),
|
||||
).rejects.toBeInstanceOf(ChunkLengthMismatchError);
|
||||
await expect(
|
||||
store.writeChunk(key, 0, 3, bodyOf(Buffer.from('too long'))),
|
||||
).rejects.toBeInstanceOf(ChunkLengthMismatchError);
|
||||
});
|
||||
|
||||
it('본문이 중간에 끊기면 그 오류를 그대로 던진다 (요청이 끊긴 조각)', async () => {
|
||||
const key = randomUUID();
|
||||
await store.begin(key, 10);
|
||||
const broken = new Readable({
|
||||
read() {
|
||||
this.push(Buffer.from('12345'));
|
||||
this.destroy(new Error('aborted'));
|
||||
},
|
||||
});
|
||||
await expect(store.writeChunk(key, 0, 10, broken)).rejects.toThrow(
|
||||
'aborted',
|
||||
);
|
||||
});
|
||||
|
||||
it('없는 파일을 읽으면 ENOENT — 머리글을 쓰기 전에 알 수 있다', async () => {
|
||||
const key = randomUUID();
|
||||
const error = await store.openRead(key).catch((caught: unknown) => caught);
|
||||
expect(isMissingFile(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('지우기는 두 자리 모두 · 이미 없어도 된다', async () => {
|
||||
const incoming = randomUUID();
|
||||
const stored = randomUUID();
|
||||
await store.begin(incoming, 1);
|
||||
await store.begin(stored, 1);
|
||||
await store.complete(stored);
|
||||
|
||||
await store.remove(incoming);
|
||||
await store.remove(stored);
|
||||
await store.remove(randomUUID());
|
||||
expect(await store.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('목록은 서버가 만든 키만 — 단계와 고친 때를 함께', async () => {
|
||||
const a = randomUUID();
|
||||
const b = randomUUID();
|
||||
await store.begin(a, 1);
|
||||
await store.begin(b, 1);
|
||||
await store.complete(b);
|
||||
|
||||
const listed = await store.list();
|
||||
expect(
|
||||
listed
|
||||
.map(({ key, stage }) => ({ key, stage }))
|
||||
.sort((x, y) => x.key.localeCompare(y.key)),
|
||||
).toEqual(
|
||||
[
|
||||
{ key: a, stage: 'incoming' },
|
||||
{ key: b, stage: 'stored' },
|
||||
].sort((x, y) => x.key.localeCompare(y.key)),
|
||||
);
|
||||
expect(
|
||||
listed.every(({ modifiedAt }) => Number.isFinite(modifiedAt.getTime())),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('서버가 만든 키가 아니면 경로를 만들지 않는다 — 경로 탈출 방지', async () => {
|
||||
await expect(store.begin('../../etc/passwd', 1)).rejects.toThrow('키 형식');
|
||||
await expect(store.openRead('ABC')).rejects.toThrow('키 형식');
|
||||
expect(existsSync(join(root, 'incoming'))).toBe(false);
|
||||
});
|
||||
|
||||
it('선언한 크기의 자리는 빈 곳을 쓰지 않는 파일(크기만)이다 · 남은 공간을 알려 준다', async () => {
|
||||
const key = randomUUID();
|
||||
await store.begin(key, 1024 * 1024);
|
||||
expect(statSync(join(root, 'incoming', key)).size).toBe(1024 * 1024);
|
||||
const free = await store.freeBytes();
|
||||
expect(typeof free).toBe('number');
|
||||
expect(free).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rename,
|
||||
stat,
|
||||
statfs,
|
||||
unlink,
|
||||
} from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
import {
|
||||
ChunkLengthMismatchError,
|
||||
isMissingFile,
|
||||
type BlobRange,
|
||||
type FileBlobStore,
|
||||
type StoredBlob,
|
||||
type WrittenChunk,
|
||||
} from './file-blob-store';
|
||||
|
||||
/** 서버가 만든 키(파일 id — 소문자 UUID)만 경로로 만든다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */
|
||||
const BLOB_KEY =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) — 서명 그림 저장과 같다 */
|
||||
const FILE_MODE = 0o640;
|
||||
|
||||
const INCOMING_DIR = 'incoming';
|
||||
const STORED_DIR = 'objects';
|
||||
/** 다 올린 파일을 나누는 하위 폴더 이름 길이 — 한 폴더에 파일이 너무 많이 모이지 않게 (id 의 앞 두 글자) */
|
||||
const SHARD_LENGTH = 2;
|
||||
const SHARD_NAME = /^[0-9a-f]{2}$/;
|
||||
|
||||
function assertKey(key: string): void {
|
||||
if (!BLOB_KEY.test(key)) {
|
||||
// 서버가 만든 키가 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001)
|
||||
throw new Error('파일 저장소 키 형식이 올바르지 않습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkIfExists(path: string): Promise<void> {
|
||||
try {
|
||||
await unlink(path);
|
||||
} catch (error) {
|
||||
if (!isMissingFile(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로컬 디스크 저장 (FILE_BLOB_STORE 의 지금 구현) — `<dir>/incoming/<id>`(올리는 중 — 선언한 크기의 파일에 조각을 제자리에 쓴다) →
|
||||
* 끝내면 같은 볼륨의 `<dir>/objects/<앞 두 글자>/<id>` 로 옮긴다(rename — 크기와 상관없이 바로 끝난다).
|
||||
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 컨트롤러가 형식 · 머리글을 정한다 (.claude/rules/security.md "파일 업로드").
|
||||
* 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다.
|
||||
*/
|
||||
export class LocalDiskBlobStore implements FileBlobStore {
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
async begin(key: string, size: number): Promise<void> {
|
||||
const path = this.incomingPath(key);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
// wx — 같은 이름이 이미 있으면 덮어쓰지 않고 실패한다 (UUID 라 생기지 않지만 지키는 선)
|
||||
const handle = await open(path, 'wx', FILE_MODE);
|
||||
try {
|
||||
// 선언한 크기로 늘려 둔다(빈 곳은 디스크를 쓰지 않는다) — 조각을 차례 없이 제자리에 쓴다
|
||||
await handle.truncate(size);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async writeChunk(
|
||||
key: string,
|
||||
offset: number,
|
||||
expectedBytes: number,
|
||||
source: Readable,
|
||||
): Promise<WrittenChunk> {
|
||||
const handle = await open(this.incomingPath(key), 'r+');
|
||||
const hash = createHash('sha256');
|
||||
let written = 0;
|
||||
try {
|
||||
// 한 조각씩 기다려 쓴다 — 디스크가 느리면 요청 읽기도 멈춘다(메모리에 쌓이지 않는다)
|
||||
for await (const piece of source) {
|
||||
const data = Buffer.isBuffer(piece)
|
||||
? piece
|
||||
: Buffer.from(piece as Uint8Array);
|
||||
if (written + data.length > expectedBytes) {
|
||||
throw new ChunkLengthMismatchError(
|
||||
expectedBytes,
|
||||
written + data.length,
|
||||
);
|
||||
}
|
||||
let done = 0;
|
||||
while (done < data.length) {
|
||||
const { bytesWritten } = await handle.write(
|
||||
data,
|
||||
done,
|
||||
data.length - done,
|
||||
offset + written + done,
|
||||
);
|
||||
done += bytesWritten;
|
||||
}
|
||||
hash.update(data);
|
||||
written += data.length;
|
||||
}
|
||||
if (written !== expectedBytes) {
|
||||
throw new ChunkLengthMismatchError(expectedBytes, written);
|
||||
}
|
||||
// 다 받았다고 적기 전에 디스크에 내려쓴다 — 받은 것으로 적은 조각이 전원이 꺼져도 남게
|
||||
await handle.datasync();
|
||||
return { bytes: written, sha256: hash.digest() };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async complete(key: string): Promise<void> {
|
||||
const target = this.storedPath(key);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await rename(this.incomingPath(key), target);
|
||||
}
|
||||
|
||||
isStored(key: string): Promise<boolean> {
|
||||
return exists(this.storedPath(key));
|
||||
}
|
||||
|
||||
isIncoming(key: string): Promise<boolean> {
|
||||
return exists(this.incomingPath(key));
|
||||
}
|
||||
|
||||
async openRead(key: string, range?: BlobRange): Promise<Readable> {
|
||||
const handle = await open(this.storedPath(key), 'r');
|
||||
// 스트림이 끝나거나 망가지면 파일도 닫는다 (autoClose 기본값)
|
||||
return handle.createReadStream(
|
||||
range ? { start: range.start, end: range.end } : {},
|
||||
);
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
await unlinkIfExists(this.incomingPath(key));
|
||||
await unlinkIfExists(this.storedPath(key));
|
||||
}
|
||||
|
||||
async list(): Promise<StoredBlob[]> {
|
||||
const blobs: StoredBlob[] = [];
|
||||
const incoming = join(this.root, INCOMING_DIR);
|
||||
for (const name of await this.readDir(incoming)) {
|
||||
if (!BLOB_KEY.test(name)) continue;
|
||||
const modifiedAt = await this.modifiedAt(join(incoming, name));
|
||||
if (modifiedAt) blobs.push({ key: name, stage: 'incoming', modifiedAt });
|
||||
}
|
||||
const stored = join(this.root, STORED_DIR);
|
||||
for (const shard of await this.readDir(stored)) {
|
||||
if (!SHARD_NAME.test(shard)) continue;
|
||||
for (const name of await this.readDir(join(stored, shard))) {
|
||||
if (!BLOB_KEY.test(name) || !name.startsWith(shard)) continue;
|
||||
const modifiedAt = await this.modifiedAt(join(stored, shard, name));
|
||||
if (modifiedAt) blobs.push({ key: name, stage: 'stored', modifiedAt });
|
||||
}
|
||||
}
|
||||
return blobs;
|
||||
}
|
||||
|
||||
async freeBytes(): Promise<number | null> {
|
||||
await mkdir(this.root, { recursive: true });
|
||||
const stats = await statfs(this.root);
|
||||
return Number(stats.bavail) * Number(stats.bsize);
|
||||
}
|
||||
|
||||
private incomingPath(key: string): string {
|
||||
assertKey(key);
|
||||
return join(this.root, INCOMING_DIR, key);
|
||||
}
|
||||
|
||||
private storedPath(key: string): string {
|
||||
assertKey(key);
|
||||
return join(this.root, STORED_DIR, key.slice(0, SHARD_LENGTH), key);
|
||||
}
|
||||
|
||||
/** 폴더의 항목 — 폴더가 없으면 빈 목록 */
|
||||
private async readDir(path: string): Promise<string[]> {
|
||||
try {
|
||||
return await readdir(path);
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 고친 때 — 그사이 지워졌으면 null */
|
||||
private async modifiedAt(path: string): Promise<Date | null> {
|
||||
try {
|
||||
return (await stat(path)).mtime;
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import { StoredFileEntity } from './stored-file.entity';
|
||||
|
||||
/**
|
||||
* 받은 조각 하나 (계약 4절 "올리기" 2) — 다 받아 디스크에 쓰고(datasync) 나서야 적는다. 파일 행을 지우면 함께 지워진다.
|
||||
* SHA-256 은 서버가 받은 바이트로 계산한 값 — 같은 조각을 다시 보내면 이것과 견준다(같으면 200, 다르면 FILE_003)
|
||||
*/
|
||||
@Entity({ name: 'stored_file_chunks' })
|
||||
export class StoredFileChunkEntity {
|
||||
@PrimaryColumn({ name: 'file_id', type: 'uuid' })
|
||||
fileId!: string;
|
||||
|
||||
/** 조각 번호 (0 부터) */
|
||||
@PrimaryColumn({ name: 'chunk_index', type: 'integer' })
|
||||
chunkIndex!: number;
|
||||
|
||||
/** 바이트 */
|
||||
@Column({ type: 'integer' })
|
||||
size!: number;
|
||||
|
||||
@Column({ type: 'bytea' })
|
||||
sha256!: Buffer;
|
||||
|
||||
@Column({ name: 'received_at', type: 'timestamptz' })
|
||||
receivedAt!: Date;
|
||||
|
||||
@ManyToOne(() => StoredFileEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'file_id' })
|
||||
file?: StoredFileEntity;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import { BIGINT_NUMBER } from '@/shared/utils/bigint-column';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 정리한 파일 이름 — 코드 포인트 255자 (PostgreSQL varchar 는 글자 수로 센다) */
|
||||
const NAME_COLUMN_LENGTH = 255;
|
||||
/** 상태 · 까닭 · 쓰임 같은 짧은 낱말 */
|
||||
const WORD_COLUMN_LENGTH = 16;
|
||||
/** SHA-256 의 base64 (44자) */
|
||||
const SHA256_BASE64_LENGTH = 44;
|
||||
/** 검사기가 준 판정 이름 (예: Win.Test.EICAR_HDB-1) */
|
||||
const SIGNATURE_COLUMN_LENGTH = 255;
|
||||
|
||||
/** 파일(올리기)의 상태 — 올리는 중 · 끝내는 중 · 다 올림 · 지움(사용자가 지움 · 기간이 지남) */
|
||||
export type StoredFileState = 'uploading' | 'completing' | 'ready' | 'removed';
|
||||
|
||||
/**
|
||||
* 대용량 첨부 파일 하나 (계약 4절 — 올리기 id = 다 올린 뒤의 파일 id, 바뀌지 않는다). 바이트는 저장 포트(FILE_BLOB_STORE)에, 여기는 정보만 둔다.
|
||||
*
|
||||
* 보관 기간은 `expires_at` 하나로 본다 — 상태 · 다 올린 때 · 임시 저장에 실린 때 · 링크(공유 모듈이 알려 준다)로 계산해 바뀔 때마다 적는다
|
||||
* (file-retention.ts). 보관량 · 보내지 않은 파일 · 정리가 모두 이 값으로 "요청 때 상태"를 본다 — 정리 작업이 늦어도 바로 줄어든다.
|
||||
* - `linked_at` · `linked_until`: 공유 모듈이 적는 링크 상태 (취소하지 않은 링크의 가장 늦게 만든 때 · 끊지 않은 링크의 가장 늦은 끝)
|
||||
* - 위험 판정 파일은 바이트를 곧 지우고(`bytes_deleted_at`) 기록은 판정 뒤 30일(또는 링크가 끝날 때까지) 남긴다 — 보관량에는 넣지 않는다
|
||||
*/
|
||||
@Entity({ name: 'stored_files' })
|
||||
@Index('IDX_stored_files_owner_created', ['ownerEmail', 'createdAt'])
|
||||
@Index('IDX_stored_files_alive_expires', ['expiresAt'], {
|
||||
where: `"state" <> 'removed'`,
|
||||
})
|
||||
@Index('IDX_stored_files_scan_pending', ['scanNextAt'], {
|
||||
where: `"scan_status" = 'pending'`,
|
||||
})
|
||||
@Index('IDX_stored_files_removed', ['removedAt'], {
|
||||
where: `"state" = 'removed'`,
|
||||
})
|
||||
export class StoredFileEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'owner_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
ownerEmail!: string;
|
||||
|
||||
/** 쓰임 — 'mail' (2부 파일 저장소가 더한다) */
|
||||
@Column({ type: 'varchar', length: WORD_COLUMN_LENGTH })
|
||||
purpose!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: NAME_COLUMN_LENGTH })
|
||||
name!: string;
|
||||
|
||||
/** 선언한 크기 (바이트) */
|
||||
@Column({ type: 'bigint', transformer: BIGINT_NUMBER })
|
||||
size!: number;
|
||||
|
||||
@Column({ name: 'chunk_size', type: 'integer' })
|
||||
chunkSize!: number;
|
||||
|
||||
@Column({ name: 'chunk_count', type: 'integer' })
|
||||
chunkCount!: number;
|
||||
|
||||
/** 만들 때 받은 File.lastModified (ms) */
|
||||
@Column({
|
||||
name: 'last_modified',
|
||||
type: 'bigint',
|
||||
nullable: true,
|
||||
transformer: BIGINT_NUMBER,
|
||||
})
|
||||
lastModified!: number | null;
|
||||
|
||||
@Column({ type: 'varchar', length: WORD_COLUMN_LENGTH })
|
||||
state!: StoredFileState;
|
||||
|
||||
/** 지운 까닭 — 'deleted'(사용자) · 'expired'(기간) */
|
||||
@Column({
|
||||
name: 'removed_reason',
|
||||
type: 'varchar',
|
||||
length: WORD_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
removedReason!: 'deleted' | 'expired' | null;
|
||||
|
||||
/** 받은 0번 조각의 SHA-256 (base64) */
|
||||
@Column({
|
||||
name: 'first_chunk_sha256',
|
||||
type: 'varchar',
|
||||
length: SHA256_BASE64_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
firstChunkSha256!: string | null;
|
||||
|
||||
/** 다 올린 뒤의 검사 결과 — 올리는 중은 null */
|
||||
@Column({
|
||||
name: 'scan_status',
|
||||
type: 'varchar',
|
||||
length: WORD_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
scanStatus!: 'pending' | 'clean' | 'infected' | 'unscannable' | null;
|
||||
|
||||
/** 검사기가 준 판정 이름 (위험 · 검사하지 못함) */
|
||||
@Column({
|
||||
name: 'scan_signature',
|
||||
type: 'varchar',
|
||||
length: SIGNATURE_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
scanSignature!: string | null;
|
||||
|
||||
/** 이어서 실패한 검사 수 (검사기에 닿지 못함 등) */
|
||||
@Column({ name: 'scan_attempts', type: 'integer', default: 0 })
|
||||
scanAttempts!: number;
|
||||
|
||||
/** 다음 검사 시각 — 검사를 기다리는 동안만 */
|
||||
@Column({ name: 'scan_next_at', type: 'timestamptz', nullable: true })
|
||||
scanNextAt!: Date | null;
|
||||
|
||||
@Column({ name: 'scanned_at', type: 'timestamptz', nullable: true })
|
||||
scannedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ name: 'last_chunk_at', type: 'timestamptz', nullable: true })
|
||||
lastChunkAt!: Date | null;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt!: Date | null;
|
||||
|
||||
/**
|
||||
* 끝내기를 시작한 때 — 끝내는 중(completing)일 때만 값이 있다. 정리 작업은 이것이 오래된 끝내기만 멈춘 것으로 본다
|
||||
* (마지막 조각의 때로 보면 방금 시작한 끝내기를 되돌릴 수 있었다 — 2026-10-30 리뷰 L4)
|
||||
*/
|
||||
@Column({
|
||||
name: 'completion_started_at',
|
||||
type: 'timestamptz',
|
||||
nullable: true,
|
||||
})
|
||||
completionStartedAt!: Date | null;
|
||||
|
||||
/** 임시 저장에 마지막으로 실린 때 */
|
||||
@Column({ name: 'draft_attached_at', type: 'timestamptz', nullable: true })
|
||||
draftAttachedAt!: Date | null;
|
||||
|
||||
/** 취소하지 않은 링크 가운데 가장 늦게 만든 때 (공유 모듈이 적는다) */
|
||||
@Column({ name: 'linked_at', type: 'timestamptz', nullable: true })
|
||||
linkedAt!: Date | null;
|
||||
|
||||
/** 취소 · 끊지 않은 링크 가운데 가장 늦게 끝나는 때 (공유 모듈이 적는다) — 지금보다 뒤면 살아 있는 링크에 실린 파일 */
|
||||
@Column({ name: 'linked_until', type: 'timestamptz', nullable: true })
|
||||
linkedUntil!: Date | null;
|
||||
|
||||
/** 이대로 두면 지워지는 때 (file-retention.ts) */
|
||||
@Column({ name: 'expires_at', type: 'timestamptz' })
|
||||
expiresAt!: Date;
|
||||
|
||||
@Column({ name: 'removed_at', type: 'timestamptz', nullable: true })
|
||||
removedAt!: Date | null;
|
||||
|
||||
/** 저장소의 바이트를 지운 때 — 정리 작업 · 위험 판정 · 지우기 */
|
||||
@Column({ name: 'bytes_deleted_at', type: 'timestamptz', nullable: true })
|
||||
bytesDeletedAt!: Date | null;
|
||||
}
|
||||
@@ -138,28 +138,9 @@ describe('메일 첨부 형식 정책', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('경로를 버리고 파일명만 남긴다 (경로 탈출 방지)', () => {
|
||||
describe('sanitizeFileName (shared/utils/file-name.ts 를 다시 내보낸다)', () => {
|
||||
it('메일 쪽 이름으로도 같은 정리를 쓴다', () => {
|
||||
expect(sanitizeFileName('../../etc/passwd')).toBe('passwd');
|
||||
expect(sanitizeFileName('C:\\Users\\kim\\보고서.pdf')).toBe('보고서.pdf');
|
||||
});
|
||||
|
||||
it('제어 문자와 방향 제어 문자를 지운다 (확장자 뒤집기 위장 방지)', () => {
|
||||
expect(sanitizeFileName('a\r\nb.txt')).toBe('ab.txt');
|
||||
expect(sanitizeFileName('invoice\u202Etxt.exe')).toBe('invoicetxt.exe');
|
||||
});
|
||||
|
||||
it('너무 긴 이름은 확장자를 남기고 자른다', () => {
|
||||
const long = `${'가'.repeat(300)}.xlsx`;
|
||||
const result = sanitizeFileName(long);
|
||||
|
||||
expect(Array.from(result)).toHaveLength(255);
|
||||
expect(result.endsWith('.xlsx')).toBe(true);
|
||||
});
|
||||
|
||||
it('남는 것이 없으면 기본 이름을 쓴다', () => {
|
||||
expect(sanitizeFileName('')).toBe('첨부파일');
|
||||
expect(sanitizeFileName('dir/')).toBe('첨부파일');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { extname } from 'path';
|
||||
|
||||
import { MAX_FILE_NAME_LENGTH } from './mail.constants';
|
||||
import {
|
||||
isControlOrBidiChar,
|
||||
sanitizeFileName,
|
||||
} from '@/shared/utils/file-name';
|
||||
|
||||
/** 파일명 정리는 shared/utils/file-name.ts 로 옮겼다(11단계 — 대용량 첨부와 함께 쓴다). 메일 쪽 이름을 그대로 쓰게 다시 내보낸다 */
|
||||
export { sanitizeFileName };
|
||||
|
||||
/**
|
||||
* 메일 첨부 형식·크기 정책 (순수 함수) — `modules/upload/upload.config.ts` 와 같은 원칙을 따른다.
|
||||
@@ -160,9 +166,6 @@ const ATTACHMENT_TYPES: ReadonlyMap<string, AttachmentTypeRule> = new Map([
|
||||
],
|
||||
]);
|
||||
|
||||
/** 정리 후 이름이 비면 쓰는 이름 */
|
||||
const FALLBACK_FILE_NAME = '첨부파일';
|
||||
|
||||
/** 'Text/Plain; charset=UTF-8' → 'text/plain' */
|
||||
export function normalizeMimeType(mimeType: string): string {
|
||||
return mimeType.split(';')[0].trim().toLowerCase();
|
||||
@@ -211,33 +214,6 @@ export function resolveAttachmentsTotalLimit(
|
||||
return engineKnown ? Math.min(configuredBytes, engineBytes) : configuredBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 표시·헤더용으로 파일명을 정리한다.
|
||||
* - 경로 구분자 앞부분을 버린다 (클라이언트가 보낸 경로를 쓰지 않는다)
|
||||
* - 제어 문자와 방향 제어 문자(확장자를 거꾸로 보이게 하는 U+202E 등)를 지운다
|
||||
* - 길이를 제한하되 확장자는 남긴다
|
||||
*/
|
||||
export function sanitizeFileName(rawName: string): string {
|
||||
const lastSegment = rawName.split(/[\\/]/).pop() ?? '';
|
||||
const cleaned = Array.from(lastSegment)
|
||||
.filter((char) => !isControlOrBidiChar(char.codePointAt(0) ?? 0))
|
||||
.join('')
|
||||
.trim();
|
||||
|
||||
const chars = Array.from(cleaned);
|
||||
if (chars.length === 0) return FALLBACK_FILE_NAME;
|
||||
if (chars.length <= MAX_FILE_NAME_LENGTH) return cleaned;
|
||||
|
||||
const extensionChars = Array.from(extname(cleaned));
|
||||
if (extensionChars.length >= MAX_FILE_NAME_LENGTH) {
|
||||
return chars.slice(0, MAX_FILE_NAME_LENGTH).join('');
|
||||
}
|
||||
return (
|
||||
chars.slice(0, MAX_FILE_NAME_LENGTH - extensionChars.length).join('') +
|
||||
extensionChars.join('')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문 파일 이름의 제목 부분 최대 글자 수(코드 포인트). 한글(UTF-8 3바이트)뿐이면 `.eml` 까지 240+4 바이트라
|
||||
* 파일 시스템의 이름 한도(255바이트) 안에 든다. 4바이트 문자(이모지 등)가 많으면 넘을 수 있는데,
|
||||
@@ -293,15 +269,3 @@ export function messageSourceFileName(subject: string): string {
|
||||
).join('');
|
||||
return `${base || SOURCE_FALLBACK_NAME}${SOURCE_EXTENSION}`;
|
||||
}
|
||||
|
||||
/** C0·C1 제어 문자, DEL, 방향 제어 문자 */
|
||||
function isControlOrBidiChar(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint <= 0x1f ||
|
||||
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
||||
codePoint === 0x200e ||
|
||||
codePoint === 0x200f ||
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
||||
(codePoint >= 0x2066 && codePoint <= 0x2069)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
|
||||
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import type { SharedFileStatus } from '@/modules/file-sharing/file-sharing.types';
|
||||
import {
|
||||
LargeFileDto,
|
||||
MissingLargeFileDto,
|
||||
} from '@/modules/file-storage/dto/file-upload.dto';
|
||||
import type {
|
||||
LargeFile,
|
||||
MissingLargeFile,
|
||||
} from '@/modules/file-storage/file-storage.types';
|
||||
import {
|
||||
CONTACT_EMAIL_LABELS,
|
||||
type ContactEmailLabel,
|
||||
@@ -19,6 +28,7 @@ import type {
|
||||
MessageAttachment,
|
||||
MessageDeletionResult,
|
||||
MessageDetail,
|
||||
MessageLargeFile,
|
||||
MessagesPatchResult,
|
||||
MessageSummary,
|
||||
PendingSend,
|
||||
@@ -388,6 +398,79 @@ export class MessageDetailDto
|
||||
type: [MessageAttachmentDto],
|
||||
})
|
||||
attachments!: MessageAttachmentDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'우리 서버가 넣은 대용량 첨부 덩이의 파일 (11단계) — 표시 주석 사이의 우리 링크이고 링크 주인이 이 메일의 보낸 사람일 때만, 없으면 빈 배열. ' +
|
||||
'그때 htmlBody 안 그 덩이의 바깥 요소에 data-ara-large-files 가 붙는다',
|
||||
type: () => [MessageLargeFileDto],
|
||||
})
|
||||
largeFiles!: MessageLargeFileDto[];
|
||||
}
|
||||
|
||||
const SHARED_FILE_STATUSES: SharedFileStatus[] = [
|
||||
'available',
|
||||
'unscannable',
|
||||
'scanning',
|
||||
'infected',
|
||||
'limit-reached',
|
||||
'revoked',
|
||||
'expired',
|
||||
'missing',
|
||||
];
|
||||
|
||||
export class MessageLargeFileDownloadsDto {
|
||||
@ApiProperty({ description: '받은 수 (0번째 바이트부터 받기 시작한 수)' })
|
||||
count!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막으로 받은 때',
|
||||
nullable: true,
|
||||
type: String,
|
||||
})
|
||||
lastDownloadedAt!: string | null;
|
||||
}
|
||||
|
||||
/** 메일 속 대용량 첨부 하나 (계약 4절 MessageLargeFile) */
|
||||
export class MessageLargeFileDto implements MessageLargeFile {
|
||||
@ApiProperty({ description: '링크 id' })
|
||||
linkId!: string;
|
||||
|
||||
@ApiProperty({ description: '파일 id — 받기 경로에 쓴다' })
|
||||
fileId!: string;
|
||||
|
||||
@ApiProperty({ description: '서버 기록의 이름 — 메일 글이 아니다' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '바이트' })
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({ description: '파일 상태', enum: SHARED_FILE_STATUSES })
|
||||
status!: SharedFileStatus;
|
||||
|
||||
@ApiProperty({ description: '링크가 끝나는 때' })
|
||||
expiresAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '링크를 끊은 때 (보낸 사람 · 운영) — 끊지 않았으면 null',
|
||||
nullable: true,
|
||||
type: String,
|
||||
example: '2026-10-22T05:12:00.000Z',
|
||||
})
|
||||
revokedAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'공개 화면 주소 <SHARE_PUBLIC_URL>/s/<토큰> — 본문 덩이의 그 주소 (같은 링크의 파일은 같은 주소)',
|
||||
})
|
||||
url!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '보는 사람이 그 링크를 만든 사람일 때만 — 아니면 null',
|
||||
nullable: true,
|
||||
type: MessageLargeFileDownloadsDto,
|
||||
})
|
||||
downloads!: MessageLargeFileDownloadsDto | null;
|
||||
}
|
||||
|
||||
export class MessagePageDto {
|
||||
@@ -520,6 +603,7 @@ export class MessageDeletionResultDto implements MessageDeletionResult {
|
||||
}
|
||||
|
||||
/** 계약 2절 "임시 저장"의 Draft — 쓰기 화면이 이어 쓴다 */
|
||||
@ApiExtraModels(LargeFileDto, MissingLargeFileDto)
|
||||
export class DraftDto implements Draft {
|
||||
@ApiProperty({
|
||||
description:
|
||||
@@ -580,6 +664,20 @@ export class DraftDto implements Draft {
|
||||
example: '2026-10-27T06:12:00.000Z',
|
||||
})
|
||||
updatedAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'대용량 첨부 (11단계) — 초안 머리글의 파일 가운데 그 직원의 것만: 있으면 LargeFile(올리는 중 포함), 지웠거나 기간이 지났으면 MissingLargeFile. ' +
|
||||
'취소해 되돌린 발송은 그 링크의 파일 (본문의 덩이는 뗐다)',
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(LargeFileDto) },
|
||||
{ $ref: getSchemaPath(MissingLargeFileDto) },
|
||||
],
|
||||
},
|
||||
})
|
||||
largeFiles!: (LargeFile | MissingLargeFile)[];
|
||||
}
|
||||
|
||||
/** 계약 2절 "임시 저장"의 DraftSaved */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
@@ -5,14 +6,21 @@ import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator';
|
||||
import { FILE_ID_PATTERN } from '@/modules/file-storage/file-id.pipe';
|
||||
import { LARGE_FILE_MAX_COUNT_LIMIT } from '@/modules/file-storage/file-storage.settings';
|
||||
|
||||
import {
|
||||
MAX_ATTACHMENT_LIST_LENGTH,
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
@@ -163,6 +171,40 @@ export class ComposeContentDto {
|
||||
@IsOptional()
|
||||
@Matches(MAIL_ID_PATTERN, { message: '답장할 메일을 다시 선택해 주세요.' })
|
||||
inReplyTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
`이 메일에 새로 싣는 내 대용량 파일 — 올리기 id(POST /api/file-uploads 의 id), 겹치지 않게, ${LARGE_FILE_MAX_COUNT_LIMIT}개까지(남용 방지 — 실제 한도는 GET /api/file-quota 의 maxFilesPerMessage, 넘으면 보낼 때 400 VAL_001). ` +
|
||||
'보낼 때는 다 올렸고 위험이 아니어야 하고(아니면 400 MAIL_016), 임시 저장은 올리는 중이어도 된다(11단계 — 계약 4절 "발송 · 임시 저장과 함께")',
|
||||
type: [String],
|
||||
example: ['0f8fad5b-d9cb-469f-a165-70867728950e'],
|
||||
})
|
||||
@IsOptional()
|
||||
// 형식 → 개수 → 겹침 → 항목 모양 (적은 차례대로 돈다)
|
||||
@applyDecorators(
|
||||
IsArray({ message: '대용량 첨부 목록 형식이 올바르지 않습니다.' }),
|
||||
ArrayMaxSize(LARGE_FILE_MAX_COUNT_LIMIT, {
|
||||
message: `대용량 첨부는 한 메일에 ${LARGE_FILE_MAX_COUNT_LIMIT}개를 넘을 수 없습니다.`,
|
||||
}),
|
||||
ArrayDistinct({ message: '같은 대용량 첨부를 두 번 실었습니다.' }),
|
||||
Matches(FILE_ID_PATTERN, {
|
||||
each: true,
|
||||
message: '대용량 첨부를 다시 선택해 주세요.',
|
||||
}),
|
||||
)
|
||||
largeFileIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'body(텍스트)에서 인용이 시작하는 자리 = 대용량 첨부 덩이를 넣을 자리 — 0 ~ body.length(JS 문자열 위치)이고 줄의 처음(0 이거나 바로 앞이 줄바꿈). ' +
|
||||
'아니면 400 VAL_001. 없으면 덩이는 맨 끝이다. 서식 본문은 인용 바로 앞의 빈 표시 <div data-ara-quote-start></div> 로 알린다 (11단계)',
|
||||
example: 1234,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt({ message: '인용 시작 자리 형식이 올바르지 않습니다.' })
|
||||
@Min(0, { message: '인용 시작 자리 형식이 올바르지 않습니다.' })
|
||||
@Max(MAX_BODY_LENGTH, { message: '인용 시작 자리 형식이 올바르지 않습니다.' })
|
||||
bodyQuoteStart?: number;
|
||||
}
|
||||
|
||||
/** POST /api/messages — 계약 2절 "발송 본문" */
|
||||
|
||||
@@ -459,3 +459,33 @@ describe('sanitizeMailHtml', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeMailHtml — 대용량 첨부 덩이 표시 (11단계)', () => {
|
||||
it('서비스가 넘긴 표시 값의 div 에만 data-ara-large-files 를 붙인다 — 받은 HTML 의 같은 속성 · 다른 값은 지운다', () => {
|
||||
const html =
|
||||
'<div data-ara-large-files="1"><p>흉내</p></div>' +
|
||||
'<div data-ara-lf="guess"><p>맞히려는 값</p></div>' +
|
||||
'<div data-ara-lf="secret-marker" style="color:#101828"><table><tbody><tr><td>덩이</td></tr></tbody></table></div>';
|
||||
const { html: sanitized } = sanitizeMailHtml(html, new Map(), {
|
||||
largeFilesMarker: 'secret-marker',
|
||||
});
|
||||
expect(sanitized).toBe(
|
||||
'<div><p>흉내</p></div><div><p>맞히려는 값</p></div>' +
|
||||
'<div style="color:#101828" data-ara-large-files><table><tbody><tr><td>덩이</td></tr></tbody></table></div>',
|
||||
);
|
||||
});
|
||||
|
||||
it('표시 값을 주지 않으면 붙이지 않는다', () => {
|
||||
const { html } = sanitizeMailHtml(
|
||||
'<div data-ara-lf="x" data-ara-large-files><p>글</p></div>',
|
||||
);
|
||||
expect(html).toBe('<div><p>글</p></div>');
|
||||
});
|
||||
|
||||
it('div 의 배경 그림(외부 주소)은 지금처럼 차단으로 알린다', () => {
|
||||
const result = sanitizeMailHtml(
|
||||
'<div style="background:url(https://track.example/p.gif)">x</div>',
|
||||
);
|
||||
expect(result.hasBlockedImages).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,8 +190,17 @@ const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
|
||||
del: ['datetime'],
|
||||
ins: ['datetime'],
|
||||
details: ['open'],
|
||||
// 서버가 확인한 대용량 첨부 덩이의 바깥 요소 표시(11단계) — 받은 HTML 의 같은 속성은 transformTags.div 가 늘 지우고, 서비스가 넘긴 표시에만 붙인다
|
||||
div: ['data-ara-large-files'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 대용량 첨부 덩이 표시 (계약 4절 "우리 웹메일에서 읽기") — 서비스가 확인한 덩이를 `<div data-ara-lf="<요청마다 무작위 값>">` 로 감싸 넘기면
|
||||
* 그 div 에만 `data-ara-large-files` 를 붙인다. 받은 HTML 에 있던 두 속성은 지운다 — 그래서 서버만 붙일 수 있다
|
||||
*/
|
||||
const LARGE_FILES_ATTRIBUTE = 'data-ara-large-files';
|
||||
export const LARGE_FILES_MARKER_ATTRIBUTE = 'data-ara-lf';
|
||||
|
||||
const BOX_SIDES = ['', '-top', '-right', '-bottom', '-left'];
|
||||
|
||||
/** 인라인 style 에서 남기는 표시용 속성 — position·z-index·content 등은 넣지 않는다 */
|
||||
@@ -366,6 +375,11 @@ export interface SanitizeMailHtmlOptions {
|
||||
* 없으면 지금처럼 외부 이미지를 지운다
|
||||
*/
|
||||
readonly externalImageSrc?: (url: URL) => string;
|
||||
/**
|
||||
* 서버가 확인한 대용량 첨부 덩이의 표시 값 (11단계) — 서비스가 그 덩이를 `<div data-ara-lf="<이 값>">` 로 감싸 넘긴다(요청마다 무작위 — 보낸 사람이 맞힐 수 없다).
|
||||
* 이 값과 같은 div 에만 `data-ara-large-files` 를 붙인다. 없으면 붙이지 않는다
|
||||
*/
|
||||
readonly largeFilesMarker?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -419,6 +433,17 @@ export function sanitizeMailHtml(
|
||||
disallowedTagsMode: 'discard',
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }),
|
||||
div: (tagName, attribs) => {
|
||||
if (loadsExternalImage(attribs)) markBlocked();
|
||||
const marked =
|
||||
options.largeFilesMarker !== undefined &&
|
||||
attribs[LARGE_FILES_MARKER_ATTRIBUTE] === options.largeFilesMarker;
|
||||
const next: sanitizeHtml.Attributes = { ...attribs };
|
||||
delete next[LARGE_FILES_MARKER_ATTRIBUTE];
|
||||
delete next[LARGE_FILES_ATTRIBUTE];
|
||||
if (marked) next[LARGE_FILES_ATTRIBUTE] = '';
|
||||
return { tagName, attribs: next };
|
||||
},
|
||||
img: (tagName, attribs) => {
|
||||
const next = resolveImage(attribs, imageContext);
|
||||
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지) — 이름을 '' 로 바꾸면 허용 목록에 없어
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
import { QUOTE_START_ATTRIBUTE } from '@/modules/mail/large-file-block';
|
||||
import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html';
|
||||
import { parseSignatureImageId, signatureImagePath } from './signature-images';
|
||||
|
||||
@@ -91,6 +92,12 @@ const IMAGE_WIDTH = /^[1-9]\d{0,2}$/;
|
||||
export interface OutgoingSanitizeOptions {
|
||||
/** 남겨도 되는 서명 그림 id — 보내는 사람 자신의 그림(소문자 UUID). 없으면 그림을 하나도 남기지 않는다 */
|
||||
readonly signatureImageIds?: ReadonlySet<string>;
|
||||
/**
|
||||
* 쓰기 화면의 인용 경계 표시(`<div data-ara-quote-start></div>` — 11단계 대용량 첨부의 덩이 자리)를 남긴다 — 발송 · 임시 저장의 준비만.
|
||||
* div 에만, 속성 하나짜리 여는 태그(`<div data-ara-quote-start>`)로 — 그 밖의 data-* 는 지금처럼 지운다.
|
||||
* 서비스가 첫 표시 자리를 읽고 표시를 모두 지운다 (large-file-block.ts 의 extractQuoteStart — 받는 사람 · 임시 저장본 · 지문에 들지 않는다)
|
||||
*/
|
||||
readonly keepQuoteStart?: boolean;
|
||||
}
|
||||
|
||||
// ── style 값 형식 ───────────────────────────────────────────────────────────
|
||||
@@ -297,9 +304,12 @@ export function sanitizeOutgoingHtml(
|
||||
options: OutgoingSanitizeOptions = {},
|
||||
): string {
|
||||
const signatureImageIds = options.signatureImageIds ?? NO_SIGNATURE_IMAGES;
|
||||
const keepQuoteStart = options.keepQuoteStart === true;
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: ALLOWED_TAGS,
|
||||
allowedAttributes: ALLOWED_ATTRIBUTES,
|
||||
allowedAttributes: keepQuoteStart
|
||||
? { ...ALLOWED_ATTRIBUTES, div: [QUOTE_START_ATTRIBUTE] }
|
||||
: ALLOWED_ATTRIBUTES,
|
||||
allowedSchemes: [],
|
||||
allowedSchemesByTag: { a: ['http', 'https', 'mailto'] },
|
||||
allowProtocolRelative: false,
|
||||
@@ -307,6 +317,11 @@ export function sanitizeOutgoingHtml(
|
||||
nonTextTags: NON_TEXT_TAGS,
|
||||
disallowedTagsMode: 'discard',
|
||||
transformTags: {
|
||||
// 인용 경계 표시는 속성 하나만 남긴다(style 등은 뗀다) — 서비스가 글자 그대로 찾는다
|
||||
div: (tagName, attribs) =>
|
||||
keepQuoteStart && QUOTE_START_ATTRIBUTE in attribs
|
||||
? { tagName, attribs: { [QUOTE_START_ATTRIBUTE]: '' } }
|
||||
: { tagName, attribs },
|
||||
a: (_tagName, attribs) => keepSafeLink(attribs),
|
||||
ol: (tagName, attribs) => ({
|
||||
tagName,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MAX_BODY_VALUE_BYTES,
|
||||
MAX_ENVELOPE_RESUBMITS,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { SendNotStartedException } from '@/modules/mail/mail.provider';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
DraftContent,
|
||||
@@ -31,7 +32,11 @@ import { decodeCursor, encodeCursor } from './jmap-cursor';
|
||||
import { JmapFilterRunner } from './jmap-filter-run';
|
||||
import { JmapFilterScriptStore } from './jmap-filter-script';
|
||||
import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch';
|
||||
import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider';
|
||||
import {
|
||||
JmapMailProvider,
|
||||
buildThreadHeaders,
|
||||
parseLargeFileIds,
|
||||
} from './jmap-mail.provider';
|
||||
import { MAILBOX_PROPERTIES } from './jmap.mapper';
|
||||
import type { JmapInvocation } from './jmap.types';
|
||||
|
||||
@@ -3142,6 +3147,26 @@ describe('JmapMailProvider', () => {
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('보내는 메일에는 대용량 첨부 머리글(X-Ara-Large-Files)을 싣지 않는다 — 덩이(본문)만 간다 (11단계 — 임시 저장 전용 머리글)', async () => {
|
||||
const { provider, recorded } = fakeClient([prepared, fits, sent, queued]);
|
||||
const html =
|
||||
'<p>보냅니다.</p><!--ara-large-files:v1--><table><tr><td>대용량 첨부</td></tr></table><!--/ara-large-files-->';
|
||||
|
||||
// 임시 저장 본문이 섞여 들어와도(largeFileIds) 보내는 길은 머리글을 만들지 않는다
|
||||
await provider.sendMessage(ARA, {
|
||||
...outgoing,
|
||||
htmlBody: html,
|
||||
largeFileIds: ['0f8fad5b-d9cb-469f-a165-70867728950e'],
|
||||
} as OutgoingMessage);
|
||||
|
||||
const draft = (argsOf(recorded[2], 0).create as Record<string, unknown>)
|
||||
.draft as Record<string, unknown>;
|
||||
expect(
|
||||
Object.keys(draft).filter((key) => /x-ara-large-files/i.test(key)),
|
||||
).toEqual([]);
|
||||
expect(draft.bodyValues).toMatchObject({ html: { value: html } });
|
||||
});
|
||||
|
||||
/** 계약 2절 "받는 사람별 결과" (6단계 앞 수정) — 엔진이 그 자리에서 거절한 받는 사람 */
|
||||
describe('받는 사람별 결과', () => {
|
||||
// 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다
|
||||
@@ -3186,6 +3211,19 @@ describe('JmapMailProvider', () => {
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => clock.now);
|
||||
};
|
||||
|
||||
it('준비에 시간을 다 써 보내기를 시작하지 않으면 SendNotStartedException(MAIL_001) — 아무것도 만들거나 제출하지 않았다 (11단계 리뷰 L6 — 대용량 첨부 링크를 취소하는 표시)', async () => {
|
||||
const fake = fakeClient([prepared]);
|
||||
// 준비 50초 — 남은 5초는 보낼 최소 시간(10초)보다 적다
|
||||
elapseOnCalls(fake, [50_000], { now: 1_000_000 });
|
||||
|
||||
const error = await fake.provider
|
||||
.sendMessage(ARA, textOnly)
|
||||
.catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(SendNotStartedException);
|
||||
expect((error as BusinessException).code).toBe('MAIL_001');
|
||||
expect(fake.recorded).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('제출 다음에 따로 읽는다 — 제출 id 로 deliveryStatus 만, 짧은 제한 시간(5초)으로 (같은 요청 안의 참조는 엔진이 풀지 못한다)', async () => {
|
||||
const { provider, recorded } = fakeClient([prepared, sent, queued]);
|
||||
|
||||
@@ -3653,6 +3691,8 @@ describe('JmapMailProvider', () => {
|
||||
.sendMessage(ARA, withTypos)
|
||||
.catch((caught: unknown) => caught as BusinessException);
|
||||
expect((error as BusinessException).code).toBe('MAIL_001');
|
||||
// 시작하지 않은 실패의 표시는 형식이다 — 대용량 첨부의 링크를 취소한다 (context 는 로그 전용)
|
||||
expect(error).toBeInstanceOf(SendNotStartedException);
|
||||
expect((error as BusinessException).context).toMatchObject({
|
||||
reason: 'send-budget-exhausted',
|
||||
resubmits: 0,
|
||||
@@ -5257,6 +5297,74 @@ describe('JmapMailProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('임시 저장의 대용량 첨부 머리글 (X-Ara-Large-Files — 11단계)', () => {
|
||||
const content: DraftContent = {
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '쓰는 중',
|
||||
body: '본문',
|
||||
attachments: [],
|
||||
};
|
||||
const prepared: JmapInvocation[] = [
|
||||
['Mailbox/get', { list: stalwartMailboxes }, 'm'],
|
||||
[
|
||||
'Identity/get',
|
||||
{ list: [{ id: 'b', name: '김아라', email: ARA }] },
|
||||
'i',
|
||||
],
|
||||
];
|
||||
const created: JmapInvocation[] = [
|
||||
[
|
||||
'Email/set',
|
||||
{ oldState: 's1', newState: 's9', created: { draft: { id: 'new1' } } },
|
||||
'c',
|
||||
],
|
||||
];
|
||||
const readBack: JmapInvocation[] = [
|
||||
[
|
||||
'Email/get',
|
||||
{ list: [{ id: 'new1', receivedAt: '2026-10-30T01:00:00Z' }] },
|
||||
'g',
|
||||
],
|
||||
];
|
||||
const draftOf = (recorded: CallRecord[]) =>
|
||||
(argsOf(recorded[1]).create as Record<string, Record<string, unknown>>)
|
||||
.draft;
|
||||
|
||||
it('파일 id 를 머리글 하나에 콤마로 쓴다 (올리는 중인 올리기 id 도) — 덩이는 넣지 않는다', async () => {
|
||||
const { provider, recorded } = fakeClient([prepared, created, readBack]);
|
||||
|
||||
await provider.saveDraft(ARA, {
|
||||
...content,
|
||||
largeFileIds: [
|
||||
'0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||
],
|
||||
});
|
||||
|
||||
const draft = draftOf(recorded);
|
||||
expect(draft['header:X-Ara-Large-Files:asText']).toBe(
|
||||
'0f8fad5b-d9cb-469f-a165-70867728950e, 7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||
);
|
||||
expect(draft.bodyValues).toEqual({ body: { value: '본문' } });
|
||||
});
|
||||
|
||||
it('파일이 없으면 머리글을 쓰지 않는다', async () => {
|
||||
for (const largeFileIds of [undefined, []]) {
|
||||
const { provider, recorded } = fakeClient([
|
||||
prepared,
|
||||
created,
|
||||
readBack,
|
||||
]);
|
||||
await provider.saveDraft(ARA, { ...content, largeFileIds });
|
||||
expect(draftOf(recorded)).not.toHaveProperty(
|
||||
'header:X-Ara-Large-Files:asText',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('이어 쓸 임시 저장본 (getDraft)', () => {
|
||||
const draftEmail = (extra: Record<string, unknown> = {}) => ({
|
||||
...summaryEmail('dr1', '2026-10-27T01:00:00Z', {
|
||||
@@ -5298,6 +5406,7 @@ describe('JmapMailProvider', () => {
|
||||
inReplyTo: null,
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
isCanceledSend: false,
|
||||
largeFileIds: [],
|
||||
});
|
||||
expect(argsOf(recorded[0], 1)).toMatchObject({
|
||||
ids: ['dr1'],
|
||||
@@ -5382,6 +5491,74 @@ describe('JmapMailProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('이어 쓸 임시 저장본의 대용량 첨부 머리글 (11단계)', () => {
|
||||
const draftEmail = (header: unknown) => ({
|
||||
...summaryEmail('dr1', '2026-10-27T01:00:00Z', {
|
||||
mailboxIds: { d: true },
|
||||
keywords: { $draft: true, $seen: true },
|
||||
}),
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: ['dr1@arasystem.test'],
|
||||
references: null,
|
||||
bodyValues: { '1': { value: '본문', isTruncated: false } },
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
attachments: [],
|
||||
'header:X-Ara-Large-Files:asText': header,
|
||||
});
|
||||
|
||||
it('머리글의 파일 id 를 읽는다 — 우리 모양만 · 소문자 · 겹치지 않게, 머리글을 함께 묻는다', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
[
|
||||
['Mailbox/get', { list: stalwartMailboxes }, 'm'],
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
draftEmail(
|
||||
' 0F8FAD5B-D9CB-469F-A165-70867728950E ,7c9e6679-7425-40de-944b-e07fc1f90ae7, not-a-uuid, 0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
),
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(provider.getDraft(ARA, 'dr1')).resolves.toMatchObject({
|
||||
largeFileIds: [
|
||||
'0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||
],
|
||||
});
|
||||
expect(
|
||||
(argsOf(recorded[0], 1) as { properties: string[] }).properties,
|
||||
).toContain('header:X-Ara-Large-Files:asText');
|
||||
});
|
||||
|
||||
it('머리글이 없거나 글이 아니면 빈 목록', async () => {
|
||||
for (const header of [
|
||||
null,
|
||||
undefined,
|
||||
42,
|
||||
['0f8fad5b-d9cb-469f-a165-70867728950e'],
|
||||
]) {
|
||||
const { provider } = fakeClient([
|
||||
[
|
||||
['Mailbox/get', { list: stalwartMailboxes }, 'm'],
|
||||
['Email/get', { list: [draftEmail(header)] }, 'g'],
|
||||
],
|
||||
]);
|
||||
await expect(provider.getDraft(ARA, 'dr1')).resolves.toMatchObject({
|
||||
largeFileIds: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('보낸 초안 지우기 (deleteDraft)', () => {
|
||||
/** 메일함 + 초안 확인 — 그때의 Email 상태 's1' */
|
||||
const lookup = (list: Record<string, unknown>[]): JmapInvocation[] => [
|
||||
@@ -7090,6 +7267,60 @@ describe('JmapMailProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일 제목 (getMessageSubjects — 11단계 보낸 링크 목록)', () => {
|
||||
it('한 번에 읽는다 — id 와 제목만, 없는 메일은 빠지고 제목이 없으면 빈 글', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
[
|
||||
[
|
||||
'Email/get',
|
||||
{
|
||||
list: [
|
||||
{ id: 'm1', subject: '견적서' },
|
||||
{ id: 'm2', subject: null },
|
||||
],
|
||||
notFound: ['m3'],
|
||||
},
|
||||
's',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const subjects = await provider.getMessageSubjects(ARA, [
|
||||
'm1',
|
||||
'm2',
|
||||
'm3',
|
||||
'm1',
|
||||
]);
|
||||
expect([...subjects]).toEqual([
|
||||
['m1', '견적서'],
|
||||
['m2', ''],
|
||||
]);
|
||||
expect(argsOf(recorded[0])).toEqual({
|
||||
accountId: 'd',
|
||||
ids: ['m1', 'm2', 'm3'],
|
||||
properties: ['id', 'subject'],
|
||||
});
|
||||
expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' });
|
||||
});
|
||||
|
||||
it('모양이 틀린 id 는 묻지 않는다 — 남는 것이 없으면 엔진에 묻지 않는다', async () => {
|
||||
const { provider, recorded } = fakeClient([]);
|
||||
await expect(
|
||||
provider.getMessageSubjects(ARA, ['a b', '../x', '']),
|
||||
).resolves.toEqual(new Map());
|
||||
expect(recorded).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('엔진을 읽지 못하면 그 실패를 던진다 — 부르는 쪽이 제목 없이 답한다', async () => {
|
||||
const { provider } = fakeClient([
|
||||
new BusinessException('MAIL_001', { context: { reason: 'test' } }),
|
||||
]);
|
||||
expect(await codeOf(provider.getMessageSubjects(ARA, ['m1']))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('원문 (openMessageSource)', () => {
|
||||
it('메일의 원본 blob 을 message/rfc822 로 내려받아 흘려보낸다 — 제목을 함께 돌려준다', async () => {
|
||||
const { provider, recorded, client } = fakeClient([
|
||||
@@ -8146,3 +8377,44 @@ describe('buildThreadHeaders', () => {
|
||||
expect(buildThreadHeaders(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLargeFileIds (초안 머리글 X-Ara-Large-Files — 11단계)', () => {
|
||||
const ID = (n: number) =>
|
||||
`0f8fad5b-d9cb-469f-a165-${n.toString(16).padStart(12, '0')}`;
|
||||
|
||||
it('콤마로 나눈 우리 모양(UUID)만 · 소문자 · 겹치지 않게 · 앞뒤 공백 무시', () => {
|
||||
expect(
|
||||
parseLargeFileIds(
|
||||
' 0F8FAD5B-D9CB-469F-A165-70867728950E , 7c9e6679-7425-40de-944b-e07fc1f90ae7,0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
),
|
||||
).toEqual([
|
||||
'0f8fad5b-d9cb-469f-a165-70867728950e',
|
||||
'7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||
]);
|
||||
});
|
||||
|
||||
it('앞에서부터 20개까지만', () => {
|
||||
const many = Array.from({ length: 25 }, (_, index) => ID(index)).join(',');
|
||||
expect(parseLargeFileIds(many)).toEqual(
|
||||
Array.from({ length: 20 }, (_, index) => ID(index)),
|
||||
);
|
||||
});
|
||||
|
||||
it.each<[string, unknown]>([
|
||||
['글이 아님', 42],
|
||||
['null', null],
|
||||
['배열', ['0f8fad5b-d9cb-469f-a165-70867728950e']],
|
||||
['빈 글', ''],
|
||||
[
|
||||
'다른 구분자(;) — 다른 프로그램이 고친 값',
|
||||
'0f8fad5b-d9cb-469f-a165-70867728950e;7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||
],
|
||||
['따옴표로 감쌈', '"0f8fad5b-d9cb-469f-a165-70867728950e"'],
|
||||
['JSON 배열 글', '["0f8fad5b-d9cb-469f-a165-70867728950e"]'],
|
||||
['중괄호 GUID', '{0f8fad5b-d9cb-469f-a165-70867728950e}'],
|
||||
['한 글자 모자란 id', '0f8fad5b-d9cb-469f-a165-70867728950'],
|
||||
['경로 같은 글', '../../etc/passwd'],
|
||||
])('이상한 머리글은 버린다 — %s', (_label, value) => {
|
||||
expect(parseLargeFileIds(value)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
MAX_BODY_VALUE_BYTES,
|
||||
MAX_ENVELOPE_RESUBMITS,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import type { MailProvider } from '@/modules/mail/mail.provider';
|
||||
import {
|
||||
SendNotStartedException,
|
||||
type MailProvider,
|
||||
} from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
AttachmentContent,
|
||||
@@ -192,6 +195,29 @@ const INTERNAL_SEND_KEYWORD = '$ara-sending';
|
||||
*/
|
||||
const REPLY_TO_ID_PROPERTY = 'header:X-Ara-Reply-To-Id:asText';
|
||||
|
||||
/**
|
||||
* 임시 저장본에 실린 대용량 첨부 파일 id (11단계 — 계약 4절 "임시 저장") — `X-Ara-Large-Files: <id>, <id>…` (올리는 중인 올리기 id 도 — 같은 id 다).
|
||||
* 답장 대상 머리글과 같은 길이다 — 초안 자체는 보내지 않으므로 받는 사람에게 가지 않는다(다른 메일 프로그램이 그 초안을 그대로 보내면 머리글만 나간다 — 알려진 한계)
|
||||
*/
|
||||
const LARGE_FILES_PROPERTY = 'header:X-Ara-Large-Files:asText';
|
||||
/** 머리글에서 읽는 파일 id 모양 (우리가 만든 UUID) · 개수 상한 (발송 · 임시 저장 본문의 largeFileIds 상한과 같다) */
|
||||
const LARGE_FILE_ID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const MAX_DRAFT_LARGE_FILES = 20;
|
||||
|
||||
/**
|
||||
* 초안 머리글의 대용량 파일 id — 콤마로 나눈 조각 가운데 우리 모양(UUID)만, 소문자, 겹치지 않게, 앞에서부터 20개.
|
||||
* 다른 프로그램이 만들거나 고친 값(다른 구분자 · 따옴표 · 이상한 글)은 버린다 — 서비스가 그 직원의 파일인지 다시 본다
|
||||
*/
|
||||
export function parseLargeFileIds(value: unknown): string[] {
|
||||
if (typeof value !== 'string') return [];
|
||||
const ids = value
|
||||
.split(',')
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter((part) => LARGE_FILE_ID.test(part));
|
||||
return [...new Set(ids)].slice(0, MAX_DRAFT_LARGE_FILES);
|
||||
}
|
||||
|
||||
/** 사용자 초안인지 볼 때 읽는 속성 */
|
||||
const DRAFT_CHECK_PROPERTIES = [
|
||||
'id',
|
||||
@@ -209,6 +235,7 @@ const DRAFT_DETAIL_PROPERTIES = [
|
||||
...DETAIL_PROPERTIES,
|
||||
...DRAFT_CHECK_PROPERTIES,
|
||||
REPLY_TO_ID_PROPERTY,
|
||||
LARGE_FILES_PROPERTY,
|
||||
'inReplyTo',
|
||||
]),
|
||||
];
|
||||
@@ -2034,7 +2061,7 @@ export class JmapMailProvider implements MailProvider {
|
||||
// 보내다 끊기면 나갔는지 알 수 없다 — 남은 시간이 모자라면 아예 시작하지 않는다 (아무것도 나가지 않았으니 같은 키로 다시 보내면 된다)
|
||||
const sendTimeoutMs = Math.min(JMAP_SEND_TIMEOUT_MS, budgetLeft());
|
||||
if (sendTimeoutMs < MIN_SEND_WINDOW_MS) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
throw new SendNotStartedException('MAIL_001', {
|
||||
context: { reason: 'send-budget-exhausted', leftMs: sendTimeoutMs },
|
||||
});
|
||||
}
|
||||
@@ -2291,7 +2318,7 @@ export class JmapMailProvider implements MailProvider {
|
||||
const timeoutMs = Math.min(JMAP_SEND_TIMEOUT_MS, context.budgetLeft());
|
||||
if (timeoutMs < MIN_SEND_WINDOW_MS) {
|
||||
await discard();
|
||||
throw new BusinessException('MAIL_001', {
|
||||
throw new SendNotStartedException('MAIL_001', {
|
||||
context: {
|
||||
reason: 'send-budget-exhausted',
|
||||
leftMs: timeoutMs,
|
||||
@@ -2741,6 +2768,32 @@ export class JmapMailProvider implements MailProvider {
|
||||
};
|
||||
}
|
||||
|
||||
/** 메일 제목 — 한 번에 읽는다 (11단계 보낸 대용량 첨부 링크 목록). 없는 메일은 빠진다, id 모양이 틀리면 묻지 않는다 */
|
||||
async getMessageSubjects(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const valid = [...new Set(ids)].filter((id) => MAIL_ID_PATTERN.test(id));
|
||||
if (valid.length === 0) return new Map();
|
||||
const responses = await this.jmap.call(
|
||||
user,
|
||||
(accountId) => [
|
||||
[
|
||||
'Email/get',
|
||||
{ accountId, ids: valid, properties: ['id', 'subject'] },
|
||||
's',
|
||||
],
|
||||
],
|
||||
{ invalidRequestCode: 'RES_001' },
|
||||
);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapEmail>>(
|
||||
responses,
|
||||
's',
|
||||
'Email/get',
|
||||
);
|
||||
return new Map(list.map((email) => [email.id, email.subject ?? '']));
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은 그대로의 메일 원문 — 메일의 원본 blob 을 흘려보낸다 (계약 2절 "스팸 · 원문").
|
||||
* 없는 메일 · 엔진이 해석하지 못하는 id 는 RES_001. 엔진이 원본 blob 을 알려 주지 않으면 MAIL_001
|
||||
@@ -2944,6 +2997,10 @@ export class JmapMailProvider implements MailProvider {
|
||||
...(thread ?? {}),
|
||||
// 이어 쓸 때 돌려줄 답장 대상 — 원 메일을 찾았을 때만 (지워진 메일 id 는 남기지 않는다)
|
||||
...(original ? { [REPLY_TO_ID_PROPERTY]: original.id } : {}),
|
||||
// 대용량 첨부(11단계) — 파일 id 만 머리글로 (덩이는 보낼 때 만든다)
|
||||
...(content.largeFileIds?.length
|
||||
? { [LARGE_FILES_PROPERTY]: content.largeFileIds.join(', ') }
|
||||
: {}),
|
||||
...bodyProperties(content, []),
|
||||
};
|
||||
|
||||
@@ -3044,6 +3101,9 @@ export class JmapMailProvider implements MailProvider {
|
||||
(isCanceledSend ? await this.replyTargetInThread(user, email) : null),
|
||||
updatedAt: detail.receivedAt,
|
||||
isCanceledSend,
|
||||
largeFileIds: parseLargeFileIds(
|
||||
(email as unknown as Record<string, unknown>)[LARGE_FILES_PROPERTY],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { sanitizeMailHtml } from './html/sanitize-mail-html';
|
||||
import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html';
|
||||
import {
|
||||
LARGE_FILES_BLOCK_END,
|
||||
LARGE_FILES_BLOCK_START,
|
||||
buildLargeFileBlock,
|
||||
extractQuoteStart,
|
||||
findHtmlBlock,
|
||||
hrefsIn,
|
||||
insertHtmlBlock,
|
||||
insertTextBlock,
|
||||
isValidQuoteStart,
|
||||
removeHtmlBlock,
|
||||
removeTextBlock,
|
||||
} from './large-file-block';
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
const MB = 1024 * 1024;
|
||||
const URL_ = `https://files.arasystem.test/s/${'Ab_-9'.repeat(8)}xyz`;
|
||||
const EXPIRES = new Date('2026-11-29T14:59:59.999Z');
|
||||
const FILES = [
|
||||
{ name: '설계도_v3.dwg', size: 1.2 * GB },
|
||||
{ name: '현장영상_1029.mp4', size: 612 * MB },
|
||||
];
|
||||
|
||||
const block = buildLargeFileBlock({
|
||||
files: FILES,
|
||||
expiresAt: EXPIRES,
|
||||
url: URL_,
|
||||
});
|
||||
|
||||
/** 앞뒤 표시(주석)를 뗀 HTML 덩이 — 보내는 정화를 거치면 주석이 지워진다 */
|
||||
const withoutMarkers = (html: string) =>
|
||||
html.replace(LARGE_FILES_BLOCK_START, '').replace(LARGE_FILES_BLOCK_END, '');
|
||||
|
||||
describe('buildLargeFileBlock — 덩이의 내용 (디자인 README "11단계 › 본문 덩이")', () => {
|
||||
it('HTML — 앞뒤 표시 · 개수 · 합계 · 한국 날짜 · 파일마다 이름 · 크기 · 주소가 보이는 링크 · 로그인 안내 · 영어 한 줄', () => {
|
||||
expect(block.html.startsWith(LARGE_FILES_BLOCK_START)).toBe(true);
|
||||
expect(block.html.endsWith(LARGE_FILES_BLOCK_END)).toBe(true);
|
||||
for (const text of [
|
||||
'대용량 첨부 2개 (합계 1.8 GB)',
|
||||
'2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)',
|
||||
'설계도_v3.dwg',
|
||||
'1.2 GB',
|
||||
'현장영상_1029.mp4',
|
||||
'612 MB',
|
||||
`<a href="${URL_}">${URL_}</a>`,
|
||||
'내려받을 때 로그인이나 비밀번호를 묻지 않습니다.',
|
||||
'Large files: download by Nov 29, 2026 (KST) — no login or password needed.',
|
||||
]) {
|
||||
expect(block.html).toContain(text);
|
||||
}
|
||||
// 그림 · 아이콘 없이 테두리 표와 글자
|
||||
expect(block.html).not.toMatch(/<img|border-radius/);
|
||||
});
|
||||
|
||||
it('텍스트 — 같은 내용을 대시 줄로 가른 줄로 (파일 "- 이름 (크기)", 주소 "내려받기: 주소")', () => {
|
||||
expect(block.text.split('\n')).toEqual([
|
||||
'-'.repeat(40),
|
||||
'대용량 첨부 2개 (합계 1.8 GB)',
|
||||
'2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)',
|
||||
'- 설계도_v3.dwg (1.2 GB)',
|
||||
'- 현장영상_1029.mp4 (612 MB)',
|
||||
`내려받기: ${URL_}`,
|
||||
'내려받을 때 로그인이나 비밀번호를 묻지 않습니다.',
|
||||
'Large files: download by Nov 29, 2026 (KST) — no login or password needed.',
|
||||
'-'.repeat(40),
|
||||
]);
|
||||
});
|
||||
|
||||
it('파일 이름의 HTML 문자는 글자로 — 덩이 안에 태그가 생기지 않는다', () => {
|
||||
const tricky = buildLargeFileBlock({
|
||||
files: [{ name: '<img src=x onerror=1>&"\'.zip', size: 10 }],
|
||||
expiresAt: EXPIRES,
|
||||
url: URL_,
|
||||
});
|
||||
expect(tricky.html).toContain(
|
||||
'<img src=x onerror=1>&"'.zip',
|
||||
);
|
||||
expect(tricky.html).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('보내는 정화의 허용 목록 안이다 — 정화해도 주석만 빠지고 그대로 (답장 · 전달 인용 · 되돌린 초안에서도 모양이 남는다)', () => {
|
||||
expect(sanitizeOutgoingHtml(block.html)).toBe(withoutMarkers(block.html));
|
||||
});
|
||||
|
||||
it('다시 읽을 때(받은 메일 정화)도 표 · 칸 · 글자 모양이 남는다 — 링크는 새 창으로', () => {
|
||||
const reread = sanitizeMailHtml(block.html).html;
|
||||
expect(reread).toContain(
|
||||
'<table style="border-collapse:collapse;border:1px solid #d0d5dd',
|
||||
);
|
||||
expect(reread).toContain('font-size:14px;font-weight:bold');
|
||||
expect(reread).toContain(
|
||||
`<a href="${URL_}" target="_blank" rel="noopener noreferrer">${URL_}</a>`,
|
||||
);
|
||||
expect(reread).not.toContain(LARGE_FILES_BLOCK_START);
|
||||
});
|
||||
});
|
||||
|
||||
describe('인용 경계 · 덩이 넣기 · 떼기', () => {
|
||||
it('isValidQuoteStart — 0 ~ 길이이고 줄의 처음', () => {
|
||||
const body = '내 글\n\n----- 원본 메일 -----';
|
||||
expect(isValidQuoteStart(body, 0)).toBe(true);
|
||||
expect(isValidQuoteStart(body, 5)).toBe(true);
|
||||
expect(isValidQuoteStart(body, body.length)).toBe(false);
|
||||
expect(isValidQuoteStart('a\n', 2)).toBe(true);
|
||||
expect(isValidQuoteStart(body, 2)).toBe(false);
|
||||
expect(isValidQuoteStart(body, -1)).toBe(false);
|
||||
expect(isValidQuoteStart(body, body.length + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('extractQuoteStart — 표시를 모두 지우고 첫 자리를 준다 (내용이 있는 표시는 속성만 뗀다)', () => {
|
||||
const html =
|
||||
'<div><p>내 글</p><div data-ara-quote-start></div><p>인용</p><div data-ara-quote-start><p>둘째</p></div></div>';
|
||||
const { html: stripped, position } = extractQuoteStart(html);
|
||||
expect(stripped).toBe(
|
||||
'<div><p>내 글</p><p>인용</p><div><p>둘째</p></div></div>',
|
||||
);
|
||||
expect(position).toBe('<div><p>내 글</p>'.length);
|
||||
expect(extractQuoteStart('<p>없음</p>')).toEqual({
|
||||
html: '<p>없음</p>',
|
||||
position: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('보내는 정화는 keepQuoteStart 일 때만, div 에만 표시를 남긴다 — 그 밖의 data-* 는 지운다', () => {
|
||||
const input =
|
||||
'<p>글</p><div data-ara-quote-start="" style="color:#000"></div><p data-ara-quote-start>p</p><div data-x="1">x</div>';
|
||||
expect(sanitizeOutgoingHtml(input, { keepQuoteStart: true })).toBe(
|
||||
'<p>글</p><div data-ara-quote-start></div><p>p</p><div>x</div>',
|
||||
);
|
||||
// 남기지 않을 때는 표시 속성만 빠지고 div 는 지금처럼 (style 도 그대로)
|
||||
expect(sanitizeOutgoingHtml(input)).toBe(
|
||||
'<p>글</p><div style="color:#000"></div><p>p</p><div>x</div>',
|
||||
);
|
||||
});
|
||||
|
||||
it('HTML 덩이는 그 자리(없으면 맨 끝)에 · 원문에서 표시 사이를 찾아 뗀다', () => {
|
||||
const html = '<div><p>내 글</p><p>인용</p></div>';
|
||||
const at = '<div><p>내 글</p>'.length;
|
||||
const inserted = insertHtmlBlock(html, at, block.html);
|
||||
expect(inserted).toBe(`<div><p>내 글</p>${block.html}<p>인용</p></div>`);
|
||||
expect(insertHtmlBlock(html, null, block.html)).toBe(html + block.html);
|
||||
|
||||
const found = findHtmlBlock(inserted);
|
||||
expect(found).not.toBeNull();
|
||||
if (found) expect(removeHtmlBlock(inserted, found)).toBe(html);
|
||||
expect(findHtmlBlock(html)).toBeNull();
|
||||
expect(findHtmlBlock(`a${LARGE_FILES_BLOCK_START}b`)).toBeNull();
|
||||
});
|
||||
|
||||
it('텍스트 덩이는 앞뒤에 빈 줄 — 넣은 그대로면 뗄 수 있고, 손대면 남는다', () => {
|
||||
const body = '내 글\n\n-- \n김아라\n\n----- 원본 메일 -----\n> 인용';
|
||||
const quoteStart = body.indexOf('-----');
|
||||
const inserted = insertTextBlock(body, quoteStart, block.text);
|
||||
expect(inserted).toBe(
|
||||
`내 글\n\n-- \n김아라\n\n\n${block.text}\n\n----- 원본 메일 -----\n> 인용`,
|
||||
);
|
||||
expect(removeTextBlock(inserted, block.text)).toBe(body);
|
||||
// 엔진이 줄바꿈을 CRLF 로 돌려줘도 같다
|
||||
expect(removeTextBlock(inserted.replace(/\n/g, '\r\n'), block.text)).toBe(
|
||||
body,
|
||||
);
|
||||
expect(
|
||||
removeTextBlock(inserted.replace('1.2 GB', '1.3 GB'), block.text),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('맨 끝에 붙일 때 마지막 줄이 줄바꿈 없이 끝났으면 줄을 바꾼 뒤에', () => {
|
||||
expect(insertTextBlock('내 글', null, 'B')).toBe('내 글\n\nB\n\n');
|
||||
expect(insertTextBlock('내 글\n', null, 'B')).toBe('내 글\n\nB\n\n');
|
||||
expect(insertTextBlock('', null, 'B')).toBe('\nB\n\n');
|
||||
});
|
||||
|
||||
it('hrefsIn — 따옴표 둘 다, 겹치지 않게, 한도까지 · 긴 입력에도 길이에 비례', () => {
|
||||
expect(
|
||||
hrefsIn(
|
||||
`<a href="${URL_}">x</a><a HREF = '${URL_}'>y</a><a href="mailto:a@b">z</a>`,
|
||||
10,
|
||||
),
|
||||
).toEqual([URL_, 'mailto:a@b']);
|
||||
expect(
|
||||
hrefsIn('<a href="1"></a><a href="2"></a><a href="3"></a>', 2),
|
||||
).toEqual(['1', '2']);
|
||||
const cpuMs = startCpuTimer();
|
||||
hrefsIn(`href${' '.repeat(200_000)}`, 10);
|
||||
hrefsIn('href="'.repeat(100_000), 10);
|
||||
expect(cpuMs()).toBeLessThan(2_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { formatFileSize } from '@/shared/utils/format';
|
||||
import { formatKstDate, formatKstDateEnglish } from '@/shared/utils/kst-date';
|
||||
|
||||
/**
|
||||
* 대용량 첨부 덩이 (순수 함수) — 서버가 보내는 메일 본문에 넣는 내려받기 안내 (계약 4절 "덩이 — 서버가 본문에 넣는 안내", 디자인 README "11단계 › 본문 덩이").
|
||||
*
|
||||
* - **자리**: 내 글 · 서명 아래, 인용 위 — 서식 본문은 쓰기 화면이 둔 첫 경계 표시 `<div data-ara-quote-start></div>` 자리, 텍스트는 `bodyQuoteStart`. 없으면 맨 끝
|
||||
* - **HTML 모양**: 보내는 정화의 허용 목록 안의 태그 · style 값으로만 — 다시 읽을 때 · 답장 · 전달의 인용 · 보내기 취소로 되돌릴 때도 모양이 남게
|
||||
* (sanitize-outgoing-html.spec.ts 가 이 덩이를 정화해도 그대로인지 본다). 그림 · 뱃지 · 알약 없이 테두리 표와 글자. 링크에는 style 을 두지 않는다(정화가 지운다)
|
||||
* - **표시**: 앞뒤 주석 `<!--ara-large-files:v1-->` · `<!--/ara-large-files-->` — 보내는 정화는 HTML 주석을 지우므로 사용자가 쓴 본문(인용 포함)에는
|
||||
* 이 표시가 들어갈 수 없다(서버만 넣는다). 엔진은 저장한 메일의 주석을 그대로 둔다(2026-10-30 개발 엔진으로 확인 — 되돌린 초안의 rawHtmlBody 에 남는다)
|
||||
* - **텍스트**: 같은 내용을 줄로 — 대시 줄로 가르고 앞뒤에 빈 줄
|
||||
*
|
||||
* ⚠️ 덩이를 고치면 그 전에 보낸 메일의 텍스트 덩이를 보내기 취소로 되돌릴 때 떼지 못한다(글 그대로일 때만 뗀다) — 표시의 판(v1)을 올리고 옛 판도 알아보게 한다
|
||||
*/
|
||||
|
||||
/** 덩이의 앞뒤 표시 (HTML 주석) */
|
||||
export const LARGE_FILES_BLOCK_START = '<!--ara-large-files:v1-->';
|
||||
export const LARGE_FILES_BLOCK_END = '<!--/ara-large-files-->';
|
||||
|
||||
/** 쓰기 화면이 인용("원본 메일" 머리) 바로 앞에 두는 빈 표시 — 보내는 정화가 이 속성만 div 에 남긴다(keepQuoteStart) */
|
||||
export const QUOTE_START_ATTRIBUTE = 'data-ara-quote-start';
|
||||
|
||||
/** 정화한 HTML 의 표시 — 정화가 속성 하나짜리 여는 태그로 다시 쓴다(빈 값은 이름만) */
|
||||
const QUOTE_START_OPEN = `<div ${QUOTE_START_ATTRIBUTE}>`;
|
||||
const DIV_CLOSE = '</div>';
|
||||
|
||||
/** 텍스트 덩이를 가르는 줄 */
|
||||
const TEXT_RULE = '-'.repeat(40);
|
||||
|
||||
/** 덩이의 글꼴 — 보내는 정화의 글꼴 목록 안 (쓰기 화면의 기본 글꼴과 같다) */
|
||||
const BLOCK_FONT =
|
||||
"'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif";
|
||||
|
||||
/** 덩이 하나의 재료 */
|
||||
export interface LargeFileBlockInput {
|
||||
/** 보낸 차례 */
|
||||
readonly files: readonly { readonly name: string; readonly size: number }[];
|
||||
/** 링크가 끝나는 때 — 한국 날짜로 적는다 */
|
||||
readonly expiresAt: Date;
|
||||
/** 공개 화면 주소 (`<SHARE_PUBLIC_URL>/s/<토큰>`) */
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface LargeFileBlock {
|
||||
readonly html: string;
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 덩이를 만든다 — HTML(앞뒤 표시 포함)과 텍스트에 같은 내용 */
|
||||
export function buildLargeFileBlock(
|
||||
input: LargeFileBlockInput,
|
||||
): LargeFileBlock {
|
||||
const total = input.files.reduce((sum, file) => sum + file.size, 0);
|
||||
const title = `대용량 첨부 ${input.files.length}개 (합계 ${formatFileSize(total)})`;
|
||||
const until = `${formatKstDate(input.expiresAt)}까지 내려받을 수 있습니다 (한국 시간)`;
|
||||
const noLogin = '내려받을 때 로그인이나 비밀번호를 묻지 않습니다.';
|
||||
const english = `Large files: download by ${formatKstDateEnglish(input.expiresAt)} (KST) — no login or password needed.`;
|
||||
const url = escapeHtml(input.url);
|
||||
|
||||
const cell = (style: string, content: string) =>
|
||||
`<tr><td colspan="2" style="${style}">${content}</td></tr>`;
|
||||
const fileRows = input.files
|
||||
.map(
|
||||
(file) =>
|
||||
`<tr><td style="padding:2px 8px 2px 16px;font-size:13px;color:#101828">${escapeHtml(file.name)}</td>` +
|
||||
`<td style="padding:2px 16px 2px 8px;font-size:13px;color:#667085;text-align:right;white-space:nowrap">${formatFileSize(file.size)}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
const html =
|
||||
LARGE_FILES_BLOCK_START +
|
||||
`<table style="border-collapse:collapse;border:1px solid #d0d5dd;margin:16px 0;font-family:${BLOCK_FONT}"><tbody>` +
|
||||
cell(
|
||||
'padding:12px 16px 2px 16px;font-size:14px;font-weight:bold;color:#101828',
|
||||
escapeHtml(title),
|
||||
) +
|
||||
cell(
|
||||
'padding:0 16px 8px 16px;font-size:13px;color:#344054',
|
||||
escapeHtml(until),
|
||||
) +
|
||||
fileRows +
|
||||
cell(
|
||||
'padding:8px 16px 2px 16px;font-size:13px;color:#344054',
|
||||
`<span style="padding-right:8px">내려받기</span><a href="${url}">${url}</a>`,
|
||||
) +
|
||||
cell(
|
||||
'padding:6px 16px 0 16px;font-size:12px;color:#667085',
|
||||
escapeHtml(noLogin),
|
||||
) +
|
||||
cell(
|
||||
'padding:2px 16px 12px 16px;font-size:12px;color:#667085',
|
||||
escapeHtml(english),
|
||||
) +
|
||||
'</tbody></table>' +
|
||||
LARGE_FILES_BLOCK_END;
|
||||
|
||||
const text = [
|
||||
TEXT_RULE,
|
||||
title,
|
||||
until,
|
||||
...input.files.map(
|
||||
(file) => `- ${file.name} (${formatFileSize(file.size)})`,
|
||||
),
|
||||
`내려받기: ${input.url}`,
|
||||
noLogin,
|
||||
english,
|
||||
TEXT_RULE,
|
||||
].join('\n');
|
||||
return { html, text };
|
||||
}
|
||||
|
||||
/** 텍스트 덩이를 본문에 넣을 때의 모양 — 앞뒤에 빈 줄 (떼기도 이 모양 그대로 찾는다) */
|
||||
function textUnit(block: string): string {
|
||||
return `\n${block}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `bodyQuoteStart` 가 맞는 자리인가 — 0 ~ body.length(JS 문자열 위치)이고 줄의 처음(0 이거나 바로 앞이 줄바꿈). 아니면 400 VAL_001
|
||||
*/
|
||||
export function isValidQuoteStart(body: string, position: number): boolean {
|
||||
return (
|
||||
Number.isInteger(position) &&
|
||||
position >= 0 &&
|
||||
position <= body.length &&
|
||||
(position === 0 || body[position - 1] === '\n')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정화한 서식 본문의 인용 경계 표시를 **모두** 지운다 — 받는 사람에게 가지 않고, 임시 저장본 · 지문에도 들지 않는다.
|
||||
* 빈 표시는 태그째, 안에 내용이 있는 표시는 속성만 뗀다. 정화가 표시를 속성 하나짜리 여는 태그로 다시 쓰므로 글자 그대로 찾는다
|
||||
* (사용자 글의 `<` 는 정화가 `<` 로 바꿔 이 모양이 될 수 없다).
|
||||
* @returns 지운 HTML 과 첫 표시가 있던 자리(덩이를 넣을 곳) — 없으면 null
|
||||
*/
|
||||
export function extractQuoteStart(sanitizedHtml: string): {
|
||||
readonly html: string;
|
||||
readonly position: number | null;
|
||||
} {
|
||||
let position: number | null = null;
|
||||
let result = '';
|
||||
let from = 0;
|
||||
for (
|
||||
let found = sanitizedHtml.indexOf(QUOTE_START_OPEN);
|
||||
found >= 0;
|
||||
found = sanitizedHtml.indexOf(QUOTE_START_OPEN, from)
|
||||
) {
|
||||
result += sanitizedHtml.slice(from, found);
|
||||
position ??= result.length;
|
||||
const afterOpen = found + QUOTE_START_OPEN.length;
|
||||
if (sanitizedHtml.startsWith(DIV_CLOSE, afterOpen)) {
|
||||
from = afterOpen + DIV_CLOSE.length;
|
||||
} else {
|
||||
result += '<div>';
|
||||
from = afterOpen;
|
||||
}
|
||||
}
|
||||
return { html: result + sanitizedHtml.slice(from), position };
|
||||
}
|
||||
|
||||
/** 서식 본문의 그 자리(없으면 맨 끝)에 덩이를 넣는다 */
|
||||
export function insertHtmlBlock(
|
||||
html: string,
|
||||
position: number | null,
|
||||
block: string,
|
||||
): string {
|
||||
const at = position === null ? html.length : Math.min(position, html.length);
|
||||
return html.slice(0, at) + block + html.slice(at);
|
||||
}
|
||||
|
||||
/** 텍스트 본문의 그 자리(없으면 맨 끝)에 덩이를 넣는다 — 앞뒤에 빈 줄 */
|
||||
export function insertTextBlock(
|
||||
text: string,
|
||||
position: number | null,
|
||||
block: string,
|
||||
): string {
|
||||
const at = position === null ? text.length : Math.min(position, text.length);
|
||||
// 맨 끝에 붙일 때 마지막 줄이 줄바꿈 없이 끝났으면 줄을 바꾼 뒤에 (bodyQuoteStart 는 늘 줄의 처음이다)
|
||||
const lineBreak = at > 0 && text[at - 1] !== '\n' ? '\n' : '';
|
||||
return text.slice(0, at) + lineBreak + textUnit(block) + text.slice(at);
|
||||
}
|
||||
|
||||
/** 원문 HTML 의 덩이 자리 — 첫 앞 표시부터 그 뒤의 첫 뒤 표시 끝까지. 없으면 null */
|
||||
export function findHtmlBlock(
|
||||
raw: string,
|
||||
): { readonly start: number; readonly end: number } | null {
|
||||
const start = raw.indexOf(LARGE_FILES_BLOCK_START);
|
||||
if (start < 0) return null;
|
||||
const close = raw.indexOf(
|
||||
LARGE_FILES_BLOCK_END,
|
||||
start + LARGE_FILES_BLOCK_START.length,
|
||||
);
|
||||
if (close < 0) return null;
|
||||
return { start, end: close + LARGE_FILES_BLOCK_END.length };
|
||||
}
|
||||
|
||||
/** href 값 — 따옴표 둘 다 (덩이 안의 링크를 찾을 때만 쓴다 — 입력 길이에 비례) */
|
||||
const HREF = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
||||
|
||||
/** HTML 조각의 링크 주소 — 나온 차례, 겹치지 않게, `limit` 개까지 */
|
||||
export function hrefsIn(html: string, limit: number): string[] {
|
||||
const found = new Set<string>();
|
||||
for (const match of html.matchAll(HREF)) {
|
||||
const value = (match[1] ?? match[2] ?? '').replace(/&/g, '&').trim();
|
||||
if (value !== '') found.add(value);
|
||||
if (found.size >= limit) break;
|
||||
}
|
||||
return [...found];
|
||||
}
|
||||
|
||||
/** 원문 HTML 에서 덩이를 뗀다 (보내기 취소로 되돌린 초안 — 정화 전) */
|
||||
export function removeHtmlBlock(
|
||||
raw: string,
|
||||
block: { readonly start: number; readonly end: number },
|
||||
): string {
|
||||
return raw.slice(0, block.start) + raw.slice(block.end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 본문에서 덩이를 뗀다 — 서버가 넣은 글 그대로일 때만(사용자가 손대면 남는다). 줄바꿈은 LF 로 맞춘 뒤 찾는다.
|
||||
* @returns 뗀 본문, 찾지 못했으면 null
|
||||
*/
|
||||
export function removeTextBlock(text: string, block: string): string | null {
|
||||
const normalized = text.replace(/\r\n?/g, '\n');
|
||||
for (const unit of [textUnit(block), block]) {
|
||||
const at = normalized.indexOf(unit);
|
||||
if (at >= 0)
|
||||
return normalized.slice(0, at) + normalized.slice(at + unit.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -37,10 +37,18 @@ const FALLBACK_CONNECTION_LIFETIME_MS = ACCESS_TTL_MAX_SECONDS * 1000;
|
||||
/** 서버가 곧 닫는 까닭 (`end` 이벤트) */
|
||||
export type MailEventsEndReason = 'token-expiring' | 'server-shutdown';
|
||||
|
||||
/** 대용량 첨부의 위험 판정 (`large-file` 이벤트 — 11단계 계약 4절 "악성 검사") — 파일 이름은 그 직원 자신의 글이다 */
|
||||
export interface LargeFileEventData {
|
||||
readonly fileId: string;
|
||||
readonly name: string;
|
||||
readonly scanStatus: 'infected';
|
||||
}
|
||||
|
||||
/** 실시간 알림의 이벤트 (계약 2절 "실시간 알림") — type 이 SSE 의 event 이름이다 */
|
||||
export type MailEvent =
|
||||
| { readonly type: 'state'; readonly data: MailLiveState }
|
||||
| { readonly type: 'new-mail'; readonly data: NewMailNotice }
|
||||
| { readonly type: 'large-file'; readonly data: LargeFileEventData }
|
||||
| { readonly type: 'ping'; readonly data: { readonly upstream: boolean } }
|
||||
| {
|
||||
readonly type: 'end';
|
||||
@@ -207,6 +215,18 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 직원의 모든 연결에 이벤트 하나를 보낸다 — 연결이 없으면 아무것도 하지 않는다(그 직원은 목록 상태로 안다).
|
||||
* 메일 엔진 밖의 소식(대용량 첨부의 위험 판정 `large-file` — 11단계)이 쓴다. 던지지 않는다
|
||||
*/
|
||||
notify(user: string, event: MailEvent): void {
|
||||
const key = user.toLowerCase();
|
||||
for (const hub of [...this.hubs.values()]) {
|
||||
if (hub.closed || hub.user.toLowerCase() !== key) continue;
|
||||
this.broadcast(hub, event);
|
||||
}
|
||||
}
|
||||
|
||||
/** 서버 종료 — HTTP 서버를 닫기 전에 모든 연결을 end{server-shutdown} 으로 닫고 엔진 연결도 닫는다 */
|
||||
beforeApplicationShutdown(): void {
|
||||
this.shuttingDown = true;
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { FileSharingService } from '@/modules/file-sharing/file-sharing.service';
|
||||
import type { ShareLinkRecord } from '@/modules/file-sharing/file-sharing.types';
|
||||
import type { ShareLinkEntity } from '@/modules/file-sharing/share-link.entity';
|
||||
import { hashShareToken, shareUrl } from '@/modules/file-sharing/share-token';
|
||||
import type {
|
||||
FileScanListener,
|
||||
FileScanService,
|
||||
} from '@/modules/file-storage/file-scan.service';
|
||||
import type { FileStorageService } from '@/modules/file-storage/file-storage.service';
|
||||
import type {
|
||||
FileUseProblem,
|
||||
StoredFileView,
|
||||
} from '@/modules/file-storage/file-storage.types';
|
||||
import { sanitizeMailHtml } from './html/sanitize-mail-html';
|
||||
import {
|
||||
buildLargeFileBlock,
|
||||
insertHtmlBlock,
|
||||
insertTextBlock,
|
||||
} from './large-file-block';
|
||||
import { MAX_BODY_LENGTH } from './mail.constants';
|
||||
import type { MailEventsService } from './mail-events.service';
|
||||
import {
|
||||
MailLargeFilesService,
|
||||
isCertainSendFailure,
|
||||
type LargeFilePlan,
|
||||
} from './mail-large-files.service';
|
||||
import { SendNotStartedException, type MailProvider } from './mail.provider';
|
||||
import type { DraftSource, OutgoingMessage, SentMessage } from './mail.types';
|
||||
|
||||
const ORIGIN = 'https://files.arasystem.test';
|
||||
const ARA: AuthenticatedUser = {
|
||||
email: 'ara.kim@arasystem.test',
|
||||
name: '김아라',
|
||||
};
|
||||
const DOYUN: AuthenticatedUser = {
|
||||
email: 'doyun.lee@arasystem.test',
|
||||
name: '이도윤',
|
||||
};
|
||||
const TOKEN = 'Ab_-9'.repeat(8) + 'xyz';
|
||||
const FILE_A = '0f8fad5b-d9cb-469f-a165-70867728950e';
|
||||
const FILE_B = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const EXPIRES = new Date('2026-11-29T14:59:59.999Z');
|
||||
const SENT_AT = new Date('2026-10-30T03:00:00.000Z');
|
||||
|
||||
const view = (id: string, name: string, size: number): StoredFileView => ({
|
||||
id,
|
||||
ownerEmail: ARA.email,
|
||||
name,
|
||||
size,
|
||||
state: 'ready',
|
||||
removedReason: null,
|
||||
scanStatus: 'clean',
|
||||
completedAt: SENT_AT,
|
||||
expiresAt: EXPIRES,
|
||||
alive: true,
|
||||
bytesAvailable: true,
|
||||
});
|
||||
|
||||
const VIEWS = [
|
||||
view(FILE_A, '설계도.dwg', 1_000),
|
||||
view(FILE_B, '영상.mp4', 2_000),
|
||||
];
|
||||
|
||||
function record(overrides: Partial<ShareLinkEntity> = {}): ShareLinkRecord {
|
||||
const link = {
|
||||
id: 'link-1',
|
||||
tokenHash: hashShareToken(TOKEN),
|
||||
ownerEmail: ARA.email,
|
||||
senderName: ARA.name,
|
||||
purpose: 'mail',
|
||||
messageId: null,
|
||||
sentEmailId: 'sent-1',
|
||||
sentAt: SENT_AT,
|
||||
expiresAt: EXPIRES,
|
||||
createdAt: SENT_AT,
|
||||
revokedAt: null,
|
||||
revokedBy: null,
|
||||
canceledAt: null,
|
||||
cancelReason: null,
|
||||
...overrides,
|
||||
};
|
||||
const files = VIEWS.map((file, position) => ({
|
||||
linkId: link.id,
|
||||
fileId: file.id,
|
||||
position,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
downloadCount: 3,
|
||||
lastDownloadedAt: new Date('2026-11-01T00:00:00.000Z'),
|
||||
}));
|
||||
return { link, files };
|
||||
}
|
||||
|
||||
/** 메일에 넣은 덩이 (서버가 보낸 그대로) */
|
||||
const sentBlock = buildLargeFileBlock({
|
||||
files: VIEWS,
|
||||
expiresAt: EXPIRES,
|
||||
url: shareUrl(ORIGIN, TOKEN),
|
||||
});
|
||||
|
||||
const message = (
|
||||
overrides: Partial<OutgoingMessage> = {},
|
||||
): OutgoingMessage => ({
|
||||
to: [{ name: null, email: 'partner@example.test' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '도면 보내드립니다',
|
||||
body: '내 글\n\n----- 원본 메일 -----\n> 인용',
|
||||
htmlBody: '<div><p>내 글</p><p>인용</p></div>',
|
||||
attachments: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const PLAN: LargeFilePlan = {
|
||||
fileIds: [FILE_A, FILE_B],
|
||||
htmlQuoteStart: '<div><p>내 글</p>'.length,
|
||||
textQuoteStart: '내 글\n\n'.length,
|
||||
};
|
||||
|
||||
const SENT: SentMessage = {
|
||||
id: 'sent-9',
|
||||
rejectedRecipients: [],
|
||||
pendingSend: null,
|
||||
};
|
||||
|
||||
function setup() {
|
||||
let listener: FileScanListener | null = null;
|
||||
const files = {
|
||||
limits: { maxFilesPerMessage: 10, retentionDays: 30 },
|
||||
checkUse: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ usable: VIEWS, problems: new Map() }),
|
||||
attachToDraft: jest.fn().mockResolvedValue(undefined),
|
||||
draftFiles: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const sharing = {
|
||||
publicOrigin: ORIGIN,
|
||||
createMailLink: jest.fn().mockResolvedValue({
|
||||
record: record({ sentEmailId: null }),
|
||||
token: TOKEN,
|
||||
url: shareUrl(ORIGIN, TOKEN),
|
||||
}),
|
||||
markSent: jest.fn().mockResolvedValue(undefined),
|
||||
cancel: jest.fn().mockResolvedValue(undefined),
|
||||
attachRetry: jest.fn().mockResolvedValue(undefined),
|
||||
findBySentEmail: jest.fn().mockResolvedValue(null),
|
||||
findByTokens: jest.fn().mockResolvedValue(new Map()),
|
||||
fileStatuses: jest.fn().mockResolvedValue(new Map()),
|
||||
listOwned: jest.fn(),
|
||||
revoke: jest.fn(),
|
||||
serveFile: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const scans = {
|
||||
onVerdict: jest.fn((next: FileScanListener) => {
|
||||
listener = next;
|
||||
return () => undefined;
|
||||
}),
|
||||
};
|
||||
const events = { notify: jest.fn() };
|
||||
const provider = {
|
||||
getMessage: jest.fn(),
|
||||
getMessageSubjects: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
const service = new MailLargeFilesService(
|
||||
files as unknown as FileStorageService,
|
||||
sharing as unknown as FileSharingService,
|
||||
scans as unknown as FileScanService,
|
||||
events as unknown as MailEventsService,
|
||||
provider as unknown as MailProvider,
|
||||
);
|
||||
service.onModuleInit();
|
||||
return {
|
||||
service,
|
||||
files,
|
||||
sharing,
|
||||
events,
|
||||
provider,
|
||||
verdict: (event: Parameters<FileScanListener>[0]) => listener?.(event),
|
||||
};
|
||||
}
|
||||
|
||||
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
|
||||
try {
|
||||
await run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
describe('발송 — 덩이 · 링크', () => {
|
||||
it('쓸 수 없는 파일이 있으면 400 MAIL_016 — 링크도 메일도 만들지 않는다', async () => {
|
||||
const { service, files, sharing } = setup();
|
||||
files.checkUse.mockResolvedValue({
|
||||
usable: [],
|
||||
problems: new Map<string, FileUseProblem>([[FILE_A, 'uploading']]),
|
||||
});
|
||||
const deliver = jest.fn();
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.deliverWithLink(
|
||||
ARA,
|
||||
message(),
|
||||
PLAN,
|
||||
{ messageId: null, sentAt: SENT_AT },
|
||||
deliver,
|
||||
),
|
||||
),
|
||||
).toBe('MAIL_016');
|
||||
expect(files.checkUse).toHaveBeenCalledWith(
|
||||
ARA.email,
|
||||
PLAN.fileIds,
|
||||
'send',
|
||||
);
|
||||
expect(sharing.createMailLink).not.toHaveBeenCalled();
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('덩이를 넣은 본문이 한도를 넘으면 400 MAIL_007 — 링크를 만들기 전이다', async () => {
|
||||
const { service, sharing } = setup();
|
||||
const deliver = jest.fn();
|
||||
const long = 'x'.repeat(MAX_BODY_LENGTH - 100);
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.deliverWithLink(
|
||||
ARA,
|
||||
message({ body: long, htmlBody: undefined }),
|
||||
{ ...PLAN, textQuoteStart: null },
|
||||
{ messageId: null, sentAt: SENT_AT },
|
||||
deliver,
|
||||
),
|
||||
),
|
||||
).toBe('MAIL_007');
|
||||
expect(sharing.createMailLink).not.toHaveBeenCalled();
|
||||
expect(deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('링크를 만들고(보낸 사람 · 보낸 때 · Message-ID) 덩이를 인용 위에 넣어 보낸다 → 보낸 사본의 id 를 잇는다', async () => {
|
||||
const { service, sharing } = setup();
|
||||
const deliver = jest
|
||||
.fn<Promise<SentMessage>, [OutgoingMessage]>()
|
||||
.mockResolvedValue(SENT);
|
||||
const result = await service.deliverWithLink(
|
||||
ARA,
|
||||
message(),
|
||||
PLAN,
|
||||
{
|
||||
messageId: 'ara.123@arasystem.test',
|
||||
sentAt: SENT_AT,
|
||||
},
|
||||
deliver,
|
||||
);
|
||||
|
||||
expect(result).toBe(SENT);
|
||||
expect(sharing.createMailLink).toHaveBeenCalledWith({
|
||||
ownerEmail: ARA.email,
|
||||
senderName: ARA.name,
|
||||
files: VIEWS,
|
||||
sentAt: SENT_AT,
|
||||
messageId: 'ara.123@arasystem.test',
|
||||
});
|
||||
const sent = deliver.mock.calls[0][0];
|
||||
expect(sent.htmlBody).toBe(
|
||||
insertHtmlBlock(
|
||||
message().htmlBody ?? '',
|
||||
PLAN.htmlQuoteStart,
|
||||
sentBlock.html,
|
||||
),
|
||||
);
|
||||
expect(sent.body).toBe(
|
||||
insertTextBlock(message().body, PLAN.textQuoteStart, sentBlock.text),
|
||||
);
|
||||
expect(sharing.markSent).toHaveBeenCalledWith('link-1', 'sent-9');
|
||||
expect(sharing.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('보내지 않은 것이 확실한 실패면 링크를 취소한 링크로, 결과를 모르는 실패면 그대로 둔다', async () => {
|
||||
for (const [error, cancels] of [
|
||||
[new BusinessException('MAIL_010'), true],
|
||||
[new BusinessException('MAIL_011'), true],
|
||||
[new BusinessException('VAL_001'), true],
|
||||
[
|
||||
new SendNotStartedException('MAIL_001', {
|
||||
context: { reason: 'send-budget-exhausted' },
|
||||
}),
|
||||
true,
|
||||
],
|
||||
// context 는 로그 전용 — 같은 까닭 글이어도 형식이 아니면 결과를 모르는 실패다
|
||||
[
|
||||
new BusinessException('MAIL_001', {
|
||||
context: { reason: 'send-budget-exhausted' },
|
||||
}),
|
||||
false,
|
||||
],
|
||||
[
|
||||
new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
|
||||
false,
|
||||
],
|
||||
[new Error('socket hang up'), false],
|
||||
] as const) {
|
||||
const { service, sharing } = setup();
|
||||
const deliver = jest.fn().mockRejectedValue(error);
|
||||
await expect(
|
||||
service.deliverWithLink(
|
||||
ARA,
|
||||
message(),
|
||||
PLAN,
|
||||
{ messageId: null, sentAt: SENT_AT },
|
||||
deliver,
|
||||
),
|
||||
).rejects.toBe(error);
|
||||
expect(sharing.cancel).toHaveBeenCalledTimes(cancels ? 1 : 0);
|
||||
if (cancels) {
|
||||
expect(sharing.cancel).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'send-failed',
|
||||
);
|
||||
}
|
||||
expect(sharing.markSent).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('isCertainSendFailure — 받는 사람 모두 거절 · 엔진 거절 · 시작하지 않음(SendNotStartedException)만', () => {
|
||||
expect(isCertainSendFailure(new BusinessException('MAIL_004'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isCertainSendFailure(new SendNotStartedException('MAIL_001')),
|
||||
).toBe(true);
|
||||
expect(isCertainSendFailure(new BusinessException('MAIL_001'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCertainSendFailure(new BusinessException('MAIL_005'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isCertainSendFailure('x')).toBe(false);
|
||||
});
|
||||
|
||||
it('한 통의 개수 — maxFilesPerMessage 를 넘으면 400 VAL_001', () => {
|
||||
const { service } = setup();
|
||||
expect(() =>
|
||||
service.assertCount(Array.from({ length: 10 }, String)),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
service.assertCount(Array.from({ length: 11 }, String)),
|
||||
).toThrow(BusinessException);
|
||||
});
|
||||
|
||||
it('같은 키의 재시도 · 보내기 취소는 링크 쪽에 넘긴다 — 취소는 파일을 임시 저장 기간으로 돌린다', async () => {
|
||||
const { service, sharing, files } = setup();
|
||||
await service.attachRetry(ARA, 'mid', 'sent-1');
|
||||
expect(sharing.attachRetry).toHaveBeenCalledWith(
|
||||
ARA.email,
|
||||
'mid',
|
||||
'sent-1',
|
||||
);
|
||||
|
||||
sharing.findBySentEmail.mockResolvedValue(record());
|
||||
await service.onPendingSendCanceled(ARA, 'sent-1');
|
||||
expect(sharing.cancel).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'send-canceled',
|
||||
);
|
||||
expect(files.attachToDraft).toHaveBeenCalledWith(ARA.email, [
|
||||
FILE_A,
|
||||
FILE_B,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('읽기 — 덩이 알아보기', () => {
|
||||
const html = `<div><p>내 글</p>${sentBlock.html}<p>인용</p></div>`;
|
||||
|
||||
it('표시가 없으면 DB 를 보지 않는다', async () => {
|
||||
const { service, sharing } = setup();
|
||||
expect(
|
||||
await service.recognize(ARA, '<p>그냥 글</p>', ARA.email),
|
||||
).toBeNull();
|
||||
expect(sharing.findByTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('표시 사이의 우리 링크이고 링크 주인이 보낸 사람(From)이면 파일 · 표시를 준다 — 받은 수는 주인만', async () => {
|
||||
const { service, sharing } = setup();
|
||||
sharing.findByTokens.mockResolvedValue(new Map([[TOKEN, record()]]));
|
||||
sharing.fileStatuses.mockResolvedValue(
|
||||
new Map([
|
||||
[`link-1:${FILE_A}`, 'available'],
|
||||
[`link-1:${FILE_B}`, 'scanning'],
|
||||
]),
|
||||
);
|
||||
|
||||
const mine = await service.recognize(ARA, html, 'Ara.Kim@ArASystem.test');
|
||||
expect(sharing.findByTokens).toHaveBeenCalledWith([TOKEN]);
|
||||
expect(mine?.largeFiles).toEqual([
|
||||
{
|
||||
linkId: 'link-1',
|
||||
fileId: FILE_A,
|
||||
name: '설계도.dwg',
|
||||
size: 1_000,
|
||||
status: 'available',
|
||||
expiresAt: EXPIRES.toISOString(),
|
||||
revokedAt: null,
|
||||
url: shareUrl(ORIGIN, TOKEN),
|
||||
downloads: { count: 3, lastDownloadedAt: '2026-11-01T00:00:00.000Z' },
|
||||
},
|
||||
expect.objectContaining({ fileId: FILE_B, status: 'scanning' }),
|
||||
]);
|
||||
// 정화하면 그 덩이의 바깥에만 표시가 붙는다
|
||||
const sanitized = sanitizeMailHtml(mine?.html ?? '', new Map(), {
|
||||
largeFilesMarker: mine?.marker,
|
||||
}).html;
|
||||
expect(sanitized.match(/data-ara-large-files/g)).toHaveLength(1);
|
||||
expect(sanitized).toMatch(
|
||||
/^<div><p>내 글<\/p><div data-ara-large-files><table/,
|
||||
);
|
||||
|
||||
// 사내 받는 사람(링크 주인이 아님)에게는 받은 수가 없다
|
||||
const theirs = await service.recognize(DOYUN, html, ARA.email);
|
||||
expect(theirs?.largeFiles[0].downloads).toBeNull();
|
||||
|
||||
// 끊은 링크 — 끊은 때를 함께 준다 (화면의 "…에 끊었습니다")
|
||||
const revokedAt = new Date('2026-11-02T03:04:05.000Z');
|
||||
sharing.findByTokens.mockResolvedValue(
|
||||
new Map([[TOKEN, record({ revokedAt, revokedBy: 'owner' })]]),
|
||||
);
|
||||
const revoked = await service.recognize(ARA, html, ARA.email);
|
||||
expect(revoked?.largeFiles[0].revokedAt).toBe(revokedAt.toISOString());
|
||||
});
|
||||
|
||||
it('링크 주인이 보낸 사람이 아니면(바깥 사람의 인용 · 전달) 알아보지 않는다', async () => {
|
||||
const { service, sharing } = setup();
|
||||
sharing.findByTokens.mockResolvedValue(new Map([[TOKEN, record()]]));
|
||||
expect(
|
||||
await service.recognize(ARA, html, 'partner@example.test'),
|
||||
).toBeNull();
|
||||
expect(await service.recognize(ARA, html, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('우리 링크 모양이 아니면(다른 주소 · 표시 밖의 링크) 묻지 않는다', async () => {
|
||||
const { service, sharing } = setup();
|
||||
const lookalike = html.replaceAll(ORIGIN, 'https://files.evil.example');
|
||||
expect(await service.recognize(ARA, lookalike, ARA.email)).toBeNull();
|
||||
expect(sharing.findByTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('임시 저장본', () => {
|
||||
const draft = (overrides: Partial<DraftSource> = {}): DraftSource => ({
|
||||
id: 'sent-1',
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '제목',
|
||||
textBody: null,
|
||||
rawHtmlBody: null,
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
updatedAt: SENT_AT.toISOString(),
|
||||
isCanceledSend: false,
|
||||
largeFileIds: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('머리글의 파일 — 그 직원의 것만 (파일 모듈이 고른다)', async () => {
|
||||
const { service, files } = setup();
|
||||
await service.draftView(ARA, draft({ largeFileIds: [FILE_A] }));
|
||||
expect(files.draftFiles).toHaveBeenCalledWith(ARA.email, [FILE_A]);
|
||||
});
|
||||
|
||||
it('취소해 되돌린 발송 — 서버가 넣은 덩이를 HTML · 텍스트에서 떼고 그 링크의 파일을 돌려준다 (다시 보내면 덩이 하나)', async () => {
|
||||
const { service, sharing, files } = setup();
|
||||
sharing.findBySentEmail.mockResolvedValue(
|
||||
record({ canceledAt: SENT_AT }),
|
||||
);
|
||||
const originalHtml = '<div><p>내 글</p><p>인용</p></div>';
|
||||
const originalText = '내 글\n\n----- 원본 메일 -----\n> 인용';
|
||||
const result = await service.draftView(
|
||||
ARA,
|
||||
draft({
|
||||
isCanceledSend: true,
|
||||
rawHtmlBody: insertHtmlBlock(
|
||||
originalHtml,
|
||||
'<div><p>내 글</p>'.length,
|
||||
sentBlock.html,
|
||||
),
|
||||
textBody: insertTextBlock(
|
||||
originalText,
|
||||
'내 글\n\n'.length,
|
||||
sentBlock.text,
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(result.rawHtmlBody).toBe(originalHtml);
|
||||
expect(result.textBody).toBe(originalText);
|
||||
expect(files.draftFiles).toHaveBeenCalledWith(ARA.email, [
|
||||
FILE_A,
|
||||
FILE_B,
|
||||
]);
|
||||
});
|
||||
|
||||
it('텍스트만 보낸 발송도 주소의 토큰으로 링크를 맞춰 뗀다 · 손댄 덩이는 남긴다', async () => {
|
||||
const { service, sharing } = setup();
|
||||
sharing.findBySentEmail.mockResolvedValue(record());
|
||||
const text = insertTextBlock('내 글', null, sentBlock.text);
|
||||
const stripped = await service.draftView(
|
||||
ARA,
|
||||
draft({ isCanceledSend: true, textBody: text }),
|
||||
);
|
||||
expect(stripped.textBody).toBe('내 글\n');
|
||||
|
||||
const edited = text.replace('설계도.dwg', '설계도_고침.dwg');
|
||||
const kept = await service.draftView(
|
||||
ARA,
|
||||
draft({ isCanceledSend: true, textBody: edited }),
|
||||
);
|
||||
expect(kept.textBody).toBe(edited);
|
||||
});
|
||||
|
||||
it('다른 링크의 덩이(토큰이 다름)는 떼지 않는다', async () => {
|
||||
const { service, sharing } = setup();
|
||||
sharing.findBySentEmail.mockResolvedValue(
|
||||
record({ tokenHash: hashShareToken('Z'.repeat(43)) }),
|
||||
);
|
||||
const html = `<p>글</p>${sentBlock.html}`;
|
||||
const result = await service.draftView(
|
||||
ARA,
|
||||
draft({ isCanceledSend: true, rawHtmlBody: html }),
|
||||
);
|
||||
expect(result.rawHtmlBody).toBe(html);
|
||||
});
|
||||
});
|
||||
|
||||
describe('보낸 링크 · 위험 판정 알림', () => {
|
||||
const summary = {
|
||||
id: 'link-1',
|
||||
sentEmailId: 'sent-1',
|
||||
sentAt: SENT_AT.toISOString(),
|
||||
expiresAt: EXPIRES.toISOString(),
|
||||
status: 'active' as const,
|
||||
files: [],
|
||||
};
|
||||
|
||||
it('목록은 보낸 메일 제목을 한 번에 읽는다 — 읽지 못하면 제목 없이 그대로', async () => {
|
||||
const { service, sharing, provider } = setup();
|
||||
sharing.listOwned.mockResolvedValue({
|
||||
items: [summary, { ...summary, id: 'link-2', sentEmailId: null }],
|
||||
pageInfo: { hasNext: false, nextCursor: null, total: 2 },
|
||||
});
|
||||
provider.getMessageSubjects.mockResolvedValue(
|
||||
new Map([['sent-1', '도면']]),
|
||||
);
|
||||
const page = await service.listShareLinks(ARA, { limit: 20 });
|
||||
expect(page.items.map((item) => item.subject)).toEqual(['도면', null]);
|
||||
expect(provider.getMessageSubjects).toHaveBeenCalledWith(ARA.email, [
|
||||
'sent-1',
|
||||
]);
|
||||
|
||||
provider.getMessageSubjects.mockRejectedValue(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
const fallback = await service.listShareLinks(ARA, { limit: 20 });
|
||||
expect(fallback.items.map((item) => item.subject)).toEqual([null, null]);
|
||||
});
|
||||
|
||||
it('위험 판정이면 그 직원의 연결에 large-file 을 보낸다 (깨끗함 · 검사하지 못함은 보내지 않는다)', () => {
|
||||
const { events, verdict } = setup();
|
||||
verdict({
|
||||
ownerEmail: ARA.email,
|
||||
fileId: FILE_A,
|
||||
name: '견적서.zip',
|
||||
scanStatus: 'infected',
|
||||
});
|
||||
verdict({
|
||||
ownerEmail: ARA.email,
|
||||
fileId: FILE_B,
|
||||
name: '도면.dwg',
|
||||
scanStatus: 'clean',
|
||||
});
|
||||
expect(events.notify).toHaveBeenCalledTimes(1);
|
||||
expect(events.notify).toHaveBeenCalledWith(ARA.email, {
|
||||
type: 'large-file',
|
||||
data: { fileId: FILE_A, name: '견적서.zip', scanStatus: 'infected' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,640 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import type { PaginatedResult } from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { FileSharingService } from '@/modules/file-sharing/file-sharing.service';
|
||||
import type {
|
||||
ShareLinkRecord,
|
||||
ShareLinkSummary,
|
||||
} from '@/modules/file-sharing/file-sharing.types';
|
||||
import {
|
||||
hashShareToken,
|
||||
shareUrl,
|
||||
tokenFromShareUrl,
|
||||
} from '@/modules/file-sharing/share-token';
|
||||
import { FileScanService } from '@/modules/file-storage/file-scan.service';
|
||||
import { FileStorageService } from '@/modules/file-storage/file-storage.service';
|
||||
import type {
|
||||
FileUseProblem,
|
||||
LargeFile,
|
||||
MissingLargeFile,
|
||||
StoredFileView,
|
||||
} from '@/modules/file-storage/file-storage.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { kstEndOfDayAfter } from '@/shared/utils/kst-date';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { checkHtmlParseBudget } from './html/html-parse-budget';
|
||||
import { LARGE_FILES_MARKER_ATTRIBUTE } from './html/sanitize-mail-html';
|
||||
import {
|
||||
buildLargeFileBlock,
|
||||
findHtmlBlock,
|
||||
hrefsIn,
|
||||
insertHtmlBlock,
|
||||
insertTextBlock,
|
||||
removeHtmlBlock,
|
||||
removeTextBlock,
|
||||
type LargeFileBlock,
|
||||
} from './large-file-block';
|
||||
import { MAX_BODY_LENGTH, MAX_BODY_VALUE_BYTES } from './mail.constants';
|
||||
import { MailEventsService } from './mail-events.service';
|
||||
import {
|
||||
MAIL_PROVIDER,
|
||||
SendNotStartedException,
|
||||
type MailProvider,
|
||||
} from './mail.provider';
|
||||
import type {
|
||||
DraftSource,
|
||||
MessageLargeFile,
|
||||
OutgoingMessage,
|
||||
SentMessage,
|
||||
} from './mail.types';
|
||||
|
||||
/** 한 통에서 알아보는 링크 수 (계약 4절 "우리 웹메일에서 읽기" — 한 통에 10개까지 본다) */
|
||||
const MAX_RECOGNIZED_LINKS = 10;
|
||||
/** 덩이 안에서 읽는 링크 주소 수 — 링크마다 주소가 두 번(href) 나오지 않게 넉넉히 */
|
||||
const MAX_BLOCK_HREFS = 40;
|
||||
/** 크기를 미리 재는 덩이의 자리 토큰 — 진짜 토큰과 길이가 같다(43자) */
|
||||
const PLACEHOLDER_TOKEN = 'x'.repeat(43);
|
||||
|
||||
/**
|
||||
* 보내지 않은 것이 확실한 발송 실패 — 그 링크를 취소한 링크로 둔다 (계약 4절 "링크를 만드는 때 · 발송이 실패하면").
|
||||
* 받는 사람 모두 거절 · 엔진이 요청을 거절함 · 엔진의 요청 수 한도, 그리고 어댑터가 SendNotStartedException 으로 알린 "시작하지 않음"(시간이 모자람).
|
||||
* 그 밖(제출 중 시간 초과 · 연결 끊김 MAIL_001 · 알 수 없는 오류)은 나갔을 수 있어 그대로 둔다. context(로그 전용)는 보지 않는다
|
||||
*/
|
||||
const CERTAIN_SEND_FAILURES: ReadonlySet<string> = new Set([
|
||||
'MAIL_010',
|
||||
'MAIL_011',
|
||||
'VAL_001',
|
||||
'MAIL_004',
|
||||
'FILE_002',
|
||||
'MAIL_003',
|
||||
'AUTH_003',
|
||||
'REQ_001',
|
||||
]);
|
||||
export function isCertainSendFailure(error: unknown): boolean {
|
||||
if (error instanceof SendNotStartedException) return true;
|
||||
return (
|
||||
error instanceof BusinessException && CERTAIN_SEND_FAILURES.has(error.code)
|
||||
);
|
||||
}
|
||||
|
||||
/** 발송에 실을 대용량 파일과 덩이 자리 — 서비스가 본문을 준비하며 정한다 */
|
||||
export interface LargeFilePlan {
|
||||
/** 이 메일에 새로 싣는 내 파일 (요청 차례, 겹치지 않게, 소문자) */
|
||||
readonly fileIds: readonly string[];
|
||||
/** 서식 본문의 덩이 자리 (첫 인용 경계 표시 — 없으면 맨 끝) */
|
||||
readonly htmlQuoteStart: number | null;
|
||||
/** 텍스트 본문의 덩이 자리 (bodyQuoteStart — 없으면 맨 끝) */
|
||||
readonly textQuoteStart: number | null;
|
||||
}
|
||||
|
||||
/** 덩이를 넣은 뒤 보낸다 — 메일 서비스의 발송 함수 */
|
||||
export type DeliverFunction = (
|
||||
message: OutgoingMessage,
|
||||
) => Promise<SentMessage>;
|
||||
|
||||
/** 읽기 화면이 알아본 덩이 — 표시를 붙일 원문과 파일 */
|
||||
export interface RecognizedLargeFiles {
|
||||
readonly largeFiles: MessageLargeFile[];
|
||||
/** 덩이를 `<div data-ara-lf="<marker>">` 로 감싼 원문 HTML (정화 전) */
|
||||
readonly html: string;
|
||||
readonly marker: string;
|
||||
}
|
||||
|
||||
/** 보낸 사람의 링크 하나 (계약 4절 ShareLink) — 메일 제목을 더한다 */
|
||||
export type ShareLinkView = ShareLinkSummary & {
|
||||
readonly subject: string | null;
|
||||
};
|
||||
|
||||
/** 쓸 수 없는 대용량 파일 — 400 MAIL_016 (메일은 나가지 않았다 · 저장하지 않았다) */
|
||||
function unusableFiles(
|
||||
problems: ReadonlyMap<string, FileUseProblem>,
|
||||
use: 'send' | 'draft',
|
||||
): BusinessException {
|
||||
return new BusinessException('MAIL_016', {
|
||||
context: {
|
||||
reason: `large-file-not-${use === 'send' ? 'sendable' : 'savable'}`,
|
||||
problems: Object.fromEntries(problems),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일과 대용량 첨부(11단계 — 계약 4절 "발송 · 임시 저장과 함께" · "우리 웹메일에서 읽기" · "보낸 사람 관리")를 잇는다.
|
||||
* 파일은 파일 모듈, 링크는 공유 모듈이 맡고 — 메일 → 공유 → 저장 한 방향 — 이 서비스는 메일 쪽 규칙(덩이 · 자리 · 머리글 · 보낸 사람)만 둔다.
|
||||
* 위험 판정 소식을 실시간 알림(`large-file`)으로 보낸다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(MailLargeFilesService.name);
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly files: FileStorageService,
|
||||
private readonly sharing: FileSharingService,
|
||||
private readonly scans: FileScanService,
|
||||
private readonly events: MailEventsService,
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
// 위험 판정 → 그 직원의 열린 연결에 `large-file` (연결이 없던 직원은 목록 상태로 안다 — 계약 4절 "정한 것" 2)
|
||||
this.unsubscribe = this.scans.onVerdict((event) => {
|
||||
if (event.scanStatus !== 'infected') return;
|
||||
this.events.notify(event.ownerEmail, {
|
||||
type: 'large-file',
|
||||
data: {
|
||||
fileId: event.fileId,
|
||||
name: event.name,
|
||||
scanStatus: 'infected',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
/** 메일 한 통에 새로 싣는 대용량 파일 수 한도 — 넘으면 400 VAL_001 (보낼 때) */
|
||||
assertCount(fileIds: readonly string[]): void {
|
||||
const { maxFilesPerMessage } = this.files.limits;
|
||||
if (fileIds.length > maxFilesPerMessage) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'too-many-large-files',
|
||||
count: fileIds.length,
|
||||
limit: maxFilesPerMessage,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 발송 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 덩이를 넣어 보낸다 — 계약 4절 "지문 · 같은 키 재시도" · "링크를 만드는 때": 정화 · 서명 그림 · 지문 · 중복 확인을 **마친 뒤** 부른다.
|
||||
* 1) 파일 확인 — 내 것 · 다 올림 · 위험 아님 (아니면 400 MAIL_016 — 엔진에 묻기 전이다)
|
||||
* 2) 같은 길이의 자리 토큰으로 덩이를 넣어 본문 한도를 다시 잰다 (넘으면 400 MAIL_007 — 메일도 링크도 만들지 않는다)
|
||||
* 3) 링크를 만들고(토큰 · 보낸 사람 · 끝나는 때) 덩이를 넣어 보낸다 → 성공하면 보낸 사본의 메일 id 를 잇는다
|
||||
* 4) 보내지 않은 것이 확실한 실패면 그 링크를 취소한 링크로, 결과를 모르는 실패면 그대로 둔다
|
||||
*/
|
||||
async deliverWithLink(
|
||||
user: AuthenticatedUser,
|
||||
message: OutgoingMessage,
|
||||
plan: LargeFilePlan,
|
||||
context: { readonly messageId: string | null; readonly sentAt: Date },
|
||||
deliver: DeliverFunction,
|
||||
): Promise<SentMessage> {
|
||||
const files = await this.requireUsable(user, plan.fileIds, 'send');
|
||||
const expiresAt = kstEndOfDayAfter(
|
||||
context.sentAt,
|
||||
this.files.limits.retentionDays,
|
||||
);
|
||||
// 덩이를 넣은 크기를 미리 잰다 — 토큰 길이가 같아 진짜 덩이와 같은 크기다
|
||||
this.withBlock(
|
||||
message,
|
||||
plan,
|
||||
buildLargeFileBlock({
|
||||
files,
|
||||
expiresAt,
|
||||
url: shareUrl(this.sharing.publicOrigin, PLACEHOLDER_TOKEN),
|
||||
}),
|
||||
);
|
||||
|
||||
const created = await this.sharing.createMailLink({
|
||||
ownerEmail: user.email,
|
||||
senderName: user.name || null,
|
||||
files,
|
||||
sentAt: context.sentAt,
|
||||
messageId: context.messageId,
|
||||
});
|
||||
const outgoing = this.withBlock(
|
||||
message,
|
||||
plan,
|
||||
buildLargeFileBlock({
|
||||
files,
|
||||
expiresAt: created.record.link.expiresAt,
|
||||
url: created.url,
|
||||
}),
|
||||
);
|
||||
|
||||
let result: SentMessage;
|
||||
try {
|
||||
result = await deliver(outgoing);
|
||||
} catch (error) {
|
||||
if (isCertainSendFailure(error)) {
|
||||
await this.sharing
|
||||
.cancel(created.record, 'send-failed')
|
||||
.catch((cancelError: unknown) =>
|
||||
this.warn(
|
||||
'보내지 못한 발송의 링크를 취소하지 못했습니다',
|
||||
user,
|
||||
cancelError,
|
||||
),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.sharing
|
||||
.markSent(created.record.link.id, result.id)
|
||||
.catch((markError: unknown) =>
|
||||
this.warn('보낸 메일 id 를 링크에 잇지 못했습니다', user, markError),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 Idempotency-Key 의 재시도가 처음 메일을 찾았다 — 처음 시도가 만든 링크(보낸 메일을 모르는 것)에 보낸 사본을 잇는다. 링크를 새로 만들지 않는다
|
||||
*/
|
||||
async attachRetry(
|
||||
user: AuthenticatedUser,
|
||||
messageId: string,
|
||||
sentId: string,
|
||||
): Promise<void> {
|
||||
await this.sharing
|
||||
.attachRetry(user.email, messageId, sentId)
|
||||
.catch((error: unknown) =>
|
||||
this.warn(
|
||||
'같은 키 재시도의 링크에 보낸 메일 id 를 잇지 못했습니다',
|
||||
user,
|
||||
error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 붙잡은 발송을 취소했다(9단계 — 엔진에서 확인된 뒤) — 그 발송이 만든 링크를 취소한 링크로 두고, 파일은 임시 저장 기간으로 돌린다
|
||||
* (되돌린 초안이 쓴다 — 보내지 않은 파일 목록에 다시 나온다). 실패해도 취소는 된 것이다 — 경고만
|
||||
*/
|
||||
async onPendingSendCanceled(
|
||||
user: AuthenticatedUser,
|
||||
sentId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const record = await this.sharing.findBySentEmail(user.email, sentId);
|
||||
if (!record) return;
|
||||
await this.sharing.cancel(record, 'send-canceled');
|
||||
await this.files.attachToDraft(
|
||||
user.email,
|
||||
record.files.map((file) => file.fileId),
|
||||
);
|
||||
} catch (error) {
|
||||
this.warn('취소한 발송의 링크를 취소하지 못했습니다', user, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 임시 저장 ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 저장할 파일 확인 — 내 것이면 올리는 중 · 위험 판정이어도 된다. 없음 · 남의 것 · 지움 · 기간 지남은 400 MAIL_016 */
|
||||
async requireDraftable(
|
||||
user: AuthenticatedUser,
|
||||
fileIds: readonly string[],
|
||||
): Promise<void> {
|
||||
if (fileIds.length === 0) return;
|
||||
await this.requireUsable(user, fileIds, 'draft');
|
||||
}
|
||||
|
||||
/** 저장했다 — 그 파일들을 "임시 저장에 실린 파일"로 (남는 기간이 는다). 실패해도 저장은 된 것이다 — 경고만 */
|
||||
async afterDraftSaved(
|
||||
user: AuthenticatedUser,
|
||||
fileIds: readonly string[],
|
||||
): Promise<void> {
|
||||
if (fileIds.length === 0) return;
|
||||
await this.files
|
||||
.attachToDraft(user.email, fileIds)
|
||||
.catch((error: unknown) =>
|
||||
this.warn(
|
||||
'임시 저장에 실린 대용량 파일을 적지 못했습니다',
|
||||
user,
|
||||
error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이어 쓸 임시 저장본의 대용량 파일 · 본문 (GET /api/drafts/{id}) — 머리글의 파일 가운데 그 직원의 것만.
|
||||
* 취소해 되돌린 발송(9단계)이면 그 링크의 파일을 더하고, 서버가 넣은 덩이를 원문 HTML(정화 전) · 텍스트에서 뗀다 — 다시 보내면 새 덩이 하나
|
||||
*/
|
||||
async draftView(
|
||||
user: AuthenticatedUser,
|
||||
source: DraftSource,
|
||||
): Promise<{
|
||||
readonly rawHtmlBody: string | null;
|
||||
readonly textBody: string | null;
|
||||
readonly largeFiles: (LargeFile | MissingLargeFile)[];
|
||||
}> {
|
||||
let { rawHtmlBody, textBody } = source;
|
||||
const ids = [...source.largeFileIds];
|
||||
if (source.isCanceledSend) {
|
||||
const record = await this.sharing.findBySentEmail(user.email, source.id);
|
||||
if (record) {
|
||||
const stripped = this.stripBlock(record, rawHtmlBody, textBody);
|
||||
rawHtmlBody = stripped.rawHtmlBody;
|
||||
textBody = stripped.textBody;
|
||||
for (const file of record.files) {
|
||||
if (!ids.includes(file.fileId)) ids.push(file.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
rawHtmlBody,
|
||||
textBody,
|
||||
largeFiles: await this.files.draftFiles(user.email, ids),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 읽기 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 원문 HTML 의 덩이를 알아본다 (계약 4절 "우리 웹메일에서 읽기") — 셋이 모두 맞을 때만:
|
||||
* 표시 주석 사이의 링크이고(표시가 없으면 DB 를 보지 않는다), href 가 `<SHARE_PUBLIC_URL>/s/<토큰>` 이고 토큰이 우리 기록에 있으며(취소한 링크는 빼고 · 10개까지),
|
||||
* 그 링크를 만든 직원이 그 메일의 보낸 사람(From — 대소문자 무시)이다. 아니면 null(본문 글 그대로)
|
||||
*/
|
||||
async recognize(
|
||||
user: AuthenticatedUser,
|
||||
rawHtmlBody: string,
|
||||
from: string | null,
|
||||
): Promise<RecognizedLargeFiles | null> {
|
||||
const found = await this.recognizedRecords(rawHtmlBody, from);
|
||||
if (found === null) return null;
|
||||
const statuses = await this.sharing.fileStatuses(
|
||||
found.records.map(({ record }) => record),
|
||||
);
|
||||
const viewer = user.email.toLowerCase();
|
||||
const largeFiles = found.records.flatMap(({ record, token }) =>
|
||||
record.files.map(
|
||||
(file): MessageLargeFile => ({
|
||||
linkId: record.link.id,
|
||||
fileId: file.fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: statuses.get(`${record.link.id}:${file.fileId}`) ?? 'missing',
|
||||
expiresAt: record.link.expiresAt.toISOString(),
|
||||
revokedAt: record.link.revokedAt?.toISOString() ?? null,
|
||||
url: shareUrl(this.sharing.publicOrigin, token),
|
||||
downloads:
|
||||
record.link.ownerEmail === viewer
|
||||
? {
|
||||
count: file.downloadCount,
|
||||
lastDownloadedAt:
|
||||
file.lastDownloadedAt?.toISOString() ?? null,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
// 정화가 이 div 에만 data-ara-large-files 를 붙인다 — 요청마다 무작위라 보낸 사람이 흉내 낼 수 없다
|
||||
const marker = randomBytes(16).toString('hex');
|
||||
const { start, end } = found.block;
|
||||
const html =
|
||||
rawHtmlBody.slice(0, start) +
|
||||
`<div ${LARGE_FILES_MARKER_ATTRIBUTE}="${marker}">` +
|
||||
rawHtmlBody.slice(start, end) +
|
||||
'</div>' +
|
||||
rawHtmlBody.slice(end);
|
||||
return { largeFiles, html, marker };
|
||||
}
|
||||
|
||||
/**
|
||||
* 우리 웹메일에서 받기 (GET · HEAD /api/messages/{id}/large-files/{fileId}) — 그 메일에서 첨부처럼 올린 파일만(아니면 404 RES_001).
|
||||
* 머리글 · 범위 · 상태별 실패는 공개 받기와 같다. 보는 사람이 링크를 만든 사람이면 세지 않는다(보낸 사람 자신의 확인)
|
||||
*/
|
||||
async serveMessageFile(
|
||||
user: AuthenticatedUser,
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const detail = await this.provider.getMessage(user.email, messageId);
|
||||
const raw = detail.rawHtmlBody;
|
||||
const found =
|
||||
raw !== null && checkHtmlParseBudget(raw).ok
|
||||
? await this.recognizedRecords(raw, detail.from?.email ?? null)
|
||||
: null;
|
||||
const record = found?.records.find(({ record: candidate }) =>
|
||||
candidate.files.some((file) => file.fileId === fileId),
|
||||
)?.record;
|
||||
if (!record) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'large-file-not-in-message' },
|
||||
});
|
||||
}
|
||||
await this.sharing.serveFile(req, res, record, fileId, {
|
||||
count: record.link.ownerEmail !== user.email.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 보낸 사람 관리 (/api/share-links) ──────────────────────────────────────
|
||||
|
||||
/** 내 링크 — 보낸 메일 제목을 엔진에서 한 번에 읽는다(지웠거나 읽지 못하면 null — 목록은 그대로 준다, warn) */
|
||||
async listShareLinks(
|
||||
user: AuthenticatedUser,
|
||||
query: { readonly cursor?: string; readonly limit: number },
|
||||
): Promise<PaginatedResult<ShareLinkView>> {
|
||||
const page = await this.sharing.listOwned(user.email, query);
|
||||
const subjects = await this.subjectsOf(user, page.items);
|
||||
return {
|
||||
items: page.items.map((item) => ({
|
||||
...item,
|
||||
subject:
|
||||
item.sentEmailId === null
|
||||
? null
|
||||
: (subjects.get(item.sentEmailId) ?? null),
|
||||
})),
|
||||
pageInfo: page.pageInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/** 링크 끊기 — 그 링크(메일)의 파일 모두. 남의 링크 · 없는 것 404 */
|
||||
async revokeShareLink(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
): Promise<ShareLinkView> {
|
||||
const summary = await this.sharing.revoke(user.email, id);
|
||||
const subjects = await this.subjectsOf(user, [summary]);
|
||||
return {
|
||||
...summary,
|
||||
subject:
|
||||
summary.sentEmailId === null
|
||||
? null
|
||||
: (subjects.get(summary.sentEmailId) ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 내부 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private async requireUsable(
|
||||
user: AuthenticatedUser,
|
||||
fileIds: readonly string[],
|
||||
use: 'send' | 'draft',
|
||||
): Promise<StoredFileView[]> {
|
||||
const { usable, problems } = await this.files.checkUse(
|
||||
user.email,
|
||||
fileIds,
|
||||
use,
|
||||
);
|
||||
if (problems.size > 0) throw unusableFiles(problems, use);
|
||||
return usable;
|
||||
}
|
||||
|
||||
/** 덩이를 넣은 메일 — 넣은 뒤 본문 한도를 다시 잰다 (텍스트 100,000자 · 서식 본문 2,000,000바이트 — 넘으면 400 MAIL_007) */
|
||||
private withBlock(
|
||||
message: OutgoingMessage,
|
||||
plan: LargeFilePlan,
|
||||
block: LargeFileBlock,
|
||||
): OutgoingMessage {
|
||||
const body = insertTextBlock(message.body, plan.textQuoteStart, block.text);
|
||||
const htmlBody = message.htmlBody
|
||||
? insertHtmlBlock(message.htmlBody, plan.htmlQuoteStart, block.html)
|
||||
: undefined;
|
||||
if (body.length > MAX_BODY_LENGTH) {
|
||||
throw new BusinessException('MAIL_007', {
|
||||
context: {
|
||||
reason: 'body-too-long-with-large-files',
|
||||
length: body.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
htmlBody !== undefined &&
|
||||
Buffer.byteLength(htmlBody, 'utf8') > MAX_BODY_VALUE_BYTES
|
||||
) {
|
||||
throw new BusinessException('MAIL_007', {
|
||||
context: { reason: 'body-html-too-large-with-large-files' },
|
||||
});
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
body,
|
||||
...(htmlBody !== undefined ? { htmlBody } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 원문 HTML 의 덩이 · 그 안의 우리 링크 가운데 보낸 사람의 것 — 없으면 null */
|
||||
private async recognizedRecords(
|
||||
rawHtmlBody: string,
|
||||
from: string | null,
|
||||
): Promise<{
|
||||
readonly block: { readonly start: number; readonly end: number };
|
||||
readonly records: {
|
||||
readonly token: string;
|
||||
readonly record: ShareLinkRecord;
|
||||
}[];
|
||||
} | null> {
|
||||
if (from === null) return null;
|
||||
const block = findHtmlBlock(rawHtmlBody);
|
||||
if (block === null) return null;
|
||||
const tokens = [
|
||||
...new Set(
|
||||
hrefsIn(rawHtmlBody.slice(block.start, block.end), MAX_BLOCK_HREFS)
|
||||
.map((href) => tokenFromShareUrl(this.sharing.publicOrigin, href))
|
||||
.filter((token): token is string => token !== null),
|
||||
),
|
||||
].slice(0, MAX_RECOGNIZED_LINKS);
|
||||
if (tokens.length === 0) return null;
|
||||
const found = await this.sharing.findByTokens(tokens);
|
||||
const sender = from.trim().toLowerCase();
|
||||
const records = tokens.flatMap((token) => {
|
||||
const record = found.get(token);
|
||||
return record && record.link.ownerEmail === sender
|
||||
? [{ token, record }]
|
||||
: [];
|
||||
});
|
||||
return records.length === 0 ? null : { block, records };
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소해 되돌린 발송의 덩이를 뗀다 — HTML 은 표시 사이(그 링크의 토큰이 있을 때만), 텍스트는 서버가 넣은 글 그대로일 때만.
|
||||
* 토큰은 HTML 덩이의 링크에서(없으면 텍스트의 주소에서) 찾아 링크의 해시와 맞춘다 — 덩이를 다시 만들어 글 그대로 찾는다
|
||||
*/
|
||||
private stripBlock(
|
||||
record: ShareLinkRecord,
|
||||
rawHtmlBody: string | null,
|
||||
textBody: string | null,
|
||||
): { rawHtmlBody: string | null; textBody: string | null } {
|
||||
const origin = this.sharing.publicOrigin;
|
||||
const matches = (token: string | null): token is string =>
|
||||
token !== null && hashShareToken(token).equals(record.link.tokenHash);
|
||||
|
||||
let token: string | null = null;
|
||||
let html = rawHtmlBody;
|
||||
const block = html === null ? null : findHtmlBlock(html);
|
||||
if (html !== null && block !== null) {
|
||||
token =
|
||||
hrefsIn(html.slice(block.start, block.end), MAX_BLOCK_HREFS)
|
||||
.map((href) => tokenFromShareUrl(origin, href))
|
||||
.find(matches) ?? null;
|
||||
if (token !== null) html = removeHtmlBlock(html, block);
|
||||
}
|
||||
if (token === null && textBody !== null) {
|
||||
token = this.tokenInText(textBody, matches);
|
||||
}
|
||||
|
||||
let text = textBody;
|
||||
if (token !== null && text !== null) {
|
||||
const { text: blockText } = buildLargeFileBlock({
|
||||
files: record.files,
|
||||
expiresAt: record.link.expiresAt,
|
||||
url: shareUrl(origin, token),
|
||||
});
|
||||
text = removeTextBlock(text, blockText) ?? text;
|
||||
}
|
||||
return { rawHtmlBody: html, textBody: text };
|
||||
}
|
||||
|
||||
/** 텍스트의 공개 화면 주소 가운데 그 링크의 토큰 */
|
||||
private tokenInText(
|
||||
text: string,
|
||||
matches: (token: string | null) => token is string,
|
||||
): string | null {
|
||||
const prefix = `${this.sharing.publicOrigin}/s/`;
|
||||
for (
|
||||
let at = text.indexOf(prefix);
|
||||
at >= 0;
|
||||
at = text.indexOf(prefix, at + prefix.length)
|
||||
) {
|
||||
const candidate = text.slice(at + prefix.length, at + prefix.length + 43);
|
||||
if (matches(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 링크들의 보낸 메일 제목 — 읽지 못하면 빈 맵(warn) */
|
||||
private async subjectsOf(
|
||||
user: AuthenticatedUser,
|
||||
links: readonly ShareLinkSummary[],
|
||||
): Promise<Map<string, string>> {
|
||||
const ids = links
|
||||
.map((link) => link.sentEmailId)
|
||||
.filter((id): id is string => id !== null);
|
||||
if (ids.length === 0) return new Map();
|
||||
try {
|
||||
return await this.provider.getMessageSubjects(user.email, ids);
|
||||
} catch (error) {
|
||||
this.warn(
|
||||
'보낸 링크의 메일 제목을 읽지 못해 제목 없이 답합니다',
|
||||
user,
|
||||
error,
|
||||
);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
private warn(what: string, user: AuthenticatedUser, error: unknown): void {
|
||||
const code = error instanceof BusinessException ? error.code : 'SYS_001';
|
||||
this.logger.warn(
|
||||
`[${code}]${traceTag()} ${what}: user=${maskEmail(user.email)}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -212,5 +212,5 @@ export const MAX_DISPLAY_NAME_LENGTH = 200;
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) */
|
||||
export const MAX_EMAIL_LENGTH = 254;
|
||||
|
||||
/** 첨부 파일명 최대 길이 */
|
||||
export const MAX_FILE_NAME_LENGTH = 255;
|
||||
/** 첨부 파일명 최대 길이 — 파일 이름 정리(shared/utils/file-name.ts)와 같은 값 하나를 쓴다 (대용량 첨부도 같다) */
|
||||
export { MAX_FILE_NAME_LENGTH } from '@/shared/utils/file-name';
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { ContactsModule } from '@/modules/contacts/contacts.module';
|
||||
import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
|
||||
import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module';
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
MailFilterRunController,
|
||||
} from './mail-filter.controller';
|
||||
import { MailEventsService } from './mail-events.service';
|
||||
import { MailLargeFilesService } from './mail-large-files.service';
|
||||
import { MailEngineModule } from './mail-engine.module';
|
||||
import {
|
||||
MailUploadTempCleanup,
|
||||
@@ -56,6 +58,7 @@ import { RecipientSuggestionController } from './recipient-suggestion.controller
|
||||
import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
import { ScheduledMessageController } from './scheduled-message.controller';
|
||||
import { ScheduledMessagesService } from './scheduled-messages.service';
|
||||
import { ShareLinkController } from './share-link.controller';
|
||||
|
||||
function readMailSettings(config: ConfigService): MailSettings {
|
||||
return buildMailSettings((key) => config.get<string>(key));
|
||||
@@ -116,6 +119,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
MailDirectoryModule,
|
||||
// 보내기 · 읽기 설정 — 발송의 보내기 취소 초 · 메일 읽기의 외부 이미지 (9단계)
|
||||
MailUserSettingsModule,
|
||||
// 대용량 첨부(11단계) — 공유 링크 · 공개 내려받기(파일 저장 모듈을 함께 내보낸다). 메일 → 공유 → 저장 한 방향
|
||||
FileSharingModule,
|
||||
],
|
||||
controllers: [
|
||||
MailboxController,
|
||||
@@ -133,6 +138,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
MailImageController,
|
||||
DraftController,
|
||||
RecipientSuggestionController,
|
||||
// 보낸 대용량 첨부 링크 — 목록 · 끊기 (11단계, 보낸 메일 제목을 함께)
|
||||
ShareLinkController,
|
||||
],
|
||||
providers: [
|
||||
// 보낸 사람 차단 (9단계) — 저장은 필터 · 부재중과 같은 엔진 규칙(포트)
|
||||
@@ -153,6 +160,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
MailService,
|
||||
// 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown)
|
||||
MailEventsService,
|
||||
// 대용량 첨부(11단계) — 발송의 덩이 · 링크, 임시 저장의 파일, 읽기의 덩이 알아보기, 위험 판정 알림(large-file)
|
||||
MailLargeFilesService,
|
||||
RecipientSuggestionService,
|
||||
MailUploadTempCleanup,
|
||||
],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type {
|
||||
AttachmentContent,
|
||||
BlockedSender,
|
||||
@@ -54,6 +55,13 @@ import type {
|
||||
*/
|
||||
export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
|
||||
|
||||
/**
|
||||
* 엔진에 아무것도 내지 않은 채 끝난 발송 실패 — 보내기를 시작하지 않았다(예: 남은 시간이 모자라 제출하지 않음). 코드 · 응답은 BusinessException 그대로다.
|
||||
* 어댑터가 이 예외로 던지면 메일 서비스는 "보내지 않은 것이 확실한 실패"로 본다 (11단계 — 대용량 첨부의 링크를 취소한다, 계약 4절 "링크를 만드는 때").
|
||||
* BusinessException 의 context 는 로그 전용이라 갈래에 쓰지 않는다 — 이 형식으로 가른다 (2026-10-30 리뷰 L6)
|
||||
*/
|
||||
export class SendNotStartedException extends BusinessException {}
|
||||
|
||||
export interface MailProvider {
|
||||
/**
|
||||
* 직원 자격 증명을 메일 엔진에 확인한다. 비밀번호는 저장하지 않는다.
|
||||
@@ -294,6 +302,15 @@ export interface MailProvider {
|
||||
/** 받은 그대로의 메일 원문(엔진의 원본)을 흘려보낸다 — 없는 메일은 RES_001 */
|
||||
openMessageSource(user: string, id: string): Promise<MessageSource>;
|
||||
|
||||
/**
|
||||
* 메일 제목 (11단계 — 보낸 대용량 첨부 링크 목록이 보낸 메일 제목을 함께 보인다) — 한 번에 읽는다.
|
||||
* 없는 메일(지움)은 결과에 들지 않는다. 엔진을 읽지 못하면 그 실패(MAIL_001 등)를 던진다 — 부르는 쪽이 제목 없이 답한다
|
||||
*/
|
||||
getMessageSubjects(
|
||||
user: string,
|
||||
ids: readonly string[],
|
||||
): Promise<Map<string, string>>;
|
||||
|
||||
/** 그 메일에 실제로 있는 첨부만 내려준다 (스트리밍) */
|
||||
openAttachment(
|
||||
user: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto';
|
||||
import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto';
|
||||
import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget';
|
||||
import { MailImageTokens } from './images/mail-image-token';
|
||||
import type { MailLargeFilesService } from './mail-large-files.service';
|
||||
import {
|
||||
MAX_BODY_HTML_LENGTH,
|
||||
MAX_BODY_LENGTH,
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
Mailbox,
|
||||
MailFilterRule,
|
||||
MessageDetailSource,
|
||||
OutgoingMessage,
|
||||
SentMessage,
|
||||
SentMessageRecord,
|
||||
} from './mail.types';
|
||||
@@ -89,6 +91,7 @@ function fakeProvider(): ProviderMocks {
|
||||
setMailFilters: mockOf<'setMailFilters'>(),
|
||||
runMailFilter: mockOf<'runMailFilter'>(),
|
||||
openMessageSource: mockOf<'openMessageSource'>(),
|
||||
getMessageSubjects: mockOf<'getMessageSubjects'>(),
|
||||
getUsage: mockOf<'getUsage'>(),
|
||||
getAttachmentsTotalLimit:
|
||||
mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024),
|
||||
@@ -256,6 +259,7 @@ const draftSource = (overrides: Partial<DraftSource> = {}): DraftSource => ({
|
||||
inReplyTo: null,
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
isCanceledSend: false,
|
||||
largeFileIds: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -292,12 +296,45 @@ function fakeNinthStep(): NinthStepMocks {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 11단계 — 대용량 첨부의 대역. 기존 테스트는 대용량 파일 없이 본다: 덩이를 알아보지 않고(null), 임시 저장본은 그대로 · 파일 없음.
|
||||
* 대용량 파일이 있는 경로는 아래 "대용량 첨부" 묶음이 이 대역의 호출을 본다 (규칙 자체는 mail-large-files.service.spec.ts)
|
||||
*/
|
||||
interface LargeFileMocks {
|
||||
assertCount: jest.Mock<void, [readonly string[]]>;
|
||||
deliverWithLink: jest.Mock;
|
||||
attachRetry: jest.Mock;
|
||||
requireDraftable: jest.Mock;
|
||||
afterDraftSaved: jest.Mock;
|
||||
draftView: jest.Mock;
|
||||
recognize: jest.Mock;
|
||||
}
|
||||
|
||||
function fakeLargeFiles(): LargeFileMocks {
|
||||
return {
|
||||
assertCount: jest.fn<void, [readonly string[]]>(),
|
||||
deliverWithLink: jest.fn(),
|
||||
attachRetry: jest.fn().mockResolvedValue(undefined),
|
||||
requireDraftable: jest.fn().mockResolvedValue(undefined),
|
||||
afterDraftSaved: jest.fn().mockResolvedValue(undefined),
|
||||
draftView: jest.fn((_user: unknown, draft: DraftSource) =>
|
||||
Promise.resolve({
|
||||
rawHtmlBody: draft.rawHtmlBody,
|
||||
textBody: draft.textBody,
|
||||
largeFiles: [],
|
||||
}),
|
||||
),
|
||||
recognize: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
function createService(
|
||||
provider: ProviderMocks,
|
||||
mailSettings: MailSettings,
|
||||
signatures: SignatureMocks,
|
||||
suggestions: { invalidate: jest.Mock<void, [string]> },
|
||||
ninth: NinthStepMocks,
|
||||
largeFiles: LargeFileMocks = fakeLargeFiles(),
|
||||
): MailService {
|
||||
return new MailService(
|
||||
provider,
|
||||
@@ -308,6 +345,7 @@ function createService(
|
||||
ninth.contacts as unknown as ContactsService,
|
||||
ninth.directory as unknown as DirectoryService,
|
||||
ninth.imageTokens,
|
||||
largeFiles as unknown as MailLargeFilesService,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2733,6 +2771,7 @@ describe('MailService', () => {
|
||||
attachments: [],
|
||||
inReplyTo: 'eaaaaab',
|
||||
updatedAt: '2026-10-27T01:00:00.000Z',
|
||||
largeFiles: [],
|
||||
});
|
||||
expect(provider.getDraft).toHaveBeenCalledWith(user.email, 'draft-1');
|
||||
});
|
||||
@@ -3298,3 +3337,230 @@ describe('MailService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MailService — 대용량 첨부 (11단계)', () => {
|
||||
const FILE_A = '0f8fad5b-d9cb-469f-a165-70867728950e';
|
||||
const FILE_B = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
let provider: ProviderMocks;
|
||||
let largeFiles: LargeFileMocks;
|
||||
let service: MailService;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = fakeProvider();
|
||||
largeFiles = fakeLargeFiles();
|
||||
// 덩이 · 링크는 대역이 맡는다 — 받은 메일 그대로 보낸다
|
||||
largeFiles.deliverWithLink.mockImplementation(
|
||||
(
|
||||
_user: unknown,
|
||||
message: OutgoingMessage,
|
||||
_plan: unknown,
|
||||
_context: unknown,
|
||||
deliver: (message: OutgoingMessage) => Promise<SentMessage>,
|
||||
) => deliver(message),
|
||||
);
|
||||
service = createService(
|
||||
provider,
|
||||
settings,
|
||||
fakeSignatures(),
|
||||
{ invalidate: jest.fn<void, [string]>() },
|
||||
fakeNinthStep(),
|
||||
largeFiles,
|
||||
);
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const htmlWithQuote =
|
||||
'<div><p>내 글</p><div data-ara-quote-start></div><p>----- 원본 메일 -----</p></div>';
|
||||
const body = '내 글\n\n----- 원본 메일 -----';
|
||||
|
||||
it('발송 — 인용 경계 표시를 지우고 그 자리 · bodyQuoteStart · 파일(소문자 · 겹치지 않게)을 넘긴다', async () => {
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({
|
||||
body,
|
||||
bodyHtml: htmlWithQuote,
|
||||
bodyQuoteStart: '내 글\n\n'.length,
|
||||
largeFileIds: [FILE_A.toUpperCase(), FILE_B, FILE_A],
|
||||
}),
|
||||
);
|
||||
expect(largeFiles.assertCount).toHaveBeenCalledWith([FILE_A, FILE_B]);
|
||||
const [, message, plan, context] = largeFiles.deliverWithLink.mock
|
||||
.calls[0] as [
|
||||
unknown,
|
||||
OutgoingMessage,
|
||||
unknown,
|
||||
{ messageId: null; sentAt: Date },
|
||||
];
|
||||
expect(message.htmlBody).toBe(
|
||||
'<div><p>내 글</p><p>----- 원본 메일 -----</p></div>',
|
||||
);
|
||||
expect(plan).toEqual({
|
||||
fileIds: [FILE_A, FILE_B],
|
||||
htmlQuoteStart: '<div><p>내 글</p>'.length,
|
||||
textQuoteStart: '내 글\n\n'.length,
|
||||
});
|
||||
expect(context.messageId).toBeNull();
|
||||
expect(provider.sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('대용량 파일이 없으면 링크를 만들지 않는다 — 표시는 그래도 지운다(받는 사람에게 가지 않는다)', async () => {
|
||||
await service.sendMessage(user, sendDto({ body, bodyHtml: htmlWithQuote }));
|
||||
expect(largeFiles.deliverWithLink).not.toHaveBeenCalled();
|
||||
const sent = provider.sendMessage.mock.calls[0][1];
|
||||
expect(sent.htmlBody).not.toContain('data-ara-quote-start');
|
||||
});
|
||||
|
||||
it('bodyQuoteStart 가 줄의 처음이 아니거나 본문 밖이면 400 VAL_001 — 엔진에 묻기 전', async () => {
|
||||
for (const position of [1, body.length + 1]) {
|
||||
expect(
|
||||
await failureOf(() =>
|
||||
service.sendMessage(
|
||||
user,
|
||||
sendDto({ body, bodyQuoteStart: position, largeFileIds: [FILE_A] }),
|
||||
),
|
||||
),
|
||||
).toEqual({ code: 'VAL_001', reason: 'invalid-body-quote-start' });
|
||||
}
|
||||
expect(provider.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('같은 키의 재시도가 처음 메일을 찾으면 그 결과를 주고 링크를 새로 만들지 않는다(처음 링크에 잇는다)', async () => {
|
||||
const dto = sendDto({ largeFileIds: [FILE_A] });
|
||||
await service.sendMessage(user, dto, IDEMPOTENCY_KEY);
|
||||
const first = provider.sendMessage.mock.calls[0][1];
|
||||
expect(first.fingerprint).toBeDefined();
|
||||
|
||||
provider.findSentMessage.mockResolvedValue(
|
||||
sentRecord(first.fingerprint ?? null, { id: 'sent-1' }),
|
||||
);
|
||||
largeFiles.deliverWithLink.mockClear();
|
||||
await expect(
|
||||
service.sendMessage(user, dto, IDEMPOTENCY_KEY),
|
||||
).resolves.toMatchObject({
|
||||
id: 'sent-1',
|
||||
});
|
||||
expect(largeFiles.deliverWithLink).not.toHaveBeenCalled();
|
||||
expect(largeFiles.attachRetry).toHaveBeenCalledWith(
|
||||
user,
|
||||
first.messageId,
|
||||
'sent-1',
|
||||
);
|
||||
|
||||
// 대용량 파일을 바꾼 재시도는 지문이 달라 MAIL_005
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.sendMessage(
|
||||
user,
|
||||
sendDto({ largeFileIds: [FILE_B] }),
|
||||
IDEMPOTENCY_KEY,
|
||||
),
|
||||
),
|
||||
).toBe('MAIL_005');
|
||||
});
|
||||
|
||||
it('예약 발송의 링크는 보낼 시각부터 센다', async () => {
|
||||
const sendAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
await service.sendMessage(
|
||||
user,
|
||||
sendDto({ sendAt, largeFileIds: [FILE_A] }),
|
||||
);
|
||||
const [, , , context] = largeFiles.deliverWithLink.mock.calls[0] as [
|
||||
unknown,
|
||||
unknown,
|
||||
unknown,
|
||||
{ sentAt: Date },
|
||||
];
|
||||
expect(context.sentAt.getTime()).toBe(
|
||||
Math.floor(Date.parse(sendAt) / 1000) * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it('임시 저장 — 파일을 확인하고(올리는 중도 된다) 머리글로 넘긴 뒤 "임시 저장에 실린 파일"로 적는다 · 표시는 저장본에 남지 않는다', async () => {
|
||||
provider.saveDraft.mockResolvedValue({
|
||||
id: 'draft-2',
|
||||
attachments: [],
|
||||
updatedAt: '2026-10-30T00:00:00.000Z',
|
||||
});
|
||||
await service.createDraft(
|
||||
user,
|
||||
draftDto({ bodyHtml: htmlWithQuote, largeFileIds: [FILE_A] }),
|
||||
);
|
||||
expect(largeFiles.requireDraftable).toHaveBeenCalledWith(user, [FILE_A]);
|
||||
const [, content] = provider.saveDraft.mock.calls[0];
|
||||
expect(content.largeFileIds).toEqual([FILE_A]);
|
||||
expect(content.htmlBody).not.toContain('data-ara-quote-start');
|
||||
expect(largeFiles.afterDraftSaved).toHaveBeenCalledWith(user, [FILE_A]);
|
||||
|
||||
largeFiles.requireDraftable.mockRejectedValueOnce(
|
||||
new BusinessException('MAIL_016'),
|
||||
);
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.replaceDraft(
|
||||
user,
|
||||
'draft-2',
|
||||
draftDto({ largeFileIds: [FILE_B] }),
|
||||
),
|
||||
),
|
||||
).toBe('MAIL_016');
|
||||
expect(provider.saveDraft).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('이어 쓸 임시 저장본 — 대용량 파일 · 덩이를 뗀 본문을 쓴다', async () => {
|
||||
provider.getDraft.mockResolvedValue(
|
||||
draftSource({ rawHtmlBody: '<p>덩이 있음</p>', textBody: '덩이 있음' }),
|
||||
);
|
||||
largeFiles.draftView.mockResolvedValue({
|
||||
rawHtmlBody: '<p>뗌</p>',
|
||||
textBody: '뗌',
|
||||
largeFiles: [
|
||||
{
|
||||
id: FILE_A,
|
||||
status: 'missing',
|
||||
reason: 'deleted',
|
||||
name: 'a.zip',
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
const draft = await service.getDraft(user, 'draft-1');
|
||||
expect(draft.bodyHtml).toBe('<p>뗌</p>');
|
||||
expect(draft.body).toBe('뗌');
|
||||
expect(draft.largeFiles).toEqual([
|
||||
{
|
||||
id: FILE_A,
|
||||
status: 'missing',
|
||||
reason: 'deleted',
|
||||
name: 'a.zip',
|
||||
size: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('메일 읽기 — 알아본 덩이의 바깥에 data-ara-large-files, largeFiles 를 싣는다 · 알아보지 못하면 본문 그대로', async () => {
|
||||
provider.getMessage.mockResolvedValue(
|
||||
source({ rawHtmlBody: '<p>글</p><table><tr><td>덩이</td></tr></table>' }),
|
||||
);
|
||||
largeFiles.recognize.mockResolvedValue({
|
||||
html: '<p>글</p><div data-ara-lf="m1"><table><tr><td>덩이</td></tr></table></div>',
|
||||
marker: 'm1',
|
||||
largeFiles: [{ fileId: FILE_A }],
|
||||
});
|
||||
const detail = await service.getMessage(user, 'm1');
|
||||
expect(detail.htmlBody).toContain('<div data-ara-large-files><table>');
|
||||
expect(detail.largeFiles).toEqual([{ fileId: FILE_A }]);
|
||||
expect(largeFiles.recognize).toHaveBeenCalledWith(
|
||||
user,
|
||||
'<p>글</p><table><tr><td>덩이</td></tr></table>',
|
||||
'doyun.lee@arasystem.test',
|
||||
);
|
||||
|
||||
largeFiles.recognize.mockRejectedValue(new Error('db down'));
|
||||
const fallback = await service.getMessage(user, 'm1');
|
||||
expect(fallback.largeFiles).toEqual([]);
|
||||
expect(fallback.htmlBody).not.toContain('data-ara-large-files');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,12 @@ import {
|
||||
parseIdempotencyKey,
|
||||
payloadFingerprint,
|
||||
} from './send-idempotency';
|
||||
import { extractQuoteStart, isValidQuoteStart } from './large-file-block';
|
||||
import {
|
||||
MailLargeFilesService,
|
||||
type LargeFilePlan,
|
||||
type RecognizedLargeFiles,
|
||||
} from './mail-large-files.service';
|
||||
|
||||
/**
|
||||
* 엔진 장애(MAIL_001)로 끝난 발송의 키를 잠가 두는 시간.
|
||||
@@ -157,6 +163,23 @@ function rejectedSuffix(result: SentMessage): string {
|
||||
/** 발송·임시 저장 본문이 함께 쓰는 입력 — 받는 사람 규칙만 다르다 (dto/send-message.dto.ts) */
|
||||
type ComposeInput = ComposeContentDto & { readonly to: RecipientDto[] };
|
||||
|
||||
/** 본문 준비의 결과 — 엔진에 넘길 내용 · 함께 실을 서명 그림 · 대용량 첨부(11단계)의 파일과 덩이 자리 */
|
||||
interface PreparedCompose {
|
||||
readonly content: DraftContent;
|
||||
readonly inlineImages: OutgoingInlineImage[];
|
||||
/** 이 메일에 새로 싣는 내 대용량 파일 (요청 차례, 겹치지 않게, 소문자) */
|
||||
readonly largeFileIds: string[];
|
||||
/** 서식 본문의 첫 인용 경계 표시 자리 — 표시는 지웠다 */
|
||||
readonly htmlQuoteStart: number | null;
|
||||
/** 텍스트 본문의 인용 시작 자리 (bodyQuoteStart) */
|
||||
readonly textQuoteStart: number | null;
|
||||
}
|
||||
|
||||
/** 발송 · 임시 저장 본문의 대용량 파일 id — 소문자로 겹치지 않게 (요청 차례) */
|
||||
function normalizeLargeFileIds(ids: readonly string[] | undefined): string[] {
|
||||
return [...new Set((ids ?? []).map((id) => id.toLowerCase()))];
|
||||
}
|
||||
|
||||
/** 준비를 어디에 쓰나 — 보낼 때만 서명 그림을 메일 안에 싣는다 (임시 저장본은 그림 주소 그대로) */
|
||||
type ComposePurpose = 'send' | 'draft';
|
||||
|
||||
@@ -335,6 +358,8 @@ export class MailService {
|
||||
private readonly directory: DirectoryService,
|
||||
/** 외부 이미지 중계 표 (9단계) */
|
||||
private readonly imageTokens: MailImageTokens,
|
||||
/** 대용량 첨부 — 발송의 덩이 · 링크, 임시 저장의 파일, 읽기의 덩이 알아보기 (11단계) */
|
||||
private readonly largeFiles: MailLargeFilesService,
|
||||
) {}
|
||||
|
||||
async listMailboxes(
|
||||
@@ -707,6 +732,8 @@ export class MailService {
|
||||
imagesShown: false,
|
||||
isHtmlBodyOmitted,
|
||||
isBodyTruncated: isTextBodyTruncated,
|
||||
// 텍스트만 있는 메일 · 정화 한도를 넘은 메일의 덩이는 알아보지 않는다 (계약 4절의 알려진 한계)
|
||||
largeFiles: [],
|
||||
});
|
||||
|
||||
if (rawHtmlBody === null) return withoutHtml(false);
|
||||
@@ -721,23 +748,27 @@ export class MailService {
|
||||
return withoutHtml(true);
|
||||
}
|
||||
|
||||
const [inlineImages, showImages] = await Promise.all([
|
||||
const [inlineImages, showImages, recognized] = await Promise.all([
|
||||
this.loadInlineImages(user.email, rawHtmlBody, inlineParts),
|
||||
this.showsExternalImages(
|
||||
user,
|
||||
{ sender: detail.from?.email ?? null, isInJunkMailbox },
|
||||
query,
|
||||
),
|
||||
this.recognizeLargeFiles(user, id, rawHtmlBody, detail.from?.email),
|
||||
]);
|
||||
const sanitized = sanitizeMailHtml(
|
||||
rawHtmlBody,
|
||||
recognized?.html ?? rawHtmlBody,
|
||||
inlineImages.dataUris,
|
||||
showImages
|
||||
? {
|
||||
externalImageSrc: (url) =>
|
||||
`${MAIL_IMAGE_PATH_PREFIX}${this.imageTokens.issue(user.email, url.href)}`,
|
||||
}
|
||||
: {},
|
||||
{
|
||||
...(showImages
|
||||
? {
|
||||
externalImageSrc: (url: URL) =>
|
||||
`${MAIL_IMAGE_PATH_PREFIX}${this.imageTokens.issue(user.email, url.href)}`,
|
||||
}
|
||||
: {}),
|
||||
...(recognized ? { largeFilesMarker: recognized.marker } : {}),
|
||||
},
|
||||
);
|
||||
const inlined = new Set(
|
||||
[...sanitized.embeddedContentIds].flatMap((contentId) => {
|
||||
@@ -754,9 +785,30 @@ export class MailService {
|
||||
imagesShown: sanitized.proxiedImageCount > 0,
|
||||
isHtmlBodyOmitted: false,
|
||||
isBodyTruncated: showsHtml ? isRawHtmlBodyTruncated : isTextBodyTruncated,
|
||||
largeFiles: recognized?.largeFiles ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문 HTML 의 대용량 첨부 덩이를 알아본다 (계약 4절 "우리 웹메일에서 읽기") — 읽지 못하면(DB 장애) 덩이 없이 본문 그대로 보인다(경고만).
|
||||
* 메일 읽기를 대용량 첨부 때문에 실패로 만들지 않는다
|
||||
*/
|
||||
private async recognizeLargeFiles(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
rawHtmlBody: string,
|
||||
from: string | undefined,
|
||||
): Promise<RecognizedLargeFiles | null> {
|
||||
try {
|
||||
return await this.largeFiles.recognize(user, rawHtmlBody, from ?? null);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 대용량 첨부 덩이를 알아보지 못해 본문 그대로 보입니다: user=${maskEmail(user.email)}, id=${id}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 이미지를 보일까 (계약 2절 "외부 이미지 보기") — `images=show` 면 늘(스팸메일함도). 아니면 설정:
|
||||
* ask 는 안 보이고, 스팸메일함의 메일은 설정과 상관없이 안 보인다, always 는 보이고, trusted 는 보낸 사람이 내 주소록이나 사내 목록에 있을 때.
|
||||
@@ -893,17 +945,40 @@ export class MailService {
|
||||
? null
|
||||
: scheduledHold(dto.sendAt, Date.now());
|
||||
|
||||
const prepared = await this.prepareOutgoing(user, dto);
|
||||
const { message: prepared, plan } = await this.prepareOutgoing(user, dto);
|
||||
const hold = scheduled ?? (await this.undoHold(user));
|
||||
const message: OutgoingMessage = hold ? { ...prepared, hold } : prepared;
|
||||
// 대용량 첨부의 보낸 때 — 예약은 보낼 시각(끝나는 날도 그 날짜로), 그 밖은 지금 (보내기 취소의 몇 초는 무시한다)
|
||||
const sentAt =
|
||||
hold?.kind === 'scheduled' ? new Date(hold.until) : new Date();
|
||||
const result =
|
||||
parsed.key === null
|
||||
? await this.deliver(user, message)
|
||||
: await this.deliverOnce(user, message, parsed.key);
|
||||
? await this.deliverWithLargeFiles(user, message, plan, {
|
||||
messageId: null,
|
||||
sentAt,
|
||||
})
|
||||
: await this.deliverOnce(user, message, parsed.key, plan, sentAt);
|
||||
await this.afterSent(user, dto.draftId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 보낸다 — 대용량 파일이 있으면 링크를 만들어 덩이를 넣은 뒤 (계약 4절 "링크를 만드는 때") */
|
||||
private deliverWithLargeFiles(
|
||||
user: AuthenticatedUser,
|
||||
message: OutgoingMessage,
|
||||
plan: LargeFilePlan | null,
|
||||
context: { readonly messageId: string | null; readonly sentAt: Date },
|
||||
): Promise<SentMessage> {
|
||||
if (plan === null) return this.deliver(user, message);
|
||||
return this.largeFiles.deliverWithLink(
|
||||
user,
|
||||
message,
|
||||
plan,
|
||||
context,
|
||||
(outgoing) => this.deliver(user, outgoing),
|
||||
);
|
||||
}
|
||||
|
||||
/** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다) */
|
||||
private async undoHold(user: AuthenticatedUser): Promise<SendHold | null> {
|
||||
const { undoSendSeconds } = await this.userSettingsOf(user);
|
||||
@@ -917,8 +992,7 @@ export class MailService {
|
||||
user: AuthenticatedUser,
|
||||
dto: DraftBodyDto,
|
||||
): Promise<SavedDraft> {
|
||||
const { content } = await this.prepareContent(user, dto, 'draft');
|
||||
return this.provider.saveDraft(user.email, content);
|
||||
return this.saveDraft(user, dto);
|
||||
}
|
||||
|
||||
/** 임시 저장 — 바꿔 저장. 새 id 로 돌려준다 (초안이 아니면 RES_001, 다른 곳에서 먼저 바뀌었으면 MAIL_008) */
|
||||
@@ -927,8 +1001,31 @@ export class MailService {
|
||||
id: string,
|
||||
dto: DraftBodyDto,
|
||||
): Promise<SavedDraft> {
|
||||
const { content } = await this.prepareContent(user, dto, 'draft');
|
||||
return this.provider.saveDraft(user.email, content, id);
|
||||
return this.saveDraft(user, dto, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장 — 대용량 파일(11단계)은 내 것이면 올리는 중 · 위험 판정이어도 된다(없음 · 남의 것 · 지움 · 기간 지남은 MAIL_016).
|
||||
* 파일 id 는 초안 머리글로 두고(덩이는 넣지 않는다), 저장한 뒤 그 파일들을 "임시 저장에 실린 파일"로 적는다
|
||||
*/
|
||||
private async saveDraft(
|
||||
user: AuthenticatedUser,
|
||||
dto: DraftBodyDto,
|
||||
replaceId?: string,
|
||||
): Promise<SavedDraft> {
|
||||
const { content, largeFileIds } = await this.prepareContent(
|
||||
user,
|
||||
dto,
|
||||
'draft',
|
||||
);
|
||||
await this.largeFiles.requireDraftable(user, largeFileIds);
|
||||
const saved = await this.provider.saveDraft(
|
||||
user.email,
|
||||
largeFileIds.length > 0 ? { ...content, largeFileIds } : content,
|
||||
replaceId,
|
||||
);
|
||||
await this.largeFiles.afterDraftSaved(user, largeFileIds);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -937,17 +1034,23 @@ export class MailService {
|
||||
*/
|
||||
async getDraft(user: AuthenticatedUser, id: string): Promise<Draft> {
|
||||
const source = await this.provider.getDraft(user.email, id);
|
||||
// 대용량 첨부(11단계) — 머리글의 파일, 취소해 되돌린 발송이면 그 링크의 파일 + 본문에서 뗀 덩이
|
||||
const view = await this.largeFiles.draftView(user, source);
|
||||
return {
|
||||
id: source.id,
|
||||
to: source.to,
|
||||
cc: source.cc,
|
||||
bcc: source.bcc,
|
||||
subject: source.subject,
|
||||
body: source.textBody ?? '',
|
||||
bodyHtml: await this.draftHtml(user, source),
|
||||
body: view.textBody ?? '',
|
||||
bodyHtml: await this.draftHtml(user, {
|
||||
...source,
|
||||
rawHtmlBody: view.rawHtmlBody,
|
||||
}),
|
||||
attachments: source.attachments,
|
||||
inReplyTo: source.inReplyTo,
|
||||
updatedAt: source.updatedAt,
|
||||
largeFiles: view.largeFiles,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1032,11 +1135,17 @@ export class MailService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005 */
|
||||
/**
|
||||
* 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005.
|
||||
* 대용량 파일(11단계)은 지문에 id 만(있을 때만) 넣고, 덩이는 중복 확인 **뒤에** 넣는다 — 같은 키의 재시도가 처음 메일을 찾으면
|
||||
* 처음 결과를 그대로 주고 링크를 새로 만들지 않는다(처음 시도의 링크에 보낸 사본을 잇는다)
|
||||
*/
|
||||
private async deliverOnce(
|
||||
user: AuthenticatedUser,
|
||||
message: OutgoingMessage,
|
||||
key: string,
|
||||
plan: LargeFilePlan | null,
|
||||
sentAt: Date,
|
||||
): Promise<SentMessage> {
|
||||
const lockKey = `${user.email.toLowerCase()}\n${key}`;
|
||||
if (this.sendsInProgress.has(lockKey)) {
|
||||
@@ -1053,7 +1162,7 @@ export class MailService {
|
||||
try {
|
||||
const messageId = idempotentMessageId(user.email, key);
|
||||
// 지문 키는 이 요청의 Idempotency-Key 다 — 서버 비밀을 쓰지 않는다 (send-idempotency.ts 머리말)
|
||||
const fingerprint = payloadFingerprint(message, key);
|
||||
const fingerprint = payloadFingerprint(message, key, plan?.fileIds);
|
||||
|
||||
const previous = await this.provider.findSentMessage(
|
||||
user.email,
|
||||
@@ -1092,11 +1201,19 @@ export class MailService {
|
||||
this.logger.log(
|
||||
`${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}${rejectedSuffix(result)}`,
|
||||
);
|
||||
if (plan !== null) {
|
||||
await this.largeFiles.attachRetry(user, messageId, previous.id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.deliver(user, { ...message, messageId, fingerprint });
|
||||
return await this.deliverWithLargeFiles(
|
||||
user,
|
||||
{ ...message, messageId, fingerprint },
|
||||
plan,
|
||||
{ messageId, sentAt },
|
||||
);
|
||||
} catch (error) {
|
||||
// 엔진에 닿지 못했거나 답을 못 받았다 — 엔진이 아직 처리 중일 수 있으니 잠시 같은 키를 막아 둔다.
|
||||
// 받는 사람 모두 거절(MAIL_010 · MAIL_011)은 막지 않는다 — 아무것도 나가지 않았고 보낸 사본도 지웠다 (다시 보내면 새로 보낸다)
|
||||
@@ -1116,19 +1233,31 @@ export class MailService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 보낼 메일 — 준비에 서명 그림(cid)을 더한다 */
|
||||
/**
|
||||
* 보낼 메일 — 준비에 서명 그림(cid)을 더한다. 대용량 파일(11단계)이 있으면 덩이 자리와 함께 계획을 돌려준다
|
||||
* (한 통의 개수가 한도를 넘으면 400 VAL_001 — 파일 확인 · 덩이는 중복 확인 뒤에 한다)
|
||||
*/
|
||||
private async prepareOutgoing(
|
||||
user: AuthenticatedUser,
|
||||
dto: SendMessageDto,
|
||||
): Promise<OutgoingMessage> {
|
||||
const { content, inlineImages } = await this.prepareContent(
|
||||
user,
|
||||
dto,
|
||||
'send',
|
||||
);
|
||||
): Promise<{ message: OutgoingMessage; plan: LargeFilePlan | null }> {
|
||||
const {
|
||||
content,
|
||||
inlineImages,
|
||||
largeFileIds,
|
||||
htmlQuoteStart,
|
||||
textQuoteStart,
|
||||
} = await this.prepareContent(user, dto, 'send');
|
||||
this.largeFiles.assertCount(largeFileIds);
|
||||
return {
|
||||
...content,
|
||||
...(inlineImages.length > 0 ? { inlineImages } : {}),
|
||||
message: {
|
||||
...content,
|
||||
...(inlineImages.length > 0 ? { inlineImages } : {}),
|
||||
},
|
||||
plan:
|
||||
largeFileIds.length > 0
|
||||
? { fileIds: largeFileIds, htmlQuoteStart, textQuoteStart }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1140,10 +1269,7 @@ export class MailService {
|
||||
user: AuthenticatedUser,
|
||||
dto: ComposeInput,
|
||||
purpose: ComposePurpose,
|
||||
): Promise<{
|
||||
content: DraftContent;
|
||||
inlineImages: OutgoingInlineImage[];
|
||||
}> {
|
||||
): Promise<PreparedCompose> {
|
||||
const to = dto.to.map(toAddress);
|
||||
const cc = (dto.cc ?? []).map(toAddress);
|
||||
const bcc = (dto.bcc ?? []).map(toAddress);
|
||||
@@ -1175,8 +1301,19 @@ export class MailService {
|
||||
return { id: attachment.id, name, type };
|
||||
});
|
||||
|
||||
// 텍스트 본문의 인용 시작 자리(11단계 — 대용량 첨부 덩이 자리) — 0 ~ body.length 이고 줄의 처음이어야 한다
|
||||
const textQuoteStart = dto.bodyQuoteStart ?? null;
|
||||
if (
|
||||
textQuoteStart !== null &&
|
||||
!isValidQuoteStart(dto.body, textQuoteStart)
|
||||
) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'invalid-body-quote-start' },
|
||||
});
|
||||
}
|
||||
|
||||
// 본문 한도를 보고, 서식 본문은 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다
|
||||
const { htmlBody, inlineImages } = await this.prepareBody(
|
||||
const { htmlBody, inlineImages, htmlQuoteStart } = await this.prepareBody(
|
||||
user,
|
||||
dto.body,
|
||||
dto.bodyHtml,
|
||||
@@ -1195,26 +1332,37 @@ export class MailService {
|
||||
inReplyTo: dto.inReplyTo,
|
||||
},
|
||||
inlineImages,
|
||||
largeFileIds: normalizeLargeFileIds(dto.largeFileIds),
|
||||
htmlQuoteStart,
|
||||
textQuoteStart,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 한도와 서식 본문 정화 — 순서는 위 "본문 한도" 주석 그대로. 정화한 뒤 서명 그림이 서로 다른 3장을 넘으면 VAL_001.
|
||||
* 서명 그림은 보내는 사람 자신의 그림만 남는다 — 그림 주소가 있을 때만 그 사람의 그림 목록을 읽는다.
|
||||
* @returns 정화한 서식 본문(없거나 비면 빈 문자열 — 텍스트 메일)과 메일에 함께 실을 그림(보낼 때만)
|
||||
* 쓰기 화면의 인용 경계 표시(11단계)는 정화가 남긴 뒤 마지막에 모두 지우고 첫 자리만 기억한다 — 받는 사람 · 임시 저장본 · 지문에 들지 않는다
|
||||
* @returns 정화한 서식 본문(없거나 비면 빈 문자열 — 텍스트 메일), 메일에 함께 실을 그림(보낼 때만), 첫 인용 경계 자리(없으면 null)
|
||||
*/
|
||||
private async prepareBody(
|
||||
user: AuthenticatedUser,
|
||||
body: string,
|
||||
bodyHtml: string | undefined,
|
||||
purpose: ComposePurpose,
|
||||
): Promise<{ htmlBody: string; inlineImages: OutgoingInlineImage[] }> {
|
||||
): Promise<{
|
||||
htmlBody: string;
|
||||
inlineImages: OutgoingInlineImage[];
|
||||
htmlQuoteStart: number | null;
|
||||
}> {
|
||||
assertBodyLength(body);
|
||||
if (!bodyHtml) return { htmlBody: '', inlineImages: [] };
|
||||
if (!bodyHtml) {
|
||||
return { htmlBody: '', inlineImages: [], htmlQuoteStart: null };
|
||||
}
|
||||
|
||||
assertHtmlBeforeSanitize(bodyHtml);
|
||||
const sanitized = sanitizeOutgoingHtml(bodyHtml, {
|
||||
signatureImageIds: await this.signatureImageIdsFor(user, bodyHtml),
|
||||
keepQuoteStart: true,
|
||||
});
|
||||
// 서명 그림은 서명 저장과 같은 한도 — 서로 다른 내 그림 3장까지 (정화가 남의 그림은 이미 지웠다). 보내기 전이라 메일은 나가지 않는다
|
||||
const imageCount = findSignatureImageIds(sanitized).length;
|
||||
@@ -1227,8 +1375,15 @@ export class MailService {
|
||||
purpose === 'send'
|
||||
? await this.embedSignatureImages(user, sanitized)
|
||||
: { htmlBody: sanitized, inlineImages: [] };
|
||||
assertHtmlBytes(prepared.htmlBody);
|
||||
return prepared;
|
||||
const { html, position } = extractQuoteStart(prepared.htmlBody);
|
||||
// 표시만 있던 본문은 비어 텍스트 메일이다
|
||||
const htmlBody = html.trim() === '' ? '' : html;
|
||||
assertHtmlBytes(htmlBody);
|
||||
return {
|
||||
htmlBody,
|
||||
inlineImages: prepared.inlineImages,
|
||||
htmlQuoteStart: htmlBody === '' ? null : position,
|
||||
};
|
||||
}
|
||||
|
||||
/** 서명 그림 주소가 있을 때만 그 사람의 그림 목록을 읽는다 (없으면 DB 를 보지 않는다) */
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
import type { ContactEmailLabel } from '@/modules/contacts/contacts.types';
|
||||
import type { SharedFileStatus } from '@/modules/file-sharing/file-sharing.types';
|
||||
import type {
|
||||
LargeFile,
|
||||
MissingLargeFile,
|
||||
} from '@/modules/file-storage/file-storage.types';
|
||||
|
||||
/**
|
||||
* 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다.
|
||||
@@ -81,6 +86,29 @@ export interface MessageAttachment extends MailAttachment {
|
||||
readonly isInline: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 속 대용량 첨부 하나 (계약 4절 "우리 웹메일에서 읽기" — 11단계) — 서버가 넣은 덩이로 확인될 때만(표시 주석 사이의 우리 링크 ·
|
||||
* 링크 주인 = 그 메일의 보낸 사람). 이름 · 크기 · 상태는 메일 글이 아니라 서버 기록이다
|
||||
*/
|
||||
export interface MessageLargeFile {
|
||||
readonly linkId: string;
|
||||
readonly fileId: string;
|
||||
readonly name: string;
|
||||
readonly size: number;
|
||||
readonly status: SharedFileStatus;
|
||||
/** 링크가 끝나는 때 (ISO 8601 UTC) */
|
||||
readonly expiresAt: string;
|
||||
/** 링크를 끊은 때 (보낸 사람 · 운영) — 끊지 않았으면 null */
|
||||
readonly revokedAt: string | null;
|
||||
/** 공개 화면 주소 — 본문 덩이의 그 주소 (같은 링크의 파일은 같은 주소) */
|
||||
readonly url: string;
|
||||
/** 보는 사람이 그 링크를 만든 사람일 때만 — 아니면 null */
|
||||
readonly downloads: {
|
||||
readonly count: number;
|
||||
readonly lastDownloadedAt: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface MessageDetail extends MessageSummary {
|
||||
readonly cc: MailAddress[];
|
||||
readonly bcc: MailAddress[];
|
||||
@@ -104,6 +132,11 @@ export interface MessageDetail extends MessageSummary {
|
||||
*/
|
||||
readonly isBodyTruncated: boolean;
|
||||
readonly attachments: MessageAttachment[];
|
||||
/**
|
||||
* 우리 서버가 넣은 대용량 첨부 덩이의 파일 (11단계) — 링크 주인이 이 메일의 보낸 사람일 때만, 없으면 빈 배열.
|
||||
* 그때 htmlBody 안 그 덩이의 바깥 요소에 `data-ara-large-files` 가 붙는다
|
||||
*/
|
||||
readonly largeFiles: MessageLargeFile[];
|
||||
}
|
||||
|
||||
/** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */
|
||||
@@ -131,6 +164,7 @@ export interface MessageDetailSource extends Omit<
|
||||
| 'isHtmlBodyOmitted'
|
||||
| 'isBodyTruncated'
|
||||
| 'attachments'
|
||||
| 'largeFiles'
|
||||
> {
|
||||
/** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */
|
||||
readonly attachments: MailAttachment[];
|
||||
@@ -499,7 +533,13 @@ export type DraftContent = Pick<
|
||||
| 'htmlBody'
|
||||
| 'attachments'
|
||||
| 'inReplyTo'
|
||||
>;
|
||||
> & {
|
||||
/**
|
||||
* 이 초안에 실린 내 대용량 파일 id (11단계 — 올리는 중인 것도) — 어댑터가 초안 머리글 `X-Ara-Large-Files` 로 둔다.
|
||||
* 덩이는 넣지 않는다(보낼 때만 만든다). 없거나 비면 머리글을 두지 않는다
|
||||
*/
|
||||
readonly largeFileIds?: readonly string[];
|
||||
};
|
||||
|
||||
/** 저장한 임시 저장본 — 저장할 때마다 id·첨부 id 가 바뀐다 */
|
||||
export interface SavedDraft {
|
||||
@@ -530,6 +570,8 @@ export interface DraftSource {
|
||||
* 서명 그림 주소로 되돌린다 — 어댑터는 그 그림 부분을 첨부에서 이미 뺐다
|
||||
*/
|
||||
readonly isCanceledSend: boolean;
|
||||
/** 초안 머리글 `X-Ara-Large-Files` 의 파일 id (11단계) — 우리 파일 id 모양(UUID)만, 겹치지 않게. 없으면 빈 배열 */
|
||||
readonly largeFileIds: string[];
|
||||
}
|
||||
|
||||
/** 쓰기 화면에서 이어 쓸 임시 저장본 (계약 2절 "임시 저장"의 Draft) */
|
||||
@@ -545,6 +587,11 @@ export interface Draft {
|
||||
readonly attachments: MailAttachment[];
|
||||
readonly inReplyTo: string | null;
|
||||
readonly updatedAt: string;
|
||||
/**
|
||||
* 대용량 첨부 (11단계) — 머리글의 파일 가운데 그 직원의 것만: 있으면 LargeFile(올리는 중 포함), 지웠거나 기간이 지났으면 MissingLargeFile.
|
||||
* 취소해 되돌린 발송은 그 링크의 파일 (덩이는 본문에서 뗐다)
|
||||
*/
|
||||
readonly largeFiles: (LargeFile | MissingLargeFile)[];
|
||||
}
|
||||
|
||||
/** 주고받은 주소 하나 — 보낸 메일의 받는 사람·참조·숨은참조, 받은 메일의 보낸 사람 */
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
@@ -23,7 +24,8 @@ import {
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { FileIdPipe } from '@/modules/file-storage/file-id.pipe';
|
||||
import { currentRequestId } from '@/shared/logger/request-context';
|
||||
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
|
||||
import {
|
||||
@@ -62,6 +65,7 @@ import { MessageListQueryDto } from './dto/message-list-query.dto';
|
||||
import { SendMessageDto } from './dto/send-message.dto';
|
||||
import { UpdateMessageDto } from './dto/update-message.dto';
|
||||
import { MailIdPipe } from './mail-id.pipe';
|
||||
import { MailLargeFilesService } from './mail-large-files.service';
|
||||
import { MailService } from './mail.service';
|
||||
import type { AttachmentContent } from './mail.types';
|
||||
import { IDEMPOTENCY_KEY_HEADER } from './send-idempotency';
|
||||
@@ -72,6 +76,10 @@ const ATTACHMENT_CONTENT_TYPE = 'application/octet-stream';
|
||||
/** 메일 원문의 형식 (계약 2절 "스팸 · 원문") — attachment 로 내려주므로 브라우저가 열어 그리지 않는다 */
|
||||
const MESSAGE_SOURCE_CONTENT_TYPE = 'message/rfc822';
|
||||
|
||||
/** 우리 웹메일에서 대용량 첨부 받기의 요청 수 — IP 마다 1분 300회 (계약 4절 — 공개 받기와 같다, HEAD 포함) */
|
||||
const LARGE_FILE_THROTTLE_TTL_MS = 60_000;
|
||||
const LARGE_FILE_THROTTLE_LIMIT = 300;
|
||||
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -83,7 +91,10 @@ const MESSAGE_SOURCE_CONTENT_TYPE = 'message/rfc822';
|
||||
export class MessageController {
|
||||
private readonly logger = new Logger(MessageController.name);
|
||||
|
||||
constructor(private readonly mail: MailService) {}
|
||||
constructor(
|
||||
private readonly mail: MailService,
|
||||
private readonly largeFiles: MailLargeFilesService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
@@ -315,6 +326,51 @@ export class MessageController {
|
||||
return this.streamDownload(res, content, ATTACHMENT_CONTENT_TYPE, '첨부');
|
||||
}
|
||||
|
||||
@Get(':id/large-files/:fileId')
|
||||
@SkipTransform()
|
||||
@Throttle({
|
||||
default: {
|
||||
ttl: LARGE_FILE_THROTTLE_TTL_MS,
|
||||
limit: LARGE_FILE_THROTTLE_LIMIT,
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'우리 웹메일에서 대용량 첨부 받기 (GET · HEAD · Range — 표준 래퍼 없음, 11단계)',
|
||||
description:
|
||||
'그 메일에서 첨부처럼 올린 파일(MessageDetail.largeFiles)만 준다 — 아니면 404. 머리글 · 범위 · 상태별 실패는 공개 받기(GET /api/public-links/{token}/files/{fileId})와 같다. ' +
|
||||
`보는 사람이 링크를 만든 사람이면 받은 수에 넣지 않는다. 요청 수는 IP 마다 1분 ${LARGE_FILE_THROTTLE_LIMIT}회.`,
|
||||
})
|
||||
@ApiProduces(ATTACHMENT_CONTENT_TYPE)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '파일 (206 — 범위)',
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
|
||||
description: '받을 수 없는 범위 — Content-Range: bytes */<크기>, 본문 없음',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'RES_001',
|
||||
'없는 메일 · 그 메일에서 첨부처럼 올린 파일이 아님',
|
||||
)
|
||||
@ApiErrorResponse('FILE_008', '보안 검사 중 — 끝나면 받는다')
|
||||
@ApiErrorResponse(
|
||||
'FILE_007',
|
||||
'기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async largeFile(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', MailIdPipe) id: string,
|
||||
@Param('fileId', FileIdPipe) fileId: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
await this.largeFiles.serveMessageFile(user, id, fileId, req, res);
|
||||
}
|
||||
|
||||
@Get(':id/source')
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { encodeOffsetCursor } from '@/shared/utils/offset-cursor';
|
||||
import type { MailLargeFilesService } from './mail-large-files.service';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type { MessageSummary } from './mail.types';
|
||||
import { ScheduledMessagesService } from './scheduled-messages.service';
|
||||
@@ -45,13 +46,20 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취
|
||||
cancelPendingSend: jest.Mock;
|
||||
};
|
||||
let service: ScheduledMessagesService;
|
||||
let largeFiles: { onPendingSendCanceled: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
provider = {
|
||||
listScheduledMessages: jest.fn().mockResolvedValue(all),
|
||||
cancelPendingSend: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service = new ScheduledMessagesService(provider as unknown as MailProvider);
|
||||
largeFiles = {
|
||||
onPendingSendCanceled: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
service = new ScheduledMessagesService(
|
||||
provider as unknown as MailProvider,
|
||||
largeFiles as unknown as MailLargeFilesService,
|
||||
);
|
||||
});
|
||||
|
||||
it('목록 — 엔진이 준 차례(보낼 시각)대로 한 쪽, total 은 전체 수 · 커서로 이어 받는다', async () => {
|
||||
@@ -115,4 +123,19 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취
|
||||
code: 'MAIL_014',
|
||||
});
|
||||
});
|
||||
|
||||
it('취소가 엔진에서 확인된 뒤에만 그 발송의 대용량 첨부 링크를 취소한다 (11단계) — 취소가 실패하면 건드리지 않는다', async () => {
|
||||
await service.cancel(user, 'm1');
|
||||
expect(largeFiles.onPendingSendCanceled).toHaveBeenCalledWith(user, 'm1');
|
||||
expect(
|
||||
largeFiles.onPendingSendCanceled.mock.invocationCallOrder[0],
|
||||
).toBeGreaterThan(provider.cancelPendingSend.mock.invocationCallOrder[0]);
|
||||
|
||||
largeFiles.onPendingSendCanceled.mockClear();
|
||||
provider.cancelPendingSend.mockRejectedValueOnce(
|
||||
new BusinessException('MAIL_001', { context: { reason: 'x' } }),
|
||||
);
|
||||
await failureOf(() => service.cancel(user, 'm2'));
|
||||
expect(largeFiles.onPendingSendCanceled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
decodeOffsetCursor,
|
||||
encodeOffsetCursor,
|
||||
} from '@/shared/utils/offset-cursor';
|
||||
import { MailLargeFilesService } from './mail-large-files.service';
|
||||
import { MAIL_PROVIDER, type MailProvider } from './mail.provider';
|
||||
import type { MessageSummary } from './mail.types';
|
||||
|
||||
@@ -24,7 +25,11 @@ const SCHEDULED_SCOPE = 'scheduled-messages';
|
||||
*/
|
||||
@Injectable()
|
||||
export class ScheduledMessagesService {
|
||||
constructor(@Inject(MAIL_PROVIDER) private readonly provider: MailProvider) {}
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
/** 취소한 발송의 대용량 첨부 링크를 취소한 링크로 (11단계) */
|
||||
private readonly largeFiles: MailLargeFilesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 예약한 메일 — 보낼 시각 차례, total 포함 (화면의 「예약됨」 통 수). 커서는 위치다 — 차례 사이에 보내지거나 취소되면 한두 개가 겹치거나 빠질 수 있다.
|
||||
@@ -58,13 +63,15 @@ export class ScheduledMessagesService {
|
||||
|
||||
/**
|
||||
* 붙잡은 발송을 취소하고 그 메일을 임시보관함으로 되돌린다 — 쓰기 화면이 같은 메일 id 로 이어 쓴다 (draftId).
|
||||
* 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다)
|
||||
* 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다).
|
||||
* 엔진에서 취소가 확인된 **뒤에** 그 발송이 만든 대용량 첨부 링크를 취소한 링크로 둔다 (11단계 — 파일은 되돌린 초안이 쓴다)
|
||||
*/
|
||||
async cancel(
|
||||
user: AuthenticatedUser,
|
||||
id: string,
|
||||
): Promise<{ draftId: string }> {
|
||||
await this.provider.cancelPendingSend(user.email, id);
|
||||
await this.largeFiles.onPendingSendCanceled(user, id);
|
||||
return { draftId: id };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,3 +200,32 @@ describe('payloadFingerprint (v2 — Idempotency-Key 로 만든 HMAC)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('payloadFingerprint — 대용량 첨부(11단계)', () => {
|
||||
const FILE_A = '0f8fad5b-d9cb-469f-a165-70867728950e';
|
||||
const FILE_B = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
|
||||
it('대용량 파일이 없으면 지금과 같은 지문이다 (빈 목록 = 넘기지 않음)', () => {
|
||||
expect(payloadFingerprint(message, KEY, [])).toBe(
|
||||
payloadFingerprint(message, KEY),
|
||||
);
|
||||
});
|
||||
|
||||
it('파일 id 만(정렬 · 소문자) 넣는다 — 차례만 바꾼 재시도는 같고, 파일을 바꾼 재시도는 다르다', () => {
|
||||
const withFiles = payloadFingerprint(message, KEY, [FILE_A, FILE_B]);
|
||||
expect(
|
||||
payloadFingerprint(message, KEY, [FILE_B.toUpperCase(), FILE_A]),
|
||||
).toBe(withFiles);
|
||||
expect(withFiles).not.toBe(payloadFingerprint(message, KEY));
|
||||
expect(withFiles).not.toBe(payloadFingerprint(message, KEY, [FILE_A]));
|
||||
});
|
||||
|
||||
it('서식 본문이 없는 메일의 파일 목록이 서식 본문 자리의 글과 섞이지 않는다', () => {
|
||||
const textWithFiles = payloadFingerprint(message, KEY, [FILE_A]);
|
||||
const htmlLookalike = payloadFingerprint(
|
||||
{ ...message, htmlBody: JSON.stringify([FILE_A]) },
|
||||
KEY,
|
||||
);
|
||||
expect(textWithFiles).not.toBe(htmlLookalike);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,10 +105,13 @@ function fingerprintKey(idempotencyKey: string): Buffer {
|
||||
* 정규화: 주소는 소문자·정렬(순서만 바꾼 것은 같은 메일), 표시 이름은 넣지 않는다, 본문 줄바꿈은 LF 로, 첨부는 id 만 정렬.
|
||||
* 서식 본문(정화한 HTML)은 **있을 때만** 끝에 붙인다 — 텍스트 메일의 지문 입력은 서식 본문이 생기기 전과 같아서,
|
||||
* 배포 전에 보낸 메일을 같은 키로 다시 보내도 MAIL_005 로 오판하지 않는다 (그래서 버전을 올리지 않았다).
|
||||
* 대용량 첨부(11단계)도 같은 결이다 — 덩이(시도마다 새 토큰)가 아니라 파일 id(정렬)를 **있을 때만** `{ largeFileIds }` 로 붙인다.
|
||||
* 대용량 파일이 없는 발송의 지문은 지금과 같다. 인용 경계 표시 · bodyQuoteStart 는 넣지 않는다 (계약 4절 "지문 · 같은 키 재시도")
|
||||
*/
|
||||
export function payloadFingerprint(
|
||||
message: FingerprintSource,
|
||||
idempotencyKey: string,
|
||||
largeFileIds: readonly string[] = [],
|
||||
): string {
|
||||
const canonical = JSON.stringify([
|
||||
FINGERPRINT_VERSION,
|
||||
@@ -120,6 +123,16 @@ export function payloadFingerprint(
|
||||
message.attachments.map((attachment) => attachment.id).sort(),
|
||||
message.inReplyTo ?? null,
|
||||
...(message.htmlBody ? [normalizeNewlines(message.htmlBody)] : []),
|
||||
// 서식 본문(글)과 섞이지 않게 객체로 — 같은 자리에 와도 JSON 이 다르다
|
||||
...(largeFileIds.length > 0
|
||||
? [
|
||||
{
|
||||
largeFileIds: [...largeFileIds]
|
||||
.map((id) => id.toLowerCase())
|
||||
.sort(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
const digest = createHmac('sha256', fingerprintKey(idempotencyKey))
|
||||
.update(canonical)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
ShareLinkDto,
|
||||
ShareLinkPageDto,
|
||||
} from '@/modules/file-sharing/dto/share-link.dto';
|
||||
import { FileIdPipe } from '@/modules/file-storage/file-id.pipe';
|
||||
import { MailLargeFilesService } from './mail-large-files.service';
|
||||
|
||||
/** 보낸 대용량 첨부 링크 경로 (`/api/share-links`) */
|
||||
export const SHARE_LINKS_ROUTE = 'share-links';
|
||||
|
||||
/**
|
||||
* 보낸 사람의 대용량 첨부 링크 관리 (11단계 — 계약 4절 "보낸 사람 관리") — 메일 한 통 = 링크 하나.
|
||||
* 링크는 공유 모듈이 맡고, 보낸 메일 제목을 메일 엔진에서 읽어 더하므로 메일 모듈에 둔다 (메일 → 공유 한 방향).
|
||||
* 링크 복사는 없다 — 토큰은 SHA-256 만이라 되살릴 수 없다(보낸 메일의 덩이에서 복사한다)
|
||||
*/
|
||||
@ApiTags('Files')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(SHARE_LINKS_ROUTE)
|
||||
export class ShareLinkController {
|
||||
constructor(private readonly largeFiles: MailLargeFilesService) {}
|
||||
|
||||
@Get()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '보낸 대용량 첨부 링크 (보낸 때 최근부터, 커서 페이지네이션)',
|
||||
description:
|
||||
'내가 만든 링크 — 취소한 링크(보내기 취소 · 보내지 못함)는 빠진다. 기록을 지우기 전(끝나거나 끊긴 뒤 90일)까지 기간 지남 · 끊음도 나온다. ' +
|
||||
'subject 는 보낸 사본을 메일 서버에서 한 번에 읽는다 — 지웠거나 읽지 못하면 null 이고 목록은 그대로 준다. total 포함.',
|
||||
})
|
||||
@ApiStandardResponse(ShareLinkPageDto, { description: '보낸 링크' })
|
||||
@ApiErrorResponse('VAL_001', '깨진 커서 · limit 범위 밖')
|
||||
list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: CursorPaginationQueryDto,
|
||||
) {
|
||||
return this.largeFiles.listShareLinks(user, query);
|
||||
}
|
||||
|
||||
@Post(':id/revocation')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '링크 끊기 — 그 링크(메일)의 파일 모두',
|
||||
description:
|
||||
'되돌릴 수 없다. 다음 요청부터 공개 화면은 "공유 멈춤"이고 받기는 410 FILE_007 이다 — 이미 시작한 내려받기는 끝까지 간다. ' +
|
||||
'그 파일들이 다른 살아 있는 링크 · 임시 저장에 없으면 보관량에서 바로 빠진다. 이미 끊었거나 기간이 지났으면 바꾸지 않고 200(지금 상태).',
|
||||
})
|
||||
@ApiStandardResponse(ShareLinkDto, { description: '끊은 링크 (revoked)' })
|
||||
@ApiErrorResponse('RES_001', '남의 링크 · 없는 것 · 취소한 링크')
|
||||
revoke(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', FileIdPipe) id: string,
|
||||
) {
|
||||
return this.largeFiles.revokeShareLink(user, id);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ const prod = (extra: Record<string, string> = {}) => ({
|
||||
MAIL_GATEWAY_PASSWORD: 'Gw7xQ2pLm9Rt4Vz8Kc3N',
|
||||
CORS_ORIGIN: 'https://mail.arasystem.it.kr',
|
||||
TRUST_PROXY: '2',
|
||||
SHARE_PUBLIC_URL: 'https://files.arasystem.it.kr',
|
||||
CLAMAV_HOST: 'clamav',
|
||||
...extra,
|
||||
});
|
||||
|
||||
@@ -396,11 +398,221 @@ describe('validateEnv', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('대용량 첨부 (11단계)', () => {
|
||||
const dev = (extra: Record<string, string>) => ({
|
||||
NODE_ENV: 'development',
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('비면 기본값 — 개발은 공개 주소 · 검사기 없이도 기동한다', () => {
|
||||
expect(() =>
|
||||
validateEnv(
|
||||
dev({ SHARE_PUBLIC_URL: '', CLAMAV_HOST: '', CLAMAV_PORT: '' }),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('숫자는 앞자리 0 없는 양의 정수 · 범위 안', () => {
|
||||
expect(() =>
|
||||
validateEnv(dev({ MAIL_LARGE_FILE_MAX_SIZE_MB: '02047' })),
|
||||
).toThrow(/MAIL_LARGE_FILE_MAX_SIZE_MB 는 양의 정수/);
|
||||
for (const [key, value, max] of [
|
||||
['MAIL_LARGE_FILE_MAX_SIZE_MB', '2048', '2,047'],
|
||||
['MAIL_LARGE_FILE_MAX_COUNT', '21', '20'],
|
||||
['MAIL_LARGE_FILE_USER_QUOTA_GB', '1001', '1,000'],
|
||||
['MAIL_LARGE_FILE_RETENTION_DAYS', '91', '90'],
|
||||
['SHARE_DOWNLOAD_LIMIT', '100001', '100,000'],
|
||||
['CLAMAV_PORT', '65536', '65,535'],
|
||||
]) {
|
||||
expect(() => validateEnv(dev({ [key]: value }))).toThrow(
|
||||
new RegExp(`${key} 는 ${max} 이하여야 합니다`),
|
||||
);
|
||||
}
|
||||
expect(() =>
|
||||
validateEnv(
|
||||
dev({
|
||||
MAIL_LARGE_FILE_MAX_SIZE_MB: '2047',
|
||||
MAIL_LARGE_FILE_MAX_COUNT: '20',
|
||||
MAIL_LARGE_FILE_USER_QUOTA_GB: '1000',
|
||||
MAIL_LARGE_FILE_RETENTION_DAYS: '90',
|
||||
SHARE_DOWNLOAD_LIMIT: '100000',
|
||||
FILE_STORAGE_MAX_TOTAL_GB: '500',
|
||||
FILE_STORAGE_MIN_FREE_GB: '10',
|
||||
CLAMAV_PORT: '3310',
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('한 파일 한도는 일반 첨부 한도 이상 · 보관량은 한 파일을 담을 수 있어야', () => {
|
||||
expect(() =>
|
||||
validateEnv(
|
||||
dev({
|
||||
MAIL_ATTACHMENT_MAX_SIZE_MB: '30',
|
||||
MAIL_LARGE_FILE_MAX_SIZE_MB: '25',
|
||||
}),
|
||||
),
|
||||
).toThrow(/MAIL_LARGE_FILE_MAX_SIZE_MB\(25\)는 일반 첨부 한도/);
|
||||
expect(() =>
|
||||
validateEnv(dev({ MAIL_LARGE_FILE_USER_QUOTA_GB: '1' })),
|
||||
).toThrow(/MAIL_LARGE_FILE_USER_QUOTA_GB\(1GB\)가 한 파일 한도/);
|
||||
});
|
||||
|
||||
it('공개 주소는 출처만 — 경로 · 끝 / · 스킴이 아닌 값은 실패', () => {
|
||||
for (const value of [
|
||||
'https://files.arasystem.it.kr/',
|
||||
'https://files.arasystem.it.kr/s',
|
||||
'files.arasystem.it.kr',
|
||||
'ftp://files.arasystem.it.kr',
|
||||
]) {
|
||||
expect(() => validateEnv(dev({ SHARE_PUBLIC_URL: value }))).toThrow(
|
||||
/SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처/,
|
||||
);
|
||||
}
|
||||
expect(() =>
|
||||
validateEnv(dev({ SHARE_PUBLIC_URL: 'http://localhost:5173' })),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('공개 주소는 앞단 nginx 가 받는 모양만 — nginx(frontend/nginx-env.envsh 의 nginx_env_origin_host)가 기동을 멈추는 값이면 백엔드도 멈춘다(반쪽 배포 막기)', () => {
|
||||
for (const value of [
|
||||
// 끝 점 — nginx 의 $host 는 끝 점을 떼어 백엔드 판정과 어긋난다
|
||||
'https://files.arasystem.it.kr.',
|
||||
'https://files.arasystem.it.kr..',
|
||||
// 점으로 시작 · 연이은 점 · 하이픈으로 시작하거나 끝나는 이름
|
||||
'https://.files.arasystem.it.kr',
|
||||
'https://files..arasystem.it.kr',
|
||||
'https://-files.arasystem.it.kr',
|
||||
'https://files.arasystem.it.kr-',
|
||||
'https://.',
|
||||
'https://-',
|
||||
// 0 으로 시작하는 포트 · 6자리 이상 포트 · 빈 포트
|
||||
'https://files.arasystem.it.kr:0',
|
||||
'https://files.arasystem.it.kr:0443',
|
||||
'https://files.arasystem.it.kr:08443',
|
||||
'https://files.arasystem.it.kr:100000',
|
||||
'https://files.arasystem.it.kr:000443',
|
||||
'https://files.arasystem.it.kr:',
|
||||
// 사용자 정보 · 대괄호(IPv6)
|
||||
'https://user@files.arasystem.it.kr',
|
||||
'https://@files.arasystem.it.kr',
|
||||
'https://[2001:db8::1]',
|
||||
'https://[2001:db8::1]:8443',
|
||||
// 경로 · 끝 /
|
||||
'https://files.arasystem.it.kr/',
|
||||
'https://files.arasystem.it.kr/s',
|
||||
// nginx 는 스킴을 글자 그대로 본다 · 영문 · 숫자 · . - : 밖의 글자 · 다듬지 않은 공백
|
||||
'HTTPS://files.arasystem.it.kr',
|
||||
'Https://files.arasystem.it.kr',
|
||||
'https://files_1.arasystem.it.kr',
|
||||
'https://files!.arasystem.it.kr',
|
||||
' https://files.arasystem.it.kr',
|
||||
'https://files.arasystem.it.kr ',
|
||||
' ',
|
||||
]) {
|
||||
expect(() => validateEnv(dev({ SHARE_PUBLIC_URL: value }))).toThrow(
|
||||
/SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처/,
|
||||
);
|
||||
}
|
||||
// nginx 와 백엔드가 함께 받는 모양 — 포트 · 대문자 호스트 · IPv4 · 이름 가운데의 하이픈
|
||||
for (const value of [
|
||||
'https://files.arasystem.it.kr:8443',
|
||||
'https://FILES.arasystem.it.kr',
|
||||
'http://127.0.0.1:8080',
|
||||
'https://files-1.arasystem.it.kr',
|
||||
'http://localhost:5173',
|
||||
]) {
|
||||
expect(() =>
|
||||
validateEnv(dev({ SHARE_PUBLIC_URL: value })),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('신고 주소는 메일 주소 · 검사기 주소는 호스트 이름만', () => {
|
||||
expect(() =>
|
||||
validateEnv(dev({ SHARE_REPORT_ADDRESS: 'postmaster' })),
|
||||
).toThrow(/SHARE_REPORT_ADDRESS 는 메일 주소/);
|
||||
expect(() =>
|
||||
validateEnv(dev({ CLAMAV_HOST: 'tcp://clamav:3310' })),
|
||||
).toThrow(/CLAMAV_HOST 는 악성 검사기/);
|
||||
expect(() =>
|
||||
validateEnv(
|
||||
dev({
|
||||
SHARE_REPORT_ADDRESS: 'postmaster@arasystem.it.kr',
|
||||
CLAMAV_HOST: 'clamav',
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('운영 환경', () => {
|
||||
it('정상 설정이면 통과한다', () => {
|
||||
expect(() => validateEnv(prod())).not.toThrow();
|
||||
});
|
||||
|
||||
it('대용량 첨부의 공개 주소 · 검사기가 없으면 실패한다 (11단계)', () => {
|
||||
expect(() => validateEnv(omit(prod(), 'SHARE_PUBLIC_URL'))).toThrow(
|
||||
/SHARE_PUBLIC_URL 가 설정되지 않았습니다/,
|
||||
);
|
||||
expect(() => validateEnv(omit(prod(), 'CLAMAV_HOST'))).toThrow(
|
||||
/CLAMAV_HOST 가 설정되지 않았습니다/,
|
||||
);
|
||||
});
|
||||
|
||||
it('운영의 공개 주소는 https · 로컬 주소 · 예시 도메인 금지 (ALLOW_LOCALHOST_URL 이면 건너뛴다)', () => {
|
||||
expect(() =>
|
||||
validateEnv(prod({ SHARE_PUBLIC_URL: 'http://files.arasystem.it.kr' })),
|
||||
).toThrow(/SHARE_PUBLIC_URL 은 운영에서 https/);
|
||||
expect(() =>
|
||||
validateEnv(prod({ SHARE_PUBLIC_URL: 'https://files.example.com' })),
|
||||
).toThrow(/SHARE_PUBLIC_URL 가 운영에서 쓸 수 없는 주소입니다/);
|
||||
expect(() =>
|
||||
validateEnv(
|
||||
prod({
|
||||
SHARE_PUBLIC_URL: 'http://localhost:5173',
|
||||
ALLOW_LOCALHOST_URL: 'true',
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('운영의 공개 주소는 포털(CORS_ORIGIN)과 다른 호스트여야 한다 — 포트만 다르거나 대소문자 · 끝의 점만 달라도 같은 호스트다', () => {
|
||||
for (const [share, portal] of [
|
||||
['https://mail.arasystem.it.kr', 'https://mail.arasystem.it.kr'],
|
||||
['https://mail.arasystem.it.kr:8443', 'https://mail.arasystem.it.kr'],
|
||||
[
|
||||
'https://files.arasystem.it.kr',
|
||||
'https://portal.arasystem.it.kr,https://FILES.arasystem.it.kr.',
|
||||
],
|
||||
]) {
|
||||
expect(() =>
|
||||
validateEnv(prod({ SHARE_PUBLIC_URL: share, CORS_ORIGIN: portal })),
|
||||
).toThrow(
|
||||
/SHARE_PUBLIC_URL 의 호스트\(.+\)가 CORS_ORIGIN\(포털\)의 호스트와 같습니다/,
|
||||
);
|
||||
}
|
||||
// 로컬에서 운영 이미지를 검증할 때도 이름을 나눈다 — localhost 와 127.0.0.1 은 다른 호스트
|
||||
expect(() =>
|
||||
validateEnv(
|
||||
prod({
|
||||
SHARE_PUBLIC_URL: 'http://127.0.0.1:8080',
|
||||
CORS_ORIGIN: 'http://localhost:8080',
|
||||
BACKEND_API_URL: 'http://localhost:8080/api',
|
||||
ALLOW_LOCALHOST_URL: 'true',
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
// 비운영(개발)은 같은 주소를 쓴다 — 막지 않는다
|
||||
expect(() =>
|
||||
validateEnv({
|
||||
NODE_ENV: 'development',
|
||||
SHARE_PUBLIC_URL: 'http://localhost:5173',
|
||||
CORS_ORIGIN: 'http://localhost:5173',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('필수 DB 설정이 없으면 실패한다', () => {
|
||||
expect(() => validateEnv(omit(prod(), 'DB_PASSWORD'))).toThrow(
|
||||
/DB_PASSWORD 가 설정되지 않았습니다/,
|
||||
|
||||
@@ -14,14 +14,27 @@ import {
|
||||
REFRESH_TTL_MAX_SECONDS,
|
||||
} from '@/modules/auth/auth.constants';
|
||||
import { isLocalPart } from '@/modules/auth/employee-address';
|
||||
import { SHARE_DOWNLOAD_LIMIT_MAX } from '@/modules/file-sharing/file-sharing.settings';
|
||||
import {
|
||||
DEFAULT_LARGE_FILE_MAX_SIZE_MB,
|
||||
DEFAULT_LARGE_FILE_USER_QUOTA_GB,
|
||||
LARGE_FILE_MAX_COUNT_LIMIT,
|
||||
LARGE_FILE_MAX_SIZE_MB_LIMIT,
|
||||
LARGE_FILE_RETENTION_DAYS_LIMIT,
|
||||
LARGE_FILE_USER_QUOTA_GB_LIMIT,
|
||||
} from '@/modules/file-storage/file-storage.settings';
|
||||
import {
|
||||
DEFAULT_ATTACHMENT_MAX_SIZE_MB,
|
||||
DEFAULT_MAIL_ADMIN_LOCAL_PART,
|
||||
DEFAULT_MAIL_DIRECTORY_LOCAL_PART,
|
||||
parseAccountList,
|
||||
} from '@/modules/mail/mail.settings';
|
||||
import { parseDurationSeconds } from '@/shared/utils/duration';
|
||||
import { isPositiveIntString } from '@/shared/utils/env';
|
||||
import { parseOriginList } from '@/shared/utils/request-origin';
|
||||
import {
|
||||
normalizeOrigin,
|
||||
parseOriginList,
|
||||
} from '@/shared/utils/request-origin';
|
||||
import {
|
||||
parseTrustProxy,
|
||||
unsafeTrustProxyReason,
|
||||
@@ -49,6 +62,10 @@ const REQUIRED_IN_PRODUCTION = [
|
||||
'CORS_ORIGIN',
|
||||
// 백엔드 앞의 프록시 — 클라이언트 IP(로그인 실패 한도·요청 수 한도의 기준)를 정한다
|
||||
'TRUST_PROXY',
|
||||
// 대용량 첨부(11단계)의 공개 내려받기 주소 — 덩이 · 링크 · 덩이 알아보기 · 공개 호스트 막기 (계약 4절 "공개 호스트")
|
||||
'SHARE_PUBLIC_URL',
|
||||
// 대용량 첨부의 악성 검사기(clamd) — 운영은 검사 없이 받을 수 있게 되는 일이 없게 (계약 4절 "악성 검사")
|
||||
'CLAMAV_HOST',
|
||||
] as const;
|
||||
|
||||
/** 값이 있다면 양의 정수(앞자리 0 없이)여야 하는 키 — shared/utils/env.ts 의 isPositiveIntString */
|
||||
@@ -61,6 +78,15 @@ const POSITIVE_INT_KEYS = [
|
||||
'UPLOAD_MAX_SIZE_MB',
|
||||
'MAIL_ATTACHMENT_MAX_SIZE_MB',
|
||||
'MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB',
|
||||
// 대용량 첨부(11단계 — 계약 4절 "환경 변수") — 범위는 validateLargeFileSettings
|
||||
'SHARE_DOWNLOAD_LIMIT',
|
||||
'MAIL_LARGE_FILE_MAX_SIZE_MB',
|
||||
'MAIL_LARGE_FILE_MAX_COUNT',
|
||||
'MAIL_LARGE_FILE_USER_QUOTA_GB',
|
||||
'MAIL_LARGE_FILE_RETENTION_DAYS',
|
||||
'FILE_STORAGE_MAX_TOTAL_GB',
|
||||
'FILE_STORAGE_MIN_FREE_GB',
|
||||
'CLAMAV_PORT',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -102,7 +128,12 @@ const JWT_SECRET_MIN_LENGTH_IN_PRODUCTION = 32;
|
||||
* 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만
|
||||
* 브라우저가 API 에 닿지 못하거나 CORS 화이트리스트가 실제 도메인을 막아 **조용히** 실패한다.
|
||||
*/
|
||||
const PUBLIC_URL_IN_PRODUCTION = ['CORS_ORIGIN', 'BACKEND_API_URL'] as const;
|
||||
const PUBLIC_URL_IN_PRODUCTION = [
|
||||
'CORS_ORIGIN',
|
||||
'BACKEND_API_URL',
|
||||
// 대용량 첨부의 공개 내려받기 주소 — 메일 본문의 링크가 된다 (11단계)
|
||||
'SHARE_PUBLIC_URL',
|
||||
] as const;
|
||||
|
||||
/** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */
|
||||
const NON_PUBLIC_URL_PATTERNS = [
|
||||
@@ -255,6 +286,171 @@ function validateAttachmentLimits(
|
||||
}
|
||||
}
|
||||
|
||||
/** 숫자 env 의 범위 — 형식(양의 정수)은 위에서 이미 알렸다. 값이 있을 때만 */
|
||||
const LARGE_FILE_RANGES: readonly [key: string, max: number][] = [
|
||||
['SHARE_DOWNLOAD_LIMIT', SHARE_DOWNLOAD_LIMIT_MAX],
|
||||
['MAIL_LARGE_FILE_MAX_SIZE_MB', LARGE_FILE_MAX_SIZE_MB_LIMIT],
|
||||
['MAIL_LARGE_FILE_MAX_COUNT', LARGE_FILE_MAX_COUNT_LIMIT],
|
||||
['MAIL_LARGE_FILE_USER_QUOTA_GB', LARGE_FILE_USER_QUOTA_GB_LIMIT],
|
||||
['MAIL_LARGE_FILE_RETENTION_DAYS', LARGE_FILE_RETENTION_DAYS_LIMIT],
|
||||
['CLAMAV_PORT', 65_535],
|
||||
];
|
||||
|
||||
/** 견줄 호스트 이름 — 출처의 호스트를 소문자로, 끝의 점을 떼고 (포트는 보지 않는다 — 앞단 nginx 는 포트 없는 Host 를 넘긴다) */
|
||||
function comparableHostname(origin: string): string {
|
||||
return new URL(origin).hostname.toLowerCase().replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
/** 해석한 양의 정수 — 형식이 틀렸거나 비었으면 null (형식 오류는 따로 알린다) */
|
||||
function readPositiveInt(
|
||||
config: Record<string, unknown>,
|
||||
key: string,
|
||||
): number | null {
|
||||
const value = readString(config, key);
|
||||
return value !== undefined && isPositiveIntString(value)
|
||||
? Number(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** 호스트 이름 · IPv4 (스킴 · 포트 · 경로 없이) — 악성 검사기 주소 */
|
||||
const HOST_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;
|
||||
|
||||
/** nginx-env.envsh 가 받는 출처의 스킴 — 글자 그대로(대문자 없이) */
|
||||
const NGINX_ORIGIN_SCHEMES = ['http://', 'https://'] as const;
|
||||
/** 스킴 뒤(호스트[:포트])에 쓸 수 있는 글자 — 경로(/) · 쿼리 · 공백 · 대괄호(IPv6) · 사용자 정보(@) · `_` 는 받지 않는다 */
|
||||
const NGINX_ORIGIN_HOST_PORT = /^[A-Za-z0-9.:-]+$/;
|
||||
/** 포트 — 0 으로 시작하지 않는 숫자 5자리까지 */
|
||||
const NGINX_ORIGIN_PORT = /^[1-9][0-9]{0,4}$/;
|
||||
/** 호스트 이름의 틀린 모양 — 점 · 하이픈으로 시작하거나 끝남(끝 점 포함) · 연이은 점 */
|
||||
const NGINX_ORIGIN_BAD_HOST = /^[.-]|[.-]$|\.\./;
|
||||
|
||||
/**
|
||||
* 앞단 nginx 가 받는 출처인가 — frontend/nginx-env.envsh 의 nginx_env_origin_host 와 **짝**이다(한쪽을 고치면 둘 다).
|
||||
* nginx 는 공개 내려받기 호스트를 server_name 에 그대로 넣으려고 SHARE_PUBLIC_URL 을 다듬지 않고 셸의 case 로 보고, 틀리면 기동을 멈춘다.
|
||||
* 백엔드가 그 값을 받으면 백엔드만 뜨고 화면은 뜨지 않는 반쪽 배포가 되므로 같은 값을 거절한다 (NGINX_SHARED_INT_KEYS 와 같은 원칙):
|
||||
* - 스킴은 글자 그대로 `http://` · `https://`
|
||||
* - 나머지는 영문 · 숫자 · `.` · `-` · `:` 만
|
||||
* - 첫 `:` 뒤의 포트는 비지 않고 0 으로 시작하지 않는 숫자 5자리까지 (65535 를 넘는 값은 URL 해석이 따로 막는다)
|
||||
* - 호스트는 비지 않고 점 · 하이픈으로 시작하거나 끝나지 않으며 점이 이어지지 않는다 — 끝 점은 nginx 의 $host 가 떼어 백엔드 판정과 어긋난다
|
||||
*/
|
||||
function isNginxReadableOrigin(value: string): boolean {
|
||||
const scheme = NGINX_ORIGIN_SCHEMES.find((prefix) =>
|
||||
value.startsWith(prefix),
|
||||
);
|
||||
if (scheme === undefined) return false;
|
||||
const hostPort = value.slice(scheme.length);
|
||||
if (!NGINX_ORIGIN_HOST_PORT.test(hostPort)) return false;
|
||||
const colon = hostPort.indexOf(':');
|
||||
const host = colon < 0 ? hostPort : hostPort.slice(0, colon);
|
||||
if (colon >= 0 && !NGINX_ORIGIN_PORT.test(hostPort.slice(colon + 1))) {
|
||||
return false;
|
||||
}
|
||||
return host !== '' && !NGINX_ORIGIN_BAD_HOST.test(host);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대용량 첨부 설정 (11단계 — 계약 4절 "환경 변수") — 값이 있을 때 범위 · 형식을 본다(모든 환경).
|
||||
* - 한 파일 ≤ 2,047MB(검사기가 검사할 수 있는 2GiB 아래) · 일반 첨부 한도 이상, 한 통 ≤ 20, 보관량 ≤ 1,000GB 이고 한 파일을 담을 수 있어야, 보관 ≤ 90일, 내려받기 ≤ 100,000회
|
||||
* - 공개 주소는 출처만(스킴 · 호스트 · 포트 — 경로 · 끝 / 없음)이고 앞단 nginx 가 받는 모양이어야(isNginxReadableOrigin — 모든 환경),
|
||||
* 운영은 https (로컬 · 예시 도메인은 아래 공개 주소 검사 — ALLOW_LOCALHOST_URL 이면 둘 다 건너뛴다)
|
||||
* - 신고 주소는 메일 주소, 검사기 주소는 호스트 이름만
|
||||
*/
|
||||
function validateLargeFileSettings(
|
||||
config: Record<string, unknown>,
|
||||
isProduction: boolean,
|
||||
errors: string[],
|
||||
): void {
|
||||
for (const [key, max] of LARGE_FILE_RANGES) {
|
||||
const value = readPositiveInt(config, key);
|
||||
if (value !== null && value > max) {
|
||||
errors.push(
|
||||
`${key} 는 ${max.toLocaleString('en-US')} 이하여야 합니다. (현재 값: "${readString(config, key)}")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const configuredLargeFileMb = readPositiveInt(
|
||||
config,
|
||||
'MAIL_LARGE_FILE_MAX_SIZE_MB',
|
||||
);
|
||||
const largeFileMb = configuredLargeFileMb ?? DEFAULT_LARGE_FILE_MAX_SIZE_MB;
|
||||
const attachmentMb =
|
||||
readPositiveInt(config, 'MAIL_ATTACHMENT_MAX_SIZE_MB') ??
|
||||
DEFAULT_ATTACHMENT_MAX_SIZE_MB;
|
||||
// 한 파일 한도를 정했을 때만 견준다 (값이 있을 때만 보는 다른 검사와 같다)
|
||||
if (configuredLargeFileMb !== null && largeFileMb < attachmentMb) {
|
||||
errors.push(
|
||||
`MAIL_LARGE_FILE_MAX_SIZE_MB(${largeFileMb})는 일반 첨부 한도 MAIL_ATTACHMENT_MAX_SIZE_MB(${attachmentMb})보다 작을 수 없습니다 — 일반 첨부보다 큰 파일을 링크로 보낸다.`,
|
||||
);
|
||||
}
|
||||
const quotaGb =
|
||||
readPositiveInt(config, 'MAIL_LARGE_FILE_USER_QUOTA_GB') ??
|
||||
DEFAULT_LARGE_FILE_USER_QUOTA_GB;
|
||||
if (quotaGb * 1024 < largeFileMb) {
|
||||
errors.push(
|
||||
`MAIL_LARGE_FILE_USER_QUOTA_GB(${quotaGb}GB)가 한 파일 한도 MAIL_LARGE_FILE_MAX_SIZE_MB(${largeFileMb}MB)보다 작습니다 — 한도까지 큰 파일 하나를 올릴 수 없게 됩니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 앞단 nginx 는 이 값을 다듬지 않고 읽는다(비었을 때만 "없음") — 앞뒤 공백도 같은 값으로 본다
|
||||
const rawPublicUrl = config.SHARE_PUBLIC_URL;
|
||||
const publicUrl =
|
||||
typeof rawPublicUrl === 'string' && rawPublicUrl !== ''
|
||||
? rawPublicUrl
|
||||
: undefined;
|
||||
if (publicUrl !== undefined) {
|
||||
const origin = normalizeOrigin(publicUrl);
|
||||
if (
|
||||
origin === null ||
|
||||
origin !== publicUrl.toLowerCase() ||
|
||||
!isNginxReadableOrigin(publicUrl)
|
||||
) {
|
||||
errors.push(
|
||||
`SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처만 씁니다 — http(s)://호스트[:포트]. 앞단 nginx(frontend/nginx-env.envsh)와 같은 규칙이라 틀리면 화면이 뜨지 않습니다:` +
|
||||
` 스킴은 소문자, 호스트는 영문 · 숫자 · '.' · '-' 만(점 · 하이픈으로 시작하거나 끝나지 않고 점이 이어지지 않게 — 끝 점 없이), 포트는 0 으로 시작하지 않는 숫자,` +
|
||||
` 경로 · 끝의 / · 사용자 정보(@) · 대괄호(IPv6) · 앞뒤 공백 없이. (예: https://files.arasystem.it.kr, 현재 값: "${publicUrl}")`,
|
||||
);
|
||||
} else if (isProduction) {
|
||||
if (
|
||||
!origin.startsWith('https://') &&
|
||||
readString(config, ALLOW_LOCALHOST_URL_KEY) !== 'true'
|
||||
) {
|
||||
errors.push(
|
||||
`SHARE_PUBLIC_URL 은 운영에서 https 여야 합니다 — 받는 사람이 내려받는 주소다. (현재 값: "${publicUrl}")`,
|
||||
);
|
||||
}
|
||||
// 공개 호스트는 포털과 따로 둔다 — 호스트 이름이 같으면(포트만 달라도) 백엔드가 둘을 가르지 못해 공개 호스트 막기가 소리 없이 꺼진다
|
||||
// (file-sharing.settings.ts 의 dedicatedPublicHost · 계약 4절 "공개 호스트")
|
||||
const shareHost = comparableHostname(origin);
|
||||
const { origins: portalOrigins } = parseOriginList(
|
||||
readString(config, 'CORS_ORIGIN'),
|
||||
);
|
||||
if (
|
||||
portalOrigins.some((portal) => comparableHostname(portal) === shareHost)
|
||||
) {
|
||||
errors.push(
|
||||
`SHARE_PUBLIC_URL 의 호스트(${shareHost})가 CORS_ORIGIN(포털)의 호스트와 같습니다 — 운영에서는 공개 내려받기 호스트를 포털과 따로 둡니다(같으면 공개 호스트 막기가 꺼진다).` +
|
||||
` 로컬에서 운영 이미지를 검증할 때도 서로 다른 이름(예: localhost 와 127.0.0.1)을 씁니다.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reportAddress = readString(config, 'SHARE_REPORT_ADDRESS');
|
||||
if (reportAddress !== undefined && !isAccountAddress(reportAddress)) {
|
||||
errors.push(
|
||||
`SHARE_REPORT_ADDRESS 는 메일 주소여야 합니다. (예: postmaster@arasystem.it.kr, 현재 값: "${reportAddress}")`,
|
||||
);
|
||||
}
|
||||
|
||||
const clamavHost = readString(config, 'CLAMAV_HOST');
|
||||
if (clamavHost !== undefined && !HOST_PATTERN.test(clamavHost)) {
|
||||
errors.push(
|
||||
`CLAMAV_HOST 는 악성 검사기(clamd)의 호스트 이름만 씁니다 — 스킴 · 포트 없이(포트는 CLAMAV_PORT). (예: clamav, 현재 값: "${clamavHost}")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 도메인 이름 형식 (예: arasystem.it.kr, arasystem.test) — '@' 나 스킴이 섞이면 거부한다 */
|
||||
const DOMAIN_PATTERN =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
|
||||
@@ -421,6 +617,9 @@ export function validateEnv(
|
||||
validateTrustProxy(config, isProduction, errors);
|
||||
validateOrigins(config, errors);
|
||||
|
||||
// 1-5. 대용량 첨부 (11단계)
|
||||
validateLargeFileSettings(config, isProduction, errors);
|
||||
|
||||
if (isProduction) {
|
||||
// 2. 운영 필수값 — 없으면 기동 실패
|
||||
for (const key of REQUIRED_IN_PRODUCTION) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { BIGINT_NUMBER } from './bigint-column';
|
||||
|
||||
describe('BIGINT_NUMBER (bigint 열 ↔ 숫자)', () => {
|
||||
it('DB 의 문자열을 숫자로 — null 은 그대로', () => {
|
||||
expect(BIGINT_NUMBER.from('2146435072')).toBe(2_146_435_072);
|
||||
expect(BIGINT_NUMBER.from(null)).toBeNull();
|
||||
expect(BIGINT_NUMBER.to(10)).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ValueTransformer } from 'typeorm';
|
||||
|
||||
/**
|
||||
* PostgreSQL `bigint` 열 ↔ JS 숫자 (TypeORM 열의 transformer) — pg 드라이버는 bigint 를 문자열로 돌려준다
|
||||
* (2^53 을 넘을 수 있어서). 크기 · 횟수처럼 안전한 정수 범위 안의 값에만 쓴다 — 그대로 두면 `'10' > '9'` 같은 문자열 비교가 된다.
|
||||
*/
|
||||
export const BIGINT_NUMBER: ValueTransformer = {
|
||||
to: (value: number | null | undefined) => value,
|
||||
from: (value: string | number | null) =>
|
||||
value === null || value === undefined ? null : Number(value),
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
FALLBACK_FILE_NAME,
|
||||
MAX_FILE_NAME_LENGTH,
|
||||
fileExtension,
|
||||
isControlOrBidiChar,
|
||||
sanitizeFileName,
|
||||
} from './file-name';
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('경로를 버리고 파일명만 남긴다 (경로 탈출 방지)', () => {
|
||||
expect(sanitizeFileName('../../etc/passwd')).toBe('passwd');
|
||||
expect(sanitizeFileName('C:\\Users\\kim\\보고서.pdf')).toBe('보고서.pdf');
|
||||
});
|
||||
|
||||
it('제어 문자와 방향 제어 문자를 지운다 (확장자 뒤집기 위장 방지)', () => {
|
||||
expect(sanitizeFileName('a\r\nb.txt')).toBe('ab.txt');
|
||||
expect(sanitizeFileName('invoice\u202Etxt.exe')).toBe('invoicetxt.exe');
|
||||
});
|
||||
|
||||
it('너무 긴 이름은 확장자를 남기고 자른다', () => {
|
||||
const long = `${'가'.repeat(300)}.xlsx`;
|
||||
const result = sanitizeFileName(long);
|
||||
|
||||
expect(Array.from(result)).toHaveLength(MAX_FILE_NAME_LENGTH);
|
||||
expect(result.endsWith('.xlsx')).toBe(true);
|
||||
});
|
||||
|
||||
it('남는 것이 없으면 기본 이름을 쓴다', () => {
|
||||
expect(sanitizeFileName('')).toBe(FALLBACK_FILE_NAME);
|
||||
expect(sanitizeFileName('dir/')).toBe(FALLBACK_FILE_NAME);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileExtension · isControlOrBidiChar', () => {
|
||||
it('확장자는 소문자 · 점 포함, 없으면 빈 문자열', () => {
|
||||
expect(fileExtension('보고서.PDF')).toBe('.pdf');
|
||||
expect(fileExtension('archive.tar.7Z')).toBe('.7z');
|
||||
expect(fileExtension('README')).toBe('');
|
||||
});
|
||||
|
||||
it('제어 문자 · 방향 제어 문자를 알아본다', () => {
|
||||
expect(isControlOrBidiChar(0x0a)).toBe(true);
|
||||
expect(isControlOrBidiChar(0x202e)).toBe(true);
|
||||
expect(isControlOrBidiChar('가'.codePointAt(0) ?? 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user