generated from ttipo/project-base
feat(backend): 대용량 첨부 — 조각 올리기 · 악성 검사 · 공개 링크 · 내려받기
- file-storage: /api/file-uploads(만들기 · 16MiB 조각 PUT — Content-Digest · datasync · 같은 조각은 하나만 · 끝내기 · 상태 · 지우기),
/api/file-quota. 보관량(직원 · 서버 · 디스크 여유)은 pg_advisory_xact_lock 안에서 센다. 저장 포트 FILE_BLOB_STORE(지금은 로컬 볼륨).
ClamAV INSTREAM 을 직접 구현했다 — 판정 글은 한 곳(암호 · 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error),
다 보낸 뒤 판정 대기 30분 + 1MB 마다 1초 · TCP keepalive. 검사 차례는 DB pending(동시 2), 정리 cron.
- file-sharing · mail: 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 본문에 덩이를 넣고(표시는 서버만 —
두 정화기가 사용자 HTML 의 data-ara-* · 주석을 지운다), 링크는 확실한 실패(SendNotStartedException)에서만 거둔다.
공개 /api/public-links/{token} · 파일 받기(Range · 조건부 UPDATE 로 받은 수), 링크 끊기 · 목록 · 읽기 화면의 largeFiles.
- 공개 호스트 미들웨어는 원문 · 라우터 경로 · WHATWG URL · 퍼센트를 푼 원문을 모두 보고(리뷰 — 절대 주소 꼴 · \ 요청이 막기를 지났다),
라우팅은 대소문자를 가린다(createHttpAdapter — /FILES/ 같은 변형이 앞단 상한을 비켜 갔다).
- env: SHARE_PUBLIC_URL(nginx 와 같은 모양 검사 · 운영에서 포털과 같은 호스트면 기동 중단) · MAIL_LARGE_FILE_* · FILE_STORAGE_* · CLAMAV_*.
- 에러 코드 FILE_003 ~ FILE_008 · MAIL_016 — 웹 · 앱 사전 다시 생성. 마이그레이션 둘.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user