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,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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user