generated from ttipo/project-base
feat(docker): 대용량 첨부 — ClamAV 검사기 · 공개 내려받기 호스트 · 받기 동시 연결 상한
- ClamAV 1.4.6 LTS 를 둘로 나눴다: clamd 는 백엔드와만 닿는 internal 네트워크(바깥 길 없음 · 서명 볼륨 읽기 전용), freshclam 은 자기 바깥 길에만. 비-root · cap_drop ALL. 운영은 늘 뜨고 개발은 profile clamav. clamd.conf 는 한 파일 한도 2047M 까지 끝까지 검사하고 암호 · 한도 초과를 판정으로 알린다(실측표는 mail/README). 검사 중에 죽으면 남던 임시 파일을 띄울 때 지운다. - nginx 를 포털 · 공개 내려받기(files.) 두 server 로 가른다(SHARE_PUBLIC_URL 과 CORS_ORIGIN 의 호스트 비교 — 같으면 포털이 함께 연다). 조각 PUT 17m, 받기는 버퍼링 · 압축 끔 + X-Accel-Buffering 넘김 + 머리글 버퍼 16k, limit_conn 은 X-Forwarded-For 마지막 칸으로 IP 마다 공개 10 · 앱 안 30, 전체 공개 400 · 앱 안 200. 받기 location 은 /api/ 블록 안의 대소문자를 가리지 않는 정규식이다(리뷰 — /FILES/ 같은 변형이 상한을 비켜 갔다). 접근 로그는 경로 모양과 43자 토큰 모양으로 토큰을 가린다. - 파일은 named volume <PROJECT_NAME>_files(개발도 — builder 단계가 /app/files 를 node 소유로 만든다), 백업하지 않는다. - CI infra 잡: nginx 두 꼴 · 받기 경로 변형 시험(가짜 백엔드) · clamconf(-n 없이 기본값까지) · 틀린 SHARE_PUBLIC_URL 거부. - mail/README: 악성 검사 · 공개 호스트 · NPM 칸(Advanced 다섯 줄) · 메모리 예산 · 문제 해결. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+13
-1
@@ -46,8 +46,20 @@ FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS nginx-config
|
||||
ENV NGINX_ENVSUBST_FILTER=^NGINX_ \
|
||||
NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1
|
||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||
# 템플릿 값 계산 (루트 env 의 첨부·업로드 한도 → nginx 본문 한도). 실행 권한이 있어야 entrypoint 가 source 한다
|
||||
# 템플릿 값 계산 (루트 env 의 첨부·업로드 한도 → nginx 본문 한도, 공개 내려받기 호스트). 실행 권한이 있어야 entrypoint 가 source 한다
|
||||
COPY --chmod=755 nginx-env.envsh /docker-entrypoint.d/16-nginx-env.envsh
|
||||
# 두 server(포털 · 공개 내려받기 — 11단계)가 include 하는 location 묶음. 템플릿이 아니다 — envsubst 를 거치지 않고 그대로 읽는다.
|
||||
# share-open · share-closed 는 server 단계(화면 /s/), share-api-open · share-api-closed 는 /api/ 블록 안(공개 API — 대소문자를 가리지 않는다)
|
||||
COPY nginx-share-open.conf /etc/nginx/snippets/share-open.conf
|
||||
COPY nginx-share-closed.conf /etc/nginx/snippets/share-closed.conf
|
||||
COPY nginx-share-api-open.conf /etc/nginx/snippets/share-api-open.conf
|
||||
COPY nginx-share-api-closed.conf /etc/nginx/snippets/share-api-closed.conf
|
||||
COPY nginx-api-errors.conf /etc/nginx/snippets/api-errors.conf
|
||||
# 워커마다 연결 수 — 이미지 기본 1024 → 4096. 대용량 받기(버퍼링 끔 — 받기 하나가 연결 둘, 공개 400 · 앱 안 200 개까지) · 실시간 알림(브라우저마다 연결 둘을
|
||||
# 1시간) · 포털 요청이 함께 버티게 (nginx.conf 의 http 단계 "동시 연결 수"). 워커 하나에 몰려도 모자라지 않게 워커마다 잡는다.
|
||||
# 연결마다 파일 하나(fd)를 쓰므로 compose 가 frontend 의 nofile 을 8192 로 둔다. 이미지의 nginx.conf 꼴이 바뀌어 고치지 못하면 빌드를 멈춘다
|
||||
RUN sed -i 's/^\([[:space:]]*worker_connections[[:space:]]\{1,\}\)[0-9]\{1,\};/\14096;/' /etc/nginx/nginx.conf \
|
||||
&& grep -Eq '^[[:space:]]*worker_connections[[:space:]]+4096;' /etc/nginx/nginx.conf
|
||||
|
||||
# 운영 이미지 — compose 는 target 없이 빌드하므로 이 단계가 파일의 마지막이어야 한다
|
||||
FROM nginx-config AS production-stage
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# ==========================================
|
||||
# /api 의 nginx 오류 응답 — 두 server(포털 · 공개 내려받기)가 넣는다 (nginx.conf). 템플릿이 아니다(/etc/nginx/snippets/api-errors.conf)
|
||||
#
|
||||
# message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
|
||||
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다
|
||||
# (접근 로그는 server 단계의 쿼리 없는 main_rid 를 그대로 물려받는다).
|
||||
# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(404·413·429·502·504 등)이 그대로 나간다 (2026-10-27 확인)
|
||||
# ==========================================
|
||||
|
||||
# nginx 가 닫은 경로 404 — 공개 내려받기 server 의 /api/(공개 API 밖) · 포털의 공개 API(공개 호스트를 따로 둘 때). 없는 경로와 같은 RES_001
|
||||
location @api_not_found {
|
||||
add_header_inherit off;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Request-Id $request_id always;
|
||||
return 200 '{"success":false,"error":{"code":"RES_001","message":"","traceId":"$request_id"}}';
|
||||
}
|
||||
|
||||
# 본문 한도 초과 413 — 첨부 · 업로드 · 대용량 조각 · 공개 내려받기 server 의 /api/(1m)
|
||||
location @api_payload_too_large {
|
||||
add_header_inherit off;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Request-Id $request_id always;
|
||||
return 200 '{"success":false,"error":{"code":"FILE_002","message":"","traceId":"$request_id"}}';
|
||||
}
|
||||
|
||||
# 동시 연결 수 초과 429 — 대용량 받기의 limit_conn (11단계 — nginx.conf 의 http 단계 "동시 연결 수"). 백엔드의 요청 수 한도와 같은 REQ_001
|
||||
location @api_too_many_requests {
|
||||
add_header_inherit off;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Request-Id $request_id always;
|
||||
return 200 '{"success":false,"error":{"code":"REQ_001","message":"","traceId":"$request_id"}}';
|
||||
}
|
||||
|
||||
# 백엔드 응답 없음 · 시간 초과 등 5xx
|
||||
location @api_unavailable {
|
||||
add_header_inherit off;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Request-Id $request_id always;
|
||||
return 200 '{"success":false,"error":{"code":"SYS_001","message":"","traceId":"$request_id"}}';
|
||||
}
|
||||
@@ -64,4 +64,74 @@ esac
|
||||
# 도커 내장 DNS — 15-local-resolvers.envsh 가 /etc/resolv.conf 에서 읽어 둔다 (Dockerfile 의 NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1)
|
||||
NGINX_LOCAL_RESOLVERS="${NGINX_LOCAL_RESOLVERS:-127.0.0.11}"
|
||||
|
||||
export NGINX_MAIL_ATTACHMENT_BODY_LIMIT NGINX_UPLOAD_BODY_LIMIT NGINX_BACKEND_UPSTREAM NGINX_LOCAL_RESOLVERS
|
||||
# ------------------------------------------
|
||||
# 대용량 첨부의 공개 내려받기 호스트 (11단계 — docs/mail/api-contract.md 4절 "공개 호스트")
|
||||
#
|
||||
# SHARE_PUBLIC_URL(출처 — http(s)://호스트[:포트])의 호스트 이름을 포털(CORS_ORIGIN — 콤마로 여럿)의 호스트 이름들과 견준다.
|
||||
# 백엔드(modules/file-sharing/file-sharing.settings.ts 의 dedicatedPublicHost)와 같은 판정이다 — 포트는 보지 않는다(nginx 는 포트 없는 Host 를 넘긴다):
|
||||
# 다르면 공개 내려받기 server 의 이름 = 그 호스트 이름, 포털은 /s/ · /api/public-links/ 를 404 로 닫는다(share-closed · share-api-closed)
|
||||
# 같거나 SHARE_PUBLIC_URL 이 비었으면 포털이 함께 연다(share-open · share-api-open), 공개 내려받기 server 는 어느 요청에도 맞지 않는 이름(share.invalid — RFC 2606)
|
||||
# SHARE_PUBLIC_URL 의 모양이 틀리면 기동을 멈춘다 — 경로 · 공백 · ; 등이 server_name 에 들어가 설정을 깨거나 지시어를 끼워 넣지 않게.
|
||||
# CORS_ORIGIN 의 모양은 백엔드가 본다(운영은 틀리면 백엔드가 뜨지 않는다) — 여기서는 읽을 수 있는 주소만 견준다.
|
||||
# ------------------------------------------
|
||||
|
||||
# 출처 → 호스트 이름 (소문자, 포트 없이). 모양이 틀리면 아무것도 내지 않고 1
|
||||
nginx_env_origin_host() {
|
||||
case "$1" in
|
||||
http://*) nginx_env_hostport="${1#http://}" ;;
|
||||
https://*) nginx_env_hostport="${1#https://}" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
# 영문 · 숫자 · . - : 만 — 경로(/) · 쿼리 · 공백 · 대괄호(IPv6) · 사용자 정보(@) 를 받지 않는다
|
||||
case "$nginx_env_hostport" in
|
||||
"" | *[!A-Za-z0-9.:-]*) return 1 ;;
|
||||
esac
|
||||
nginx_env_host="${nginx_env_hostport%%:*}"
|
||||
nginx_env_port="${nginx_env_hostport#"$nginx_env_host"}"
|
||||
case "$nginx_env_port" in
|
||||
"") ;;
|
||||
:0* | : | :*[!0-9]* | :??????*) return 1 ;;
|
||||
esac
|
||||
# 빈 이름 · 점으로 시작 · 끝(끝 점 — nginx 의 $host 는 끝 점을 떼어 백엔드와 어긋난다) · 연이은 점 · 하이픈으로 시작 · 끝
|
||||
case "$nginx_env_host" in
|
||||
"" | .* | *. | *..* | -* | *-) return 1 ;;
|
||||
esac
|
||||
printf '%s' "$nginx_env_host" | tr 'A-Z' 'a-z'
|
||||
}
|
||||
|
||||
nginx_env_share_url="${SHARE_PUBLIC_URL:-}"
|
||||
nginx_env_share_host=""
|
||||
if [ -n "$nginx_env_share_url" ]; then
|
||||
nginx_env_share_host="$(nginx_env_origin_host "$nginx_env_share_url")" \
|
||||
|| nginx_env_fail "SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처(http(s)://호스트[:포트] — 경로 · 끝의 / · 끝 점 없이)여야 합니다 (예: https://files.arasystem.it.kr, 현재: \"$nginx_env_share_url\")."
|
||||
fi
|
||||
|
||||
nginx_env_same_host=""
|
||||
if [ -z "$nginx_env_share_host" ]; then
|
||||
nginx_env_same_host=1
|
||||
else
|
||||
# 콤마로 나눠 하나씩 — 앞뒤 공백과 끝의 / 하나는 떼고 견준다 (백엔드도 출처로 고쳐 견준다)
|
||||
while IFS= read -r nginx_env_origin; do
|
||||
nginx_env_origin="$(printf '%s' "$nginx_env_origin" | tr -d ' ')"
|
||||
nginx_env_origin="${nginx_env_origin%/}"
|
||||
nginx_env_portal_host="$(nginx_env_origin_host "$nginx_env_origin")" || continue
|
||||
if [ "$nginx_env_portal_host" = "$nginx_env_share_host" ]; then
|
||||
nginx_env_same_host=1
|
||||
fi
|
||||
done <<NGINX_ENV_ORIGINS
|
||||
$(printf '%s\n' "${CORS_ORIGIN:-}" | tr ',' '\n')
|
||||
NGINX_ENV_ORIGINS
|
||||
fi
|
||||
|
||||
if [ -n "$nginx_env_same_host" ]; then
|
||||
NGINX_SHARE_SERVER_NAME="share.invalid"
|
||||
NGINX_PORTAL_SHARE="open"
|
||||
echo "nginx-env.envsh: 공개 내려받기(/s/ · /api/public-links/)를 포털과 같은 호스트에서 연다 (SHARE_PUBLIC_URL: \"${nginx_env_share_url}\")"
|
||||
else
|
||||
NGINX_SHARE_SERVER_NAME="$nginx_env_share_host"
|
||||
NGINX_PORTAL_SHARE="closed"
|
||||
echo "nginx-env.envsh: 공개 내려받기 호스트 ${nginx_env_share_host} — 포털에서는 /s/ · /api/public-links/ 를 닫는다"
|
||||
fi
|
||||
|
||||
export NGINX_MAIL_ATTACHMENT_BODY_LIMIT NGINX_UPLOAD_BODY_LIMIT NGINX_BACKEND_UPSTREAM NGINX_LOCAL_RESOLVERS \
|
||||
NGINX_SHARE_SERVER_NAME NGINX_PORTAL_SHARE
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# ==========================================
|
||||
# 포털에서는 대용량 첨부의 공개 API 를 열지 않는다 — 공개 호스트(SHARE_PUBLIC_URL)를 따로 둘 때 (11단계 — 계약 4절 "공개 호스트")
|
||||
#
|
||||
# 포털 server 의 location ^~ /api/ 블록 **안에** 넣는다(nginx.conf — nginx-env.envsh 의 NGINX_PORTAL_SHARE=closed).
|
||||
# 템플릿이 아니다(/etc/nginx/snippets/share-api-closed.conf). 없는 경로와 같은 404 RES_001(JSON — @api_not_found) —
|
||||
# 백엔드도 같은 판정으로 막는다(두 겹 — modules/file-sharing/public-host.ts). 본문 한도(1m)를 넘는 요청도 JSON 413 FILE_002 다.
|
||||
# 대소문자를 가리지 않는다(~*) — /api/Public-Links/… 도 닫는다(백엔드 라우팅은 대소문자를 가리지 않는다 — nginx.conf 머리말)
|
||||
# ==========================================
|
||||
|
||||
location ~* ^/api/public-links(?:/|$) {
|
||||
error_page 404 @api_not_found;
|
||||
error_page 413 @api_payload_too_large;
|
||||
return 404;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# ==========================================
|
||||
# 대용량 첨부의 공개 API — /api/public-links/ (11단계 — docs/mail/api-contract.md 4절 "공개 내려받기")
|
||||
#
|
||||
# 두 server 의 location ^~ /api/ 블록 **안에** 넣는다(nginx.conf) — 공개 내려받기 server 는 늘, 포털은 공개 호스트를 따로 두지 않을 때
|
||||
# (nginx-env.envsh 의 NGINX_PORTAL_SHARE=open). 템플릿이 아니다 — /etc/nginx/snippets/share-api-open.conf 로 그대로 들어간다(Dockerfile).
|
||||
# $backend_upstream 과 limit_conn 의 영역(download_public_*)은 nginx.conf 의 http 단계, @api_* 는 nginx-api-errors.conf.
|
||||
# /api/ 블록의 add_header_inherit off 를 물려받는다 — 응답의 머리글(no-store · noindex · no-referrer · 파일의 CSP 등)은 백엔드가 정한다.
|
||||
#
|
||||
# 대소문자를 가리지 않는다(~*) — 백엔드(Express) 라우팅이 가리지 않아 /api/Public-Links/…/FILES/… 도 받기 처리기에 닿는다(nginx.conf 머리말).
|
||||
# 정규식은 적은 차례로 처음 맞은 것을 쓴다 — 파일 받기를 링크 정보보다 먼저 둔다.
|
||||
# ==========================================
|
||||
|
||||
# 파일 받기 GET·HEAD /api/public-links/{token}/files/{fileId} — 200 · 206(Range) · 416, 2GB 까지
|
||||
# - 버퍼링을 끈다: 기본값이면 받는 쪽이 느릴 때 nginx 가 응답을 임시 파일(/tmp/proxy_temp)에 1GB 까지 받아 둔다 — 받는 사람마다.
|
||||
# 백엔드가 보내는 X-Accel-Buffering: no 로도 꺼지지만 그 머리글에 기대지 않는다(실시간 알림과 같다)
|
||||
# - X-Accel-Buffering 을 넘긴다: nginx 계열 외부 리버스 프록시(Nginx Proxy Manager 등)도 이 응답만 버퍼링을 끈다
|
||||
# - proxy_buffer_size 16k — 머리글 버퍼. 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이다(Content-Disposition 하나가 2.5KB) — 기본 4k 로는 빠듯하다
|
||||
# 버퍼링을 끈 응답은 본문도 이 크기로 받아 넘긴다(기본 4k 보다 시스템 호출이 적다). proxy_busy_buffers_size 는 nginx 가 함께 맞추라고 하는 값이다 —
|
||||
# 안 적으면 2 × 16k = 32k 가 proxy_buffers(8 × 4k)의 "하나 뺀 합" 28k 를 넘어 설정 검사가 멈춘다. 버퍼링을 끈 location 이라 proxy_buffers 는 쓰이지 않는다
|
||||
# - 압축하지 않는다(gzip_types 에 application/octet-stream 이 없지만 목록을 넓혀도 이 경로는 그대로) · Range · If-Range 는 손대지 않고 넘긴다
|
||||
# (nginx 는 프록시한 응답을 스스로 자르지 않는다 — 백엔드의 206 · 416 이 그대로 간다)
|
||||
# - 시간: proxy_read_timeout 은 백엔드에서 "두 번 읽는 사이", send_timeout(기본 60s)은 받는 쪽에 "두 번 쓰는 사이"다 — 느려도 조금씩
|
||||
# 받아 가면 끊지 않고, 받는 쪽이 60초 동안 하나도 받지 않으면 끊는다(브라우저 · 내려받기 관리자는 Range 로 이어 받는다 — mail/README.md 의 시험)
|
||||
# - 동시 연결: 클라이언트 주소마다 10 · 공개 받기 전체 400 — 넘으면 429 REQ_001 (nginx.conf 의 http 단계 "동시 연결 수")
|
||||
# - 토큰 칸은 [^/]+ 가 아니라 .+ — nginx 는 %2F 를 / 로 풀어 location 을 고르고 백엔드는 한 칸으로 읽는다(지금은 백엔드의 토큰 검사가 그 꼴을 404 로 막지만 기대지 않는다)
|
||||
# - 같은 줄들이 nginx.conf 의 앱 안 받기(/api/messages/{id}/large-files/…)에 있다 — 한쪽을 고치면 다른 쪽도
|
||||
# error_page 를 하나라도 쓰면 위의 것을 물려받지 않아 셋을 다 적는다
|
||||
location ~* ^/api/public-links/.+/files/ {
|
||||
limit_conn download_public_ip 10;
|
||||
limit_conn download_public_total 400;
|
||||
error_page 413 @api_payload_too_large;
|
||||
error_page 429 @api_too_many_requests;
|
||||
error_page 500 502 503 504 @api_unavailable;
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_busy_buffers_size 16k;
|
||||
gzip off;
|
||||
proxy_pass_header X-Accel-Buffering;
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
|
||||
# 링크 정보 GET /api/public-links/{token}(JSON) 등 — 백엔드로 넘긴다. 공개 내려받기 server 의 /api/ 블록은 404 만 정하므로 여기서 다 적는다
|
||||
location ~* ^/api/public-links(?:/|$) {
|
||||
error_page 413 @api_payload_too_large;
|
||||
error_page 500 502 503 504 @api_unavailable;
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# ==========================================
|
||||
# 포털에서는 대용량 첨부의 공개 화면 /s/ 를 열지 않는다 — 공개 호스트(SHARE_PUBLIC_URL)를 따로 둘 때 (11단계 — 계약 4절 "공개 호스트")
|
||||
#
|
||||
# 포털 server 가 넣는다(nginx.conf — nginx-env.envsh 의 NGINX_PORTAL_SHARE=closed). 템플릿이 아니다(/etc/nginx/snippets/share-closed.conf).
|
||||
# 공개 API(/api/public-links/)는 /api/ 블록 안의 nginx-share-api-closed.conf 가 닫는다(없는 경로와 같은 404 RES_001 JSON).
|
||||
# 포털 주소로 온 공개 링크는 열지 않는다 — 털린 계정이 퍼뜨린 악성 파일로 포털 호스트까지 차단 목록에 오르지 않게.
|
||||
# /S/… 처럼 대소문자가 다른 꼴은 SPA 로 가지만 화면 라우터가 공개 화면을 그리지 않는다(routes.ts 의 sensitive) — 받기는 API 쪽에서 닫혀 있다
|
||||
# ==========================================
|
||||
|
||||
location ^~ /s/ {
|
||||
return 404;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# ==========================================
|
||||
# 대용량 첨부의 공개 화면 /s/<토큰> (11단계 — docs/mail/api-contract.md 4절 "공개 내려받기")
|
||||
#
|
||||
# 공개 내려받기 server 가 넣는다(nginx.conf). 공개 호스트를 따로 두지 않으면 포털 server 도 넣는다(nginx-env.envsh 의 NGINX_PORTAL_SHARE=open).
|
||||
# 템플릿이 아니다 — /etc/nginx/snippets/share-open.conf 로 그대로 들어간다(Dockerfile). ${NGINX_*} 를 쓰지 않는다 — $page_csp 는 nginx.conf 의 http 단계.
|
||||
# 공개 API(/api/public-links/)는 여기가 아니라 /api/ 블록 안의 nginx-share-api-open.conf 다(대소문자를 가리지 않게 — nginx.conf 머리말).
|
||||
# 로그인 · 쿠키를 쓰지 않는 길이다 — 바깥 거래처의 브라우저 · 내려받기 관리자 · 받는 쪽 보안 장비가 부른다.
|
||||
# ==========================================
|
||||
|
||||
# 화면 /s/<토큰> — SPA 의 공개 라우트(셸 · 로그인 없음 — 세션 복원을 건너뛴다). 파일은 토큰과 상관없이 index.html 하나다.
|
||||
# server 단계의 보안 헤더를 물려받지 않고 여기서 다 쓴다 — 포털 server 에 들어갈 때(같은 호스트) 포털의 Referrer-Policy(strict-origin)와
|
||||
# 두 줄이 되지 않게. 포털 화면과 다른 것: Referer 를 아예 보내지 않는다(주소에 토큰이 있다 — 화면에서 나가는 요청 · 내려받기 이동에 실리지 않게),
|
||||
# 검색 엔진에 올리지 않는다. index.html 이라 매번 재검증한다(배포 뒤 지워진 해시 자산을 부르지 않게 — 포털의 /index.html 과 같은 까닭)
|
||||
# 대소문자를 가린다 — 화면 라우터도 /s/ 만 공개 화면으로 그린다(routes.ts 의 sensitive). /S/… 는 공개 내려받기 server 에서는 404,
|
||||
# 포털에서는 SPA 의 다른 화면이다(공개 화면이 그려지지 않는다)
|
||||
location ^~ /s/ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files /index.html =404;
|
||||
add_header_inherit off;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header Content-Security-Policy $page_csp always;
|
||||
}
|
||||
+285
-90
@@ -4,10 +4,27 @@
|
||||
# 같은 주소(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
|
||||
#
|
||||
# ⚠️ 백엔드(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 등)에서 계산한다.
|
||||
# 값은 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/ (연다 · 닫는다)
|
||||
# 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=…)에도 들어간다
|
||||
@@ -17,20 +34,53 @@
|
||||
# $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_line {
|
||||
# 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째(토큰째) 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그").
|
||||
map $request $log_request_unqueried {
|
||||
"" "-";
|
||||
"~^(?<log_request_head>[^?]*)\?[^ ]*(?<log_request_tail>.*)" $log_request_head$log_request_tail;
|
||||
default $request;
|
||||
}
|
||||
|
||||
map $http_referer $log_referer {
|
||||
map $log_request_unqueried $log_request_pathmasked {
|
||||
"~*^(?<log_path_head>[^ ]+ (?:https?://[^/ ]+)?/(?:s|api/public-links)/)[^/ ]+(?<log_path_tail>.*)$" $log_path_head***$log_path_tail;
|
||||
default $log_request_unqueried;
|
||||
}
|
||||
|
||||
map $log_request_pathmasked $log_request_tokenmasked {
|
||||
"~^(?<log_token1_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_token1_tail>.*)$" $log_token1_head***$log_token1_tail;
|
||||
default $log_request_pathmasked;
|
||||
}
|
||||
|
||||
map $log_request_tokenmasked $log_request_line {
|
||||
"~^(?<log_token2_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_token2_tail>.*)$" $log_token2_head***$log_token2_tail;
|
||||
default $log_request_tokenmasked;
|
||||
}
|
||||
|
||||
map $http_referer $log_referer_unqueried {
|
||||
"" "-";
|
||||
"~^(?<log_referer_head>[^?]*)\?" $log_referer_head;
|
||||
default $http_referer;
|
||||
}
|
||||
|
||||
map $log_referer_unqueried $log_referer_pathmasked {
|
||||
"~*^(?<log_referer_path_head>(?:https?://[^/]+)?/(?:s|api/public-links)/)[^/]+(?<log_referer_path_tail>.*)$" $log_referer_path_head***$log_referer_path_tail;
|
||||
default $log_referer_unqueried;
|
||||
}
|
||||
|
||||
map $log_referer_pathmasked $log_referer {
|
||||
"~^(?<log_referer_token_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_referer_token_tail>.*)$" $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 는 쿼리를 뺀 경로다 (이 요청은 내부 이동이 없다)
|
||||
@@ -46,15 +96,118 @@ map "$request_method:$uri:$status" $log_access {
|
||||
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 문서는 이 정책을 상속하는데,
|
||||
# 그 안에는 앱이 넣는 <style>(본문 기본 서식)과 받은 메일의 style 속성이 있다 — 둘 다 인라인 스타일이다.
|
||||
# (메일 원문의 <style> 요소는 서버·클라이언트 정화에서 지운다. 2026-10-27 운영 이미지로 확인:
|
||||
# style 속성·data: 이미지 표시, CSP 위반 0건)
|
||||
# 앱 화면 자체는 인라인 스타일이 필요 없다 — Vue 의 :style 은 CSSOM 으로 바꿔 CSP 대상이 아니고,
|
||||
# 컴포넌트 CSS 는 /assets 의 파일로 나간다
|
||||
# - connect-src 'self' : API 는 같은 주소의 /api 다 (같은 주소 배포). API 를 다른 주소로 옮기지 말 것 —
|
||||
# CSP 뿐 아니라 쿠키(Path=/api, SameSite)와 출처 검사가 같은 주소를 전제로 한다.
|
||||
# 공개 내려받기 화면은 자기 호스트의 /api/public-links/ 만 부른다 — 'self' 그대로 맞다 (계약 4절 "공개 호스트")
|
||||
# - img-src data: : 메일 본문의 인라인 이미지(cid → data:)와 빌드가 인라인한 작은 이미지.
|
||||
# blob: 은 쓰는 곳이 없어 뺐다 — 미리보기 등으로 createObjectURL 이미지를 쓰게 되면 그때 넣는다
|
||||
# - font-src 'self' : 글꼴(Pretendard)은 npm 패키지를 번들에 넣어 /assets 의 파일로 내려준다 — 외부 CDN·data: 글꼴 없음
|
||||
# (빌드가 4KB 미만 자산을 data: 로 인라인하는데, Pretendard 조각은 가장 작은 것도 8KB 라 해당 없다)
|
||||
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
|
||||
#
|
||||
# 웹메일 본문 iframe(MailBodyFrame, srcdoc)과의 관계 — 값을 바꿀 때 함께 확인할 것:
|
||||
# - srcdoc 문서는 이 정책을 그대로 상속하고, 문서 안 meta CSP
|
||||
# (default-src 'none'; img-src data:; style-src 'unsafe-inline')가 그 위에 겹쳐 적용된다 (둘 다 만족해야 한다).
|
||||
# - 그래서 img-src 에서 data: 를, style-src 에서 'unsafe-inline' 을 빼면 메일 본문의 인라인 이미지·서식이 깨진다.
|
||||
# - srcdoc 은 네트워크로 불러오는 문서가 아니라 frame-src·frame-ancestors 의 영향을 받지 않는다 (frame-src 추가 불필요).
|
||||
#
|
||||
# 외부 폰트·분석 스크립트·이미지 CDN 을 도입하면 해당 도메인을 명시적으로 추가한다.
|
||||
# script-src 에 'unsafe-inline'/'unsafe-eval' 을 추가하면 CSP 의 의미가 사라지므로 피할 것.
|
||||
map $host $page_csp {
|
||||
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
|
||||
}
|
||||
|
||||
# ----------------------------------------
|
||||
# 대용량 첨부 받기의 동시 연결 수 (11단계) — 공개 받기(/api/public-links/<토큰>/files/…) · 앱 안 받기(/api/messages/<id>/large-files/…)
|
||||
#
|
||||
# 받기는 버퍼링을 끄고 흘려보낸다 — 받는 쪽이 느리면 연결 하나가 nginx 연결 둘(클라이언트 · 백엔드)과 백엔드의 파일을 그동안 잡는다.
|
||||
# 토큰을 가진 사람이 느린 받기를 수백 개 열어 이 nginx(포털과 함께)를 멈추지 못하게 센다 — 넘으면 429 → @api_too_many_requests 의 REQ_001 JSON:
|
||||
# 공개 받기 클라이언트 주소마다 10 · 공개 받기 전체 400
|
||||
# 앱 안 받기 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200
|
||||
# 전체 한도를 둘로 나눈 까닭 — 하나를 함께 쓰면 바깥 주소 몇십 개(IPv6 면 /64 하나)로 공개 받기가 그릇을 채워 직원의 앱 안 받기까지 모두 429 가 된다.
|
||||
# 세는 것은 처리 중인 요청이다(쉬는 keep-alive 연결은 세지 않는다). 두 location 모두 대소문자를 가리지 않는다(위 머리말).
|
||||
#
|
||||
# 클라이언트 주소 — 이 nginx 앞에는 늘 외부 리버스 프록시가 있어 $remote_addr 은 프록시다. 프록시는 X-Forwarded-For 에 자기가 본
|
||||
# 클라이언트 주소를 이어 붙인다(docker 스킬 "외부 리버스 프록시 요구사항") — 그 마지막 칸을 쓴다. 머리글이 없으면(프록시 없이 바로) 접속 주소.
|
||||
# 프록시를 건너뛰고 바로 붙어 머리글을 꾸미면 주소마다 한도는 피하지만 경로마다의 전체 한도(400 · 200)는 그대로 걸린다(8080 은 ACG · proxy 네트워크가 프록시에만 연다).
|
||||
# IPv6 는 주소 그대로 센다(백엔드처럼 /64 로 묶지 않는다 — 표기가 여러 꼴이라 map 으로 줄이지 않았다) — 그것도 전체 한도가 막는다.
|
||||
# 거절은 접근 로그(429 · 토큰은 가려짐)로 본다 — 오류 로그에는 남기지 않는다(info — 이미지의 error_log 는 notice 부터. 남기면 토큰째 요청 줄이 쏟아진다).
|
||||
# ⚠️ 연결 수의 그릇은 이미지 nginx.conf 의 worker_connections 다 — Dockerfile 이 워커마다 4096 으로 올린다(compose 의 nofile 8192).
|
||||
# 받기 600 개(연결 1,200) + 실시간 알림(브라우저마다 연결 둘) + 포털 요청이 워커 하나에 몰려도 들어간다
|
||||
# ----------------------------------------
|
||||
map $http_x_forwarded_for $download_client {
|
||||
"~(?:^|,) *(?<download_client_ip>[^ ,]+) *$" $download_client_ip;
|
||||
default $remote_addr;
|
||||
}
|
||||
map $host $download_total_key {
|
||||
default all;
|
||||
}
|
||||
limit_conn_zone $download_client zone=download_public_ip:10m;
|
||||
limit_conn_zone $download_client zone=download_app_ip:10m;
|
||||
limit_conn_zone $download_total_key zone=download_public_total:1m;
|
||||
limit_conn_zone $download_total_key zone=download_app_total:1m;
|
||||
limit_conn_status 429;
|
||||
limit_conn_log_level info;
|
||||
|
||||
# ----------------------------------------
|
||||
# /api → 백엔드 (같은 주소 배포) — 두 server 가 함께 쓴다
|
||||
# ----------------------------------------
|
||||
# 백엔드 주소는 요청 때마다 도커 DNS 로 다시 푼다 — 백엔드 컨테이너를 다시 만들어 IP 가 바뀌어도 nginx 를 재시작할 필요가 없다.
|
||||
# 주소는 서비스 이름(backend)이 아니라 고유 컨테이너 이름(<PROJECT_NAME>-backend)이다 (compose 가 준다) —
|
||||
# 이 컨테이너는 다른 프로젝트와 함께 쓰는 proxy 네트워크에도 붙어 있어, 거기 backend 라는 이름이 있으면 그쪽으로 풀릴 수 있다.
|
||||
# (map 은 늘 같은 값이다 — server 마다 set 을 쓰지 않으려고)
|
||||
resolver ${NGINX_LOCAL_RESOLVERS} valid=10s ipv6=off;
|
||||
map $host $backend_upstream {
|
||||
default ${NGINX_BACKEND_UPSTREAM};
|
||||
}
|
||||
|
||||
# 아래 proxy_* 는 두 server 의 /api 가 모두 물려받는다.
|
||||
# ⚠️ 하위 location 에 proxy_set_header 를 하나라도 쓰면 여기 것을 물려받지 않는다 (add_header 와 같은 규칙)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
# 원래 Host 를 그대로 넘긴다 — 두 server(포털 · 공개 내려받기) 모두 이 값을 물려받는다(공개 내려받기 server 에서도 바꾸지 않는다).
|
||||
# 백엔드가 이 값의 호스트 이름으로 공개 호스트를 가른다(modules/file-sharing/public-host.ts — 공개 호스트에서는 /api/public-links/ 만).
|
||||
# ⚠️ 백엔드는 nginx 가 정규화한 $host 에 기댄다: 소문자 · 포트 없음 · 끝 점 없음, 요청 줄이 절대 주소(absolute-form)면 그 호스트가 이긴다 —
|
||||
# 그리고 백엔드로 넘기는 요청 줄은 늘 경로만 있는 origin-form 이다. 이 nginx 를 거치지 않고 백엔드에 닿는 길을 만들거나 다른 프록시를 두면 같은 정규화를 해야 한다
|
||||
proxy_set_header Host $host;
|
||||
# 앞 단계(외부 리버스 프록시)가 보낸 값 뒤에 이어 붙인다 — 덮어쓰지 않는다 (백엔드 TRUST_PROXY 가 홉 수로 읽는다)
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# 클라이언트가 보낸 값은 버리고 nginx 의 요청 ID 로 바꾼다 → 백엔드 traceId 와 이 nginx 로그의 rid 가 같다
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
# Origin·Sec-Fetch-Site(출처 검사)·Idempotency-Key(중복 발송 방지)·Cookie·Range·Content-Digest 는 손대지 않고 그대로 넘긴다.
|
||||
# X-Forwarded-Proto 도 외부 프록시가 보낸 값을 그대로 둔다 (Secure 쿠키는 백엔드가 NODE_ENV 로 정한다).
|
||||
|
||||
# ----------------------------------------
|
||||
# 압축 (Gzip) — 두 server 공통. 실시간 알림 · 대용량 받기는 각 location 에서 끈다
|
||||
# ----------------------------------------
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
|
||||
|
||||
# ==========================================
|
||||
# 포털 — 웹메일 화면과 /api (Host 가 공개 내려받기 호스트가 아닌 모든 요청)
|
||||
# ==========================================
|
||||
server {
|
||||
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
listen 8080 default_server;
|
||||
# 이름으로 고르지 않는다(default_server) — 공개 내려받기 server 의 이름과 겹치지 않게 비워 둔다
|
||||
server_name _;
|
||||
|
||||
# 접근 로그 — 형식·조건은 위 map·log_format (쿼리 없음, 성공한 새 메일 확인은 남기지 않음). 모든 location 이 물려받는다
|
||||
# 접근 로그 — 형식·조건은 위 map·log_format (쿼리 · 토큰 없음, 성공한 새 메일 확인은 남기지 않음). 모든 location 이 물려받는다
|
||||
access_log /var/log/nginx/access.log main_rid if=$log_access;
|
||||
|
||||
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장 경로만 아래 /api 에서 늘린다
|
||||
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장·대용량 조각 경로만 아래 /api 에서 늘린다
|
||||
client_max_body_size 1m;
|
||||
|
||||
# ----------------------------------------
|
||||
@@ -74,42 +227,8 @@ server {
|
||||
# 이 정책과 상관없이 그대로 붙는다. 다른 사이트로 가는 요청은 전과 같다 (origin 만, HTTPS → HTTP 는 보내지 않는다).
|
||||
add_header Referrer-Policy "strict-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# Content-Security-Policy — XSS 방어의 핵심 헤더
|
||||
#
|
||||
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
|
||||
# - default-src 'self' : 기본적으로 같은 출처만 허용
|
||||
# - style-src 'unsafe-inline' : 웹메일 본문 iframe(srcdoc) 때문이다 (아래). srcdoc 문서는 이 정책을 상속하는데,
|
||||
# 그 안에는 앱이 넣는 <style>(본문 기본 서식)과 받은 메일의 style 속성이 있다 — 둘 다 인라인 스타일이다.
|
||||
# (메일 원문의 <style> 요소는 서버·클라이언트 정화에서 지운다. 2026-10-27 운영 이미지로 확인:
|
||||
# style 속성·data: 이미지 표시, CSP 위반 0건)
|
||||
# 앱 화면 자체는 인라인 스타일이 필요 없다 — Vue 의 :style 은 CSSOM 으로 바꿔 CSP 대상이 아니고,
|
||||
# 컴포넌트 CSS 는 /assets 의 파일로 나간다
|
||||
# - connect-src 'self' : API 는 같은 주소의 /api 다 (같은 주소 배포). API 를 다른 주소로 옮기지 말 것 —
|
||||
# CSP 뿐 아니라 쿠키(Path=/api, SameSite)와 출처 검사가 같은 주소를 전제로 한다
|
||||
# - img-src data: : 메일 본문의 인라인 이미지(cid → data:)와 빌드가 인라인한 작은 이미지.
|
||||
# blob: 은 쓰는 곳이 없어 뺐다 — 미리보기 등으로 createObjectURL 이미지를 쓰게 되면 그때 넣는다
|
||||
# - font-src 'self' : 글꼴(Pretendard)은 npm 패키지를 번들에 넣어 /assets 의 파일로 내려준다 — 외부 CDN·data: 글꼴 없음
|
||||
# (빌드가 4KB 미만 자산을 data: 로 인라인하는데, Pretendard 조각은 가장 작은 것도 8KB 라 해당 없다)
|
||||
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
|
||||
#
|
||||
# 웹메일 본문 iframe(MailBodyFrame, srcdoc)과의 관계 — 값을 바꿀 때 함께 확인할 것:
|
||||
# - srcdoc 문서는 이 정책을 그대로 상속하고, 문서 안 meta CSP
|
||||
# (default-src 'none'; img-src data:; style-src 'unsafe-inline')가 그 위에 겹쳐 적용된다 (둘 다 만족해야 한다).
|
||||
# - 그래서 img-src 에서 data: 를, style-src 에서 'unsafe-inline' 을 빼면 메일 본문의 인라인 이미지·서식이 깨진다.
|
||||
# - srcdoc 은 네트워크로 불러오는 문서가 아니라 frame-src·frame-ancestors 의 영향을 받지 않는다 (frame-src 추가 불필요).
|
||||
#
|
||||
# 외부 폰트·분석 스크립트·이미지 CDN 을 도입하면 해당 도메인을 명시적으로 추가한다.
|
||||
# script-src 에 'unsafe-inline'/'unsafe-eval' 을 추가하면 CSP 의 의미가 사라지므로 피할 것.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
|
||||
# ----------------------------------------
|
||||
# 압축 (Gzip)
|
||||
# ----------------------------------------
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
|
||||
# 값과 설명은 위 http 단계의 map $page_csp
|
||||
add_header Content-Security-Policy $page_csp always;
|
||||
|
||||
# ----------------------------------------
|
||||
# SPA 라우팅
|
||||
@@ -144,29 +263,13 @@ server {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# ----------------------------------------
|
||||
# /api → 백엔드 (같은 주소 배포)
|
||||
# ----------------------------------------
|
||||
# 백엔드 주소는 요청 때마다 도커 DNS 로 다시 푼다 — 백엔드 컨테이너를 다시 만들어 IP 가 바뀌어도 nginx 를 재시작할 필요가 없다.
|
||||
# 주소는 서비스 이름(backend)이 아니라 고유 컨테이너 이름(<PROJECT_NAME>-backend)이다 (compose 가 준다) —
|
||||
# 이 컨테이너는 다른 프로젝트와 함께 쓰는 proxy 네트워크에도 붙어 있어, 거기 backend 라는 이름이 있으면 그쪽으로 풀릴 수 있다.
|
||||
resolver ${NGINX_LOCAL_RESOLVERS} valid=10s ipv6=off;
|
||||
set $backend_upstream ${NGINX_BACKEND_UPSTREAM};
|
||||
|
||||
# 아래 proxy_* 는 /api 의 모든 location 이 물려받는다.
|
||||
# ⚠️ 하위 location 에 proxy_set_header 를 하나라도 쓰면 여기 것을 물려받지 않는다 (add_header 와 같은 규칙)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
# 앞 단계(외부 리버스 프록시)가 보낸 값 뒤에 이어 붙인다 — 덮어쓰지 않는다 (백엔드 TRUST_PROXY 가 홉 수로 읽는다)
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# 클라이언트가 보낸 값은 버리고 nginx 의 요청 ID 로 바꾼다 → 백엔드 traceId 와 이 nginx 로그의 rid 가 같다
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
# Origin·Sec-Fetch-Site(출처 검사)·Idempotency-Key(중복 발송 방지)·Cookie 는 손대지 않고 그대로 넘긴다.
|
||||
# X-Forwarded-Proto 도 외부 프록시가 보낸 값을 그대로 둔다 (Secure 쿠키는 백엔드가 NODE_ENV 로 정한다).
|
||||
# 대용량 첨부의 공개 화면 /s/<토큰>(11단계). 공개 호스트를 따로 두면 여기서는 404 로 닫고(nginx-share-closed.conf),
|
||||
# 따로 두지 않으면 공개 내려받기 server 와 같은 것을 연다(nginx-share-open.conf). 고르는 것은 nginx-env.envsh 의 NGINX_PORTAL_SHARE.
|
||||
# 공개 API /api/public-links/ 는 아래 /api/ 블록 안에서 같은 값으로 고른다(share-api-*.conf)
|
||||
include /etc/nginx/snippets/share-${NGINX_PORTAL_SHARE}.conf;
|
||||
|
||||
location ^~ /api/ {
|
||||
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다
|
||||
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다 (안쪽 location 도 이 off 를 물려받는다)
|
||||
add_header_inherit off;
|
||||
proxy_pass $backend_upstream;
|
||||
|
||||
@@ -175,6 +278,9 @@ server {
|
||||
error_page 413 @api_payload_too_large;
|
||||
error_page 500 502 503 504 @api_unavailable;
|
||||
|
||||
# ⚠️ 이 안의 정규식 location 은 아래 prefix location(/api/messages 등)보다 먼저 정해진다 — prefix 에 ^~ 가 없을 때.
|
||||
# 받기 경로가 걸리는 /api/messages 는 그래서 ^~ 없이 둔다 (^~ 면 /api/messages/… 가 아래 받기 location 을 비켜 간다)
|
||||
|
||||
# 헬스체크는 내부 전용 — 바깥에서 오면 없는 경로와 같은 404 (실패하면 DB 오류 문자열 등 원인이 담기기 때문이다).
|
||||
# 대소문자·끝 / 를 가리지 않는 백엔드 라우팅에 맞춰 막는다. 내부 확인 방법은 docker 스킬 문서 "운영 환경".
|
||||
location ~* ^/api/health(?:/|$) {
|
||||
@@ -185,6 +291,49 @@ server {
|
||||
return 404 '{"success":false,"error":{"code":"RES_001","message":"","traceId":"$request_id"}}';
|
||||
}
|
||||
|
||||
# 공개 API /api/public-links/ (11단계) — 공개 호스트를 따로 두면 404 RES_001 로 닫고(nginx-share-api-closed.conf),
|
||||
# 두지 않으면 공개 내려받기 server 와 같은 것을 연다(nginx-share-api-open.conf — 파일 받기는 공개 받기의 동시 연결 수 · 버퍼링 끔). 대소문자를 가리지 않는다
|
||||
include /etc/nginx/snippets/share-api-${NGINX_PORTAL_SHARE}.conf;
|
||||
|
||||
# 대용량 첨부 받기 GET·HEAD /api/messages/{id}/large-files/{fileId} (11단계 — 계약 4절 "우리 웹메일에서 읽기") — 공개 받기와 같게 흘려보낸다
|
||||
# (nginx-share-api-open.conf 의 파일 받기와 같은 줄들 — 한쪽을 고치면 다른 쪽도):
|
||||
# - 동시 연결은 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200 (위 http 단계). 넘으면 429 REQ_001
|
||||
# - 버퍼링 · 압축을 끄고 X-Accel-Buffering 을 넘긴다 — 외부 프록시(NPM)도 이 응답을 버퍼에 쌓지 않는다(받는 사람마다 1GB 를 디스크에 쌓지 않게).
|
||||
# Range 는 손대지 않고 넘긴다(206 그대로)
|
||||
# - proxy_buffer_size 16k — 머리글 버퍼. 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이다(Content-Disposition 하나가 2.5KB) — 기본 4k 로는 빠듯하다
|
||||
# 버퍼링을 끈 응답은 본문도 이 크기로 받아 넘긴다(기본 4k 보다 시스템 호출이 적다). proxy_busy_buffers_size 는 nginx 가 함께 맞추라고 하는 값이다 —
|
||||
# 안 적으면 2 × 16k = 32k 가 proxy_buffers(8 × 4k)의 "하나 뺀 합" 28k 를 넘어 설정 검사가 멈춘다. 버퍼링을 끈 location 이라 proxy_buffers 는 쓰이지 않는다
|
||||
# - 대소문자를 가리지 않는다(머리말). id 칸은 [^/]+ 가 아니라 .+ — nginx 는 %2F 를 / 로 풀어 location 을 고르고 백엔드는 한 칸으로 읽는다
|
||||
# (지금은 백엔드의 id 검사가 그 꼴을 404 로 막지만 거기에 기대지 않는다)
|
||||
# error_page 를 하나라도 쓰면 위의 것을 물려받지 않아 셋을 다 적는다
|
||||
location ~* ^/api/messages/.+/large-files/ {
|
||||
limit_conn download_app_ip 30;
|
||||
limit_conn download_app_total 200;
|
||||
error_page 413 @api_payload_too_large;
|
||||
error_page 429 @api_too_many_requests;
|
||||
error_page 500 502 503 504 @api_unavailable;
|
||||
proxy_buffering off;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_busy_buffers_size 16k;
|
||||
gzip off;
|
||||
proxy_pass_header X-Accel-Buffering;
|
||||
proxy_read_timeout 90s;
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
|
||||
# 대용량 첨부의 조각 올리기 PUT /api/file-uploads/{id}/chunks/{n} (11단계 — 계약 4절 "올리기") — 조각 16MiB(계약 상수 — 마지막 조각만 짧다)
|
||||
# + 여유 = 17m 고정. 계약 상수라 env 로 바꾸지 않는다(바꾸려면 계약 · 백엔드 · 화면을 함께). 그 밖의 /api/file-uploads(만들기 · 끝내기 · 상태 · 지우기)는
|
||||
# JSON 이라 기본 1m 그대로다. 요청 본문은 nginx 가 다 받아 둔 뒤(proxy_request_buffering 기본 켬 — 느린 연결은 nginx 가 받는다, /tmp/client_temp)
|
||||
# 백엔드에 넘긴다 — 백엔드가 디스크에 쓰고 내려쓰기(datasync)까지 기다린다. 백엔드 라우팅처럼 대소문자를 가리지 않는다.
|
||||
# 오류 로그는 error 부터 — 16MiB 조각은 늘 메모리 버퍼(client_body_buffer_size)를 넘어 임시 파일로 받는데, nginx 는 그때마다
|
||||
# "a client request body is buffered to a temporary file" 을 warn 으로 남긴다(조각마다 한 줄 — 2GB 파일이면 128줄). 버퍼를 키우지 않는다(동시 조각 수 × 16MiB 가 메모리에 앉는다)
|
||||
location ~* ^/api/file-uploads/[^/]+/chunks/[^/]+$ {
|
||||
client_max_body_size 17m;
|
||||
proxy_read_timeout 90s;
|
||||
error_log /var/log/nginx/error.log error;
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
|
||||
# 메일 첨부 업로드 — 파일당 MAIL_ATTACHMENT_MAX_SIZE_MB + 멀티파트 여유 1MB (nginx-env.envsh).
|
||||
# 본문을 다 받은 뒤 백엔드가 메일 엔진에 올리는 시간까지 기다린다 (프론트의 업로드 한도는 5분)
|
||||
location ^~ /api/mail-attachments {
|
||||
@@ -199,8 +348,9 @@ server {
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
|
||||
# 메일 조회·수정·첨부 내려받기 — 메일 엔진 응답을 기다리는 시간 (본문 한도는 기본 1m)
|
||||
location ^~ /api/messages {
|
||||
# 메일 조회·수정·첨부 내려받기 — 메일 엔진 응답을 기다리는 시간 (본문 한도는 기본 1m).
|
||||
# ^~ 를 붙이지 않는다 — 대용량 첨부 받기(/api/messages/{id}/large-files/…)는 위 정규식 location 이 가져가야 한다(위 ⚠️)
|
||||
location /api/messages {
|
||||
proxy_read_timeout 90s;
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
@@ -247,8 +397,8 @@ server {
|
||||
# 버퍼링을 끈다 — 기본 설정의 nginx 가 머리글을 첫 이벤트까지 붙잡지 않고 바로 보낸다 (docker 스킬 "외부 리버스 프록시 요구사항").
|
||||
# 브라우저는 이 머리글을 쓰지 않는다.
|
||||
# 머리글(proxy_set_header — 여기 쓰지 않는다) · 오류 응답(@api_unavailable 등) · 접근 로그는 위 /api 와 server 단계 것을 그대로 물려받는다.
|
||||
# 열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다 — 수용량은 이미지 기본 nginx.conf 를 따른다
|
||||
# (워커마다 worker_connections 1024, 워커 수 auto).
|
||||
# 열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다 — 수용량은 워커마다 worker_connections 4096
|
||||
# (Dockerfile 이 이미지 기본 1024 에서 올린다 — 대용량 받기와 함께 버티게, 위 http 단계 "동시 연결 수"), 워커 수 auto.
|
||||
location ^~ /api/mail-events {
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
@@ -259,27 +409,9 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# /api 의 nginx 오류 응답 — message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
|
||||
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다
|
||||
# (접근 로그는 server 단계의 쿼리 없는 main_rid 를 그대로 물려받는다).
|
||||
# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(413·502·504 등)이 그대로 나간다 (2026-10-27 확인)
|
||||
location @api_payload_too_large {
|
||||
add_header_inherit off;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Request-Id $request_id always;
|
||||
return 200 '{"success":false,"error":{"code":"FILE_002","message":"","traceId":"$request_id"}}';
|
||||
}
|
||||
|
||||
location @api_unavailable {
|
||||
add_header_inherit off;
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Request-Id $request_id always;
|
||||
return 200 '{"success":false,"error":{"code":"SYS_001","message":"","traceId":"$request_id"}}';
|
||||
}
|
||||
# /api 의 nginx 오류 응답 — @api_not_found(404 RES_001) · @api_payload_too_large(413 FILE_002) · @api_too_many_requests(429 REQ_001) ·
|
||||
# @api_unavailable(5xx SYS_001)
|
||||
include /etc/nginx/snippets/api-errors.conf;
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
@@ -293,3 +425,66 @@ server {
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 공개 내려받기 — 대용량 첨부의 공개 호스트 (11단계 — 계약 4절 "공개 호스트", 운영 files.arasystem.it.kr)
|
||||
#
|
||||
# 로그인 없이 바깥 사람이 여는 곳이다. 여는 것은 셋뿐이다 — 화면 /s/<토큰>(SPA 의 공개 라우트 — 같은 묶음의 index.html) · 화면 자산(/assets/ · /favicon.ico) ·
|
||||
# 공개 API /api/public-links/. 그 밖은 화면 · API 모두 404 — 털린 계정이 퍼뜨린 링크가 포털의 화면 · API 로 이어지지 않게 (백엔드도 같은 판정으로 막는다).
|
||||
# 공개 호스트를 따로 두지 않으면 이 server 의 이름은 어느 요청에도 맞지 않는 share.invalid 이고(nginx-env.envsh), 포털이 같은 것을 연다.
|
||||
# 포털 쿠키는 호스트 한정(Domain 없음)이라 이 호스트로 오지 않는다.
|
||||
# ==========================================
|
||||
server {
|
||||
listen 8080;
|
||||
server_name ${NGINX_SHARE_SERVER_NAME};
|
||||
|
||||
# 접근 로그 — 포털과 같은 형식(쿼리 없음 · 경로의 토큰은 ***)
|
||||
access_log /var/log/nginx/access.log main_rid if=$log_access;
|
||||
|
||||
# 받기만 한다 — 본문을 받는 경로가 없다
|
||||
client_max_body_size 1m;
|
||||
|
||||
# 보안 헤더 (화면용) — 포털과 같고 둘이 다르다: Referer 를 아예 보내지 않고(no-referrer — 화면 주소에 토큰이 있다),
|
||||
# 검색 엔진에 올리지 않는다(X-Robots-Tag). 공개 API 는 location 에서 상속을 끈다(백엔드가 정한다)
|
||||
add_header_inherit merge;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
add_header Content-Security-Policy $page_csp always;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# 화면 /s/<토큰> (포털이 같은 호스트일 때와 같은 파일)
|
||||
include /etc/nginx/snippets/share-open.conf;
|
||||
|
||||
# 화면 자산 — 포털과 같은 캐시 (해시가 든 /assets/ 는 30일 immutable, 해시가 없는 favicon 은 매번 재검증)
|
||||
location ^~ /assets/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
location = /favicon.ico {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# API — 공개 API /api/public-links/ 만 백엔드로 넘기고(포털이 같은 호스트일 때와 같은 파일 — 대소문자를 가리지 않는다),
|
||||
# 그 밖은 없는 경로와 같은 404 RES_001 JSON (@api_not_found). 본문 한도(1m)를 넘는 요청도 JSON 413 FILE_002 다.
|
||||
# add_header 는 여기 두지 않는다 — 두면 안쪽의 백엔드로 넘기는 location 이 물려받는다(JSON 머리글은 @api_* 가 붙인다).
|
||||
# return 은 안쪽 location 으로 물려지지 않는다 — 안쪽에 맞은 요청은 백엔드로 간다
|
||||
location ^~ /api/ {
|
||||
add_header_inherit off;
|
||||
error_page 404 @api_not_found;
|
||||
error_page 413 @api_payload_too_large;
|
||||
include /etc/nginx/snippets/share-api-open.conf;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 그 밖의 화면 · 파일 (/ · /index.html · /mail/… 등) — 404
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# API 의 nginx 오류 응답 (포털과 같은 파일)
|
||||
include /etc/nginx/snippets/api-errors.conf;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user