Files
arasystem/backend/src/main.ts
T
ttipo 93154ad9a5 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>
2026-10-31 00:37:36 +09:00

103 lines
4.5 KiB
TypeScript

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { WinstonModule } from 'nest-winston';
import type {
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
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';
import {
buildAllowedOrigins,
normalizeOrigin,
} from '@/shared/utils/request-origin';
/** PORT 가 없거나 비었을 때 듣는 포트 */
const DEFAULT_PORT = 3000;
async function bootstrap() {
// 어댑터는 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();
expressApp.get('/', (_req: ExpressRequest, res: ExpressResponse) => {
res.send('Backend API Server is running 정상 구동 중입니다.');
});
// 1. 공통 HTTP 파이프라인 — prefix · helmet · 쿠키 · 본문 파서 · 검증 · 표준 응답
// e2e 테스트도 같은 함수를 쓴다 (app.setup.ts)
configureApp(app);
// 2. 화이트리스트 기반 CORS 정책 — 운영은 CORS_ORIGIN 만, localhost 기본값은 개발에서만.
// 화면과 API 를 같은 주소로 배포하면 CORS 는 쓰이지 않는다 (docs/mail/api-contract.md 머리말).
// 같은 목록을 상태 변경 요청의 출처 검사(OriginGuard)도 쓴다.
const allowedOrigins = new Set(
buildAllowedOrigins((key) => process.env[key]),
);
app.enableCors({
origin: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) => {
// 서버 간 통신(origin 없음)이거나 목록에 있는 출처일 때만 허용
const normalized = origin === undefined ? null : normalizeOrigin(origin);
if (!origin || (normalized !== null && allowedOrigins.has(normalized))) {
callback(null, true);
} else {
// Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로,
// CORS 헤더를 부여하지 않아 브라우저가 차단하도록 한다.
callback(null, false);
}
},
credentials: true, // 쿠키 교환 허용
});
// 3. Rate Limiting 은 AppModule 의 ThrottlerGuard(APP_GUARD)로 일원화되어 있다.
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
// 4. Swagger API 문서 설정 — 인증은 HttpOnly 쿠키(access_token)뿐이다 (Authorization 헤더는 읽지 않는다)
const config = new DocumentBuilder()
.setTitle('Backend API Documentation')
.setDescription('The API documentation conforming to the global guidelines')
.setVersion('1.0')
.addCookieAuth(ACCESS_TOKEN_COOKIE)
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
// 5. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
// (이 설정이 없으면 배포·재시작 때 처리 중이던 요청이 그대로 끊긴다)
app.enableShutdownHooks();
// 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다.
// compose 는 정의되지 않은 변수를 빈 문자열로 넣는다 — Number('') 는 0 이라 parsePositiveInt 로 기본값을 쓴다
const port = parsePositiveInt(process.env.PORT, DEFAULT_PORT);
await app.listen(port, '0.0.0.0');
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
Logger.log(
`📘 Swagger docs at http://localhost:${port}/api-docs`,
'Bootstrap',
);
}
void bootstrap();