# ========================================== # 프론트엔드 nginx — 화면(SPA) 정적 호스팅 + /api 를 백엔드로 넘기는 프록시 # # 같은 주소(origin) 배포 — 브라우저는 화면과 같은 주소의 /api 를 부른다 (docs/mail/api-contract.md 머리말). # 그래서 CORS 가 필요 없고 CSP connect-src 'self' 로 충분하다. 운영의 입구는 이 nginx 하나다. # # server 가 둘이다 (11단계 — 대용량 첨부의 공개 내려받기, 계약 4절 "공개 호스트"). 어느 쪽인지는 Host 로 가른다 — # 외부 리버스 프록시는 두 호스트를 모두 이 8080 으로 넘기며 Host 를 그대로 둔다 (mail/README.md "Nginx Proxy Manager 설정"): # 포털 default_server — 웹메일 화면과 /api. 공개 내려받기(/s/ · /api/public-links/)는 공개 호스트를 따로 두면 404 로 닫는다 # 공개 내려받기 SHARE_PUBLIC_URL 의 호스트 이름 — /s/<토큰>(화면) · 화면 자산 · /api/public-links/ 만 연다. 그 밖은 404 # 공개 호스트를 따로 두지 않으면(SHARE_PUBLIC_URL 이 비었거나 그 호스트 이름이 CORS_ORIGIN 에 있다) 포털이 둘 다 연다 — nginx-env.envsh # 메일 프로그램 자동 설정(13단계) autoconfig. · autodiscover. — 고정 XML 만 낸다(백엔드로 넘기지 않는다). 파일 끝의 셋째 server # # ⚠️ 백엔드(Express)는 경로의 대소문자를 가리지 않는다 — /api/Public-Links/… · /api/messages/x/LARGE-FILES/… 도 처리기에 닿는다. # 그래서 /api 아래에서 경로로 무엇을 정하는 location(받기의 동시 연결 수 · 버퍼링 끔, 포털의 공개 API 닫기, 조각 본문 한도)은 # 대소문자를 가리지 않는 정규식(~*)으로 /api/ 블록 안에 둔다 — 대소문자를 가리는 prefix location(^~ /api/messages 등)에 기대면 변형이 비켜 간다 # (2026-10-30 리뷰 — 받기 limit_conn 을 /FILES/ · /Messages/ 로 비켜 갔다). 백엔드의 대소문자 구분(겹 방어)에는 기대지 않는다. # /API/… 처럼 /api/ 자체가 다르면 /api/ 블록에 들지 않아 백엔드로 가지 않는다(포털은 SPA 화면, 공개 내려받기는 404). # # ⚠️ 이 파일은 템플릿이다. 컨테이너가 뜰 때 nginx 이미지의 entrypoint 가 이 파일 # (/etc/nginx/templates/default.conf.template)의 ${NGINX_*} 만 envsubst 로 채워 /etc/nginx/conf.d/default.conf 를 만든다. # 값은 nginx-env.envsh 가 루트 env(MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB·SHARE_PUBLIC_URL·CORS_ORIGIN 등)에서 계산한다. # nginx 변수($uri 등)는 NGINX_ 로 시작하지 않아 그대로 남는다 (Dockerfile 의 NGINX_ENVSUBST_FILTER). # 두 server 가 함께 쓰는 location 은 /etc/nginx/snippets/ 의 파일이다(템플릿이 아니라 그대로 쓴다): # share-open.conf · share-closed.conf server 단계 — 공개 화면 /s/ (연다 · 닫는다) # share-api-open.conf · share-api-closed.conf /api/ 블록 안 — 공개 API /api/public-links/ (연다 · 닫는다) # internal-api-closed.conf /api/ 블록 안 — 엔진만 부르는 /api/internal/ (늘 닫는다 — 12단계 엔진 웹훅) # api-errors.conf server 단계 — nginx 가 직접 끝내는 /api 오류의 JSON(@api_*) # 이 파일은 http 단계에 들어간다(이미지의 /etc/nginx/nginx.conf 가 conf.d 를 http 안에서 읽는다) — 아래 map · proxy_* · gzip 은 두 server 가 물려받는다. # ========================================== # 접근 로그 — 쿼리를 남기지 않는다. 검색어(q)가 /api 주소뿐 아니라 화면 주소(/mail/search?q=…)에도 들어간다 # (계약 2절 "검색"·"받는 사람 추천", 로깅 규칙). 형식은 main_rid 하나이고 server 단계에만 건다 — 모든 location # (/api·오류용 이름 있는 location 포함)과 location 을 고르기 전에 거부된 요청(헤더가 너무 큰 400 등)이 물려받는다. # - 요청 줄: $request 에서 ? 부터 다음 빈칸 앞까지 뺀다 ("GET /mail/search?q=… HTTP/1.1" → "GET /mail/search HTTP/1.1"). # $uri 로 다시 짜지 않는다 — SPA 폴백(try_files … /index.html) 뒤에는 $uri 가 /index.html 이라 어느 화면이었는지 사라진다. # - Referer: ? 앞까지만 남긴다. 화면 응답이 Referrer-Policy strict-origin 이라(아래 보안 헤더) 브라우저는 같은 주소의 요청에도 # origin 만 보내지만, 배포 전에 연 탭은 옛 정책(보던 화면 주소를 쿼리째)으로 계속 보내고 브라우저가 아닌 클라이언트는 무엇이든 보낸다. # - 내려받기 토큰도 가린다 (11단계 — 계약 4절 "로그"). 토큰을 가진 누구나 파일을 받으므로 로그를 읽는 사람이 받지 못하게 — 요청 줄 · Referer 둘 다, # 두 가지로 겹쳐 가린다: # (1) 경로 모양 — /s/<…> · /api/public-links/<…> 의 그 칸을 길이와 상관없이 ***(대소문자 무시). 잘린 토큰(몇 글자 모자란 것)도 가린다 # (2) 토큰 모양 — 토큰은 base64url 43자다(backend modules/file-sharing/share-token.ts). 앞뒤가 [A-Za-z0-9_-] 가 아닌 딱 43자 덩어리, # 그리고 %XY(퍼센트 인코딩) 바로 뒤의 43자 덩어리를 *** 로 — 경로 모양이 어긋나도(//s/T · /s//T · /./s/T · /%73/T · /x/../s/T · # /api//public-links/T · /api/public%2Dlinks/T · /api/public-links%2FT — nginx 는 정규화해 받지만 로그는 받은 그대로다) 가린다. # 43자가 아닌 값(UUID 36자 · 메일 id · 엔진의 첨부 id 처럼 더 긴 것)은 가리지 않는다. 요청 줄은 두 번 본다(한 줄에 토큰이 둘이어도) # 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴(/s/AbC%44ef…)은 가리지 못한다 — 그 토큰을 가진 본인만 만들 수 있는 꼴이다 # - 값이 없으면 지금처럼 "-" 다 (map 의 정규식은 빈 값에는 돌지 않아 "" 를 따로 적는다). # 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째(토큰째) 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그"). map $request $log_request_unqueried { "" "-"; "~^(?[^?]*)\?[^ ]*(?.*)" $log_request_head$log_request_tail; default $request; } map $log_request_unqueried $log_request_pathmasked { "~*^(?[^ ]+ (?:https?://[^/ ]+)?/(?:s|api/public-links)/)[^/ ]+(?.*)$" $log_path_head***$log_path_tail; default $log_request_unqueried; } map $log_request_pathmasked $log_request_tokenmasked { "~^(?.*?)(?:(?.*)$" $log_token1_head***$log_token1_tail; default $log_request_pathmasked; } map $log_request_tokenmasked $log_request_line { "~^(?.*?)(?:(?.*)$" $log_token2_head***$log_token2_tail; default $log_request_tokenmasked; } map $http_referer $log_referer_unqueried { "" "-"; "~^(?[^?]*)\?" $log_referer_head; default $http_referer; } map $log_referer_unqueried $log_referer_pathmasked { "~*^(?(?:https?://[^/]+)?/(?:s|api/public-links)/)[^/]+(?.*)$" $log_referer_path_head***$log_referer_path_tail; default $log_referer_unqueried; } map $log_referer_pathmasked $log_referer { "~^(?.*?)(?:(?.*)$" $log_referer_token_head***$log_referer_token_tail; default $log_referer_pathmasked; } # 새 메일 확인(GET /api/mail-state — 화면이 보이는 동안 탭마다 1분에 한 번)은 성공(2xx·304)하면 접근 로그를 남기지 않는다 — # 로깅 규칙 "반복되는 정상 흐름(헬스체크, 폴링)은 로그를 남기지 않는다". 실패(401·5xx 등, 오류용 이름 있는 location 포함)는 남긴다. # 0 이면 server 단계 access_log 의 if= 가 그 줄을 쓰지 않는다. $uri 는 쿼리를 뺀 경로다 (이 요청은 내부 이동이 없다) # 실시간 알림(GET /api/mail-events, SSE)은 성공도 남긴다 — 줄은 연결이 끝날 때 하나(브라우저마다 대략 1시간에 한 번 — 1분 확인의 수십분의 1)이고 # rt= 가 연결이 이어진 시간이다. 스트림이 시작된 뒤에는 어떻게 끝나도(서버의 end · 탭 닫기 · 백엔드가 중간에 끊김) 상태가 200 이라, # 200 을 빼면 일찍 끊기는 연결의 흔적까지 사라진다 (2026-10-28 확인). 연결 전 실패(401·429·503·502 등)는 물론 남는다. map "$request_method:$uri:$status" $log_access { "~^GET:/api/mail-state:(?:2[0-9][0-9]|304)$" 0; default 1; } # 요청 추적 — rid 는 백엔드로 넘기는 X-Request-Id 이자 실패 응답의 error.traceId 와 같은 값이다 log_format main_rid '$remote_addr - $remote_user [$time_local] "$log_request_line" $status $body_bytes_sent ' '"$log_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time'; # Content-Security-Policy — XSS 방어의 핵심 헤더. 두 server(포털 · 공개 내려받기)와 공개 화면(/s/)이 이 값 하나를 쓴다 (map 은 늘 같은 값) # # ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명: # - default-src 'self' : 기본적으로 같은 출처만 허용 # - style-src 'unsafe-inline' : 웹메일 본문 iframe(srcdoc) 때문이다 (아래). srcdoc 문서는 이 정책을 상속하는데, # 그 안에는 앱이 넣는