From 93154ad9a5369c7902815acea1b89418a3e27e34 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 00:37:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EB=8C=80=EC=9A=A9=EB=9F=89=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=20=E2=80=94=20=EC=A1=B0=EA=B0=81=20=EC=98=AC?= =?UTF-8?q?=EB=A6=AC=EA=B8=B0=20=C2=B7=20=EC=95=85=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=C2=B7=20=EA=B3=B5=EA=B0=9C=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=C2=B7=20=EB=82=B4=EB=A0=A4=EB=B0=9B=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/app.module.ts | 6 + backend/src/app.setup.spec.ts | 85 +- backend/src/app.setup.ts | 77 +- .../common/decorators/no-store.decorator.ts | 8 + backend/src/common/errors/error-codes.spec.ts | 23 + backend/src/common/errors/error-codes.ts | 41 + .../filters/http-exception.filter.spec.ts | 60 + .../common/filters/http-exception.filter.ts | 24 + backend/src/main.ts | 17 +- .../1793348306562-AddLargeFileSharing.ts | 95 ++ ...793358223047-AddFileCompletionStartedAt.ts | 25 + .../file-sharing/dto/share-link.dto.ts | 221 +++ .../file-sharing/file-sharing.cleanup.ts | 38 + .../file-sharing/file-sharing.module.ts | 43 + .../file-sharing/file-sharing.repository.ts | 205 +++ .../file-sharing/file-sharing.service.spec.ts | 613 +++++++ .../file-sharing/file-sharing.service.ts | 623 +++++++ .../file-sharing/file-sharing.settings.ts | 65 + .../file-sharing/file-sharing.types.ts | 108 ++ .../modules/file-sharing/public-host.spec.ts | 403 +++++ .../src/modules/file-sharing/public-host.ts | 162 ++ .../file-sharing/public-link.controller.ts | 132 ++ .../file-sharing/share-link-file.entity.ts | 50 + .../share-link-lookup.controller.ts | 56 + .../modules/file-sharing/share-link.entity.ts | 105 ++ .../modules/file-sharing/share-token.spec.ts | 63 + .../src/modules/file-sharing/share-token.ts | 67 + .../file-sharing/shared-file-status.spec.ts | 92 ++ .../file-sharing/shared-file-status.ts | 42 + .../modules/file-storage/chunk-upload.spec.ts | 87 + .../src/modules/file-storage/chunk-upload.ts | 136 ++ .../file-storage/clamav-client.spec.ts | 469 ++++++ .../src/modules/file-storage/clamav-client.ts | 291 ++++ .../file-storage/dto/file-upload.dto.ts | 221 +++ .../modules/file-storage/file-blob-store.ts | 84 + .../modules/file-storage/file-id.pipe.spec.ts | 58 + .../src/modules/file-storage/file-id.pipe.ts | 42 + .../file-storage/file-retention.spec.ts | 144 ++ .../modules/file-storage/file-retention.ts | 83 + .../file-storage/file-scan.service.spec.ts | 372 +++++ .../modules/file-storage/file-scan.service.ts | 296 ++++ .../file-storage/file-storage.cleanup.spec.ts | 308 ++++ .../file-storage/file-storage.cleanup.ts | 176 ++ .../file-storage.constants.spec.ts | 57 + .../file-storage/file-storage.constants.ts | 113 ++ .../file-storage/file-storage.module.ts | 90 + .../file-storage/file-storage.repository.ts | 354 ++++ .../file-storage/file-storage.service.spec.ts | 688 ++++++++ .../file-storage/file-storage.service.ts | 865 ++++++++++ .../file-storage/file-storage.settings.ts | 125 ++ .../file-storage/file-storage.types.ts | 118 ++ .../file-storage/file-upload.controller.ts | 271 ++++ .../modules/file-storage/large-file.policy.ts | 47 + .../local-disk-blob-store.spec.ts | 143 ++ .../file-storage/local-disk-blob-store.ts | 210 +++ .../file-storage/stored-file-chunk.entity.ts | 31 + .../file-storage/stored-file.entity.ts | 163 ++ .../modules/mail/attachment.policy.spec.ts | 23 +- backend/src/modules/mail/attachment.policy.ts | 50 +- .../src/modules/mail/dto/mail-response.dto.ts | 100 +- .../src/modules/mail/dto/send-message.dto.ts | 42 + .../mail/html/sanitize-mail-html.spec.ts | 30 + .../modules/mail/html/sanitize-mail-html.ts | 25 + .../mail/html/sanitize-outgoing-html.ts | 17 +- .../mail/jmap/jmap-mail.provider.spec.ts | 274 +++- .../modules/mail/jmap/jmap-mail.provider.ts | 66 +- .../src/modules/mail/large-file-block.spec.ts | 191 +++ backend/src/modules/mail/large-file-block.ts | 239 +++ .../src/modules/mail/mail-events.service.ts | 20 + .../mail/mail-large-files.service.spec.ts | 596 +++++++ .../modules/mail/mail-large-files.service.ts | 640 ++++++++ backend/src/modules/mail/mail.constants.ts | 4 +- backend/src/modules/mail/mail.module.ts | 9 + backend/src/modules/mail/mail.provider.ts | 17 + backend/src/modules/mail/mail.service.spec.ts | 266 +++ backend/src/modules/mail/mail.service.ts | 233 ++- backend/src/modules/mail/mail.types.ts | 49 +- .../src/modules/mail/message.controller.ts | 60 +- .../mail/scheduled-messages.service.spec.ts | 25 +- .../mail/scheduled-messages.service.ts | 11 +- .../src/modules/mail/send-idempotency.spec.ts | 29 + backend/src/modules/mail/send-idempotency.ts | 13 + .../src/modules/mail/share-link.controller.ts | 81 + .../src/shared/config/env.validation.spec.ts | 212 +++ backend/src/shared/config/env.validation.ts | 203 ++- .../src/shared/utils/bigint-column.spec.ts | 9 + backend/src/shared/utils/bigint-column.ts | 11 + backend/src/shared/utils/file-name.spec.ts | 46 + backend/src/shared/utils/file-name.ts | 56 + backend/src/shared/utils/format.spec.ts | 31 +- backend/src/shared/utils/format.ts | 29 + backend/src/shared/utils/http-range.spec.ts | 81 + backend/src/shared/utils/http-range.ts | 106 ++ backend/src/shared/utils/kst-date.spec.ts | 48 + backend/src/shared/utils/kst-date.ts | 63 + backend/src/shared/utils/mask.spec.ts | 47 + backend/src/shared/utils/mask.ts | 61 +- backend/src/shared/utils/stream-abort.spec.ts | 29 + backend/src/shared/utils/stream-abort.ts | 22 + backend/test/app.e2e-spec.ts | 1445 ++++++++++++++++- flutter/lib/core/api/error_lexicon.g.dart | 7 + .../shared/constants/error-codes.generated.ts | 7 + 102 files changed, 15058 insertions(+), 156 deletions(-) create mode 100644 backend/src/common/decorators/no-store.decorator.ts create mode 100644 backend/src/migrations/1793348306562-AddLargeFileSharing.ts create mode 100644 backend/src/migrations/1793358223047-AddFileCompletionStartedAt.ts create mode 100644 backend/src/modules/file-sharing/dto/share-link.dto.ts create mode 100644 backend/src/modules/file-sharing/file-sharing.cleanup.ts create mode 100644 backend/src/modules/file-sharing/file-sharing.module.ts create mode 100644 backend/src/modules/file-sharing/file-sharing.repository.ts create mode 100644 backend/src/modules/file-sharing/file-sharing.service.spec.ts create mode 100644 backend/src/modules/file-sharing/file-sharing.service.ts create mode 100644 backend/src/modules/file-sharing/file-sharing.settings.ts create mode 100644 backend/src/modules/file-sharing/file-sharing.types.ts create mode 100644 backend/src/modules/file-sharing/public-host.spec.ts create mode 100644 backend/src/modules/file-sharing/public-host.ts create mode 100644 backend/src/modules/file-sharing/public-link.controller.ts create mode 100644 backend/src/modules/file-sharing/share-link-file.entity.ts create mode 100644 backend/src/modules/file-sharing/share-link-lookup.controller.ts create mode 100644 backend/src/modules/file-sharing/share-link.entity.ts create mode 100644 backend/src/modules/file-sharing/share-token.spec.ts create mode 100644 backend/src/modules/file-sharing/share-token.ts create mode 100644 backend/src/modules/file-sharing/shared-file-status.spec.ts create mode 100644 backend/src/modules/file-sharing/shared-file-status.ts create mode 100644 backend/src/modules/file-storage/chunk-upload.spec.ts create mode 100644 backend/src/modules/file-storage/chunk-upload.ts create mode 100644 backend/src/modules/file-storage/clamav-client.spec.ts create mode 100644 backend/src/modules/file-storage/clamav-client.ts create mode 100644 backend/src/modules/file-storage/dto/file-upload.dto.ts create mode 100644 backend/src/modules/file-storage/file-blob-store.ts create mode 100644 backend/src/modules/file-storage/file-id.pipe.spec.ts create mode 100644 backend/src/modules/file-storage/file-id.pipe.ts create mode 100644 backend/src/modules/file-storage/file-retention.spec.ts create mode 100644 backend/src/modules/file-storage/file-retention.ts create mode 100644 backend/src/modules/file-storage/file-scan.service.spec.ts create mode 100644 backend/src/modules/file-storage/file-scan.service.ts create mode 100644 backend/src/modules/file-storage/file-storage.cleanup.spec.ts create mode 100644 backend/src/modules/file-storage/file-storage.cleanup.ts create mode 100644 backend/src/modules/file-storage/file-storage.constants.spec.ts create mode 100644 backend/src/modules/file-storage/file-storage.constants.ts create mode 100644 backend/src/modules/file-storage/file-storage.module.ts create mode 100644 backend/src/modules/file-storage/file-storage.repository.ts create mode 100644 backend/src/modules/file-storage/file-storage.service.spec.ts create mode 100644 backend/src/modules/file-storage/file-storage.service.ts create mode 100644 backend/src/modules/file-storage/file-storage.settings.ts create mode 100644 backend/src/modules/file-storage/file-storage.types.ts create mode 100644 backend/src/modules/file-storage/file-upload.controller.ts create mode 100644 backend/src/modules/file-storage/large-file.policy.ts create mode 100644 backend/src/modules/file-storage/local-disk-blob-store.spec.ts create mode 100644 backend/src/modules/file-storage/local-disk-blob-store.ts create mode 100644 backend/src/modules/file-storage/stored-file-chunk.entity.ts create mode 100644 backend/src/modules/file-storage/stored-file.entity.ts create mode 100644 backend/src/modules/mail/large-file-block.spec.ts create mode 100644 backend/src/modules/mail/large-file-block.ts create mode 100644 backend/src/modules/mail/mail-large-files.service.spec.ts create mode 100644 backend/src/modules/mail/mail-large-files.service.ts create mode 100644 backend/src/modules/mail/share-link.controller.ts create mode 100644 backend/src/shared/utils/bigint-column.spec.ts create mode 100644 backend/src/shared/utils/bigint-column.ts create mode 100644 backend/src/shared/utils/file-name.spec.ts create mode 100644 backend/src/shared/utils/file-name.ts create mode 100644 backend/src/shared/utils/http-range.spec.ts create mode 100644 backend/src/shared/utils/http-range.ts create mode 100644 backend/src/shared/utils/kst-date.spec.ts create mode 100644 backend/src/shared/utils/kst-date.ts create mode 100644 backend/src/shared/utils/stream-abort.spec.ts create mode 100644 backend/src/shared/utils/stream-abort.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5b63f49..53ca020 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -10,6 +10,8 @@ import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guar import { OriginGuard } from '@/common/guards/origin.guard'; import { AuthModule } from '@/modules/auth/auth.module'; import { ContactsModule } from '@/modules/contacts/contacts.module'; +import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module'; +import { FileStorageModule } from '@/modules/file-storage/file-storage.module'; import { HealthModule } from '@/modules/health/health.module'; import { MailModule } from '@/modules/mail/mail.module'; import { UploadModule } from '@/modules/upload/upload.module'; @@ -68,6 +70,10 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 MailModule, // 주소록 API — 내 주소록 · 사내 목록 (8단계, 메일 모듈도 받는 사람 추천에 쓴다) ContactsModule, + // 대용량 첨부 (11단계) — 파일 · 조각 올리기 · 악성 검사(FileStorageModule)와 공개 링크 · 내려받기(FileSharingModule). + // 메일 모듈도 가져온다(메일 → 공유 → 저장 한 방향) — 2부 파일 저장소가 같은 두 모듈을 쓴다 + FileStorageModule, + FileSharingModule, UploadModule, UserModule, ], diff --git a/backend/src/app.setup.spec.ts b/backend/src/app.setup.spec.ts index 665239e..13beec4 100644 --- a/backend/src/app.setup.spec.ts +++ b/backend/src/app.setup.spec.ts @@ -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 => { + const moduleRef = await Test.createTestingModule({ + controllers: [ProbeRoutesController], + }).compile(); + const app = adapter + ? moduleRef.createNestApplication(adapter, { + logger: false, + }) + : moduleRef.createNestApplication({ + 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/); + }); +}); diff --git a/backend/src/app.setup.ts b/backend/src/app.setup.ts index c39a7fa..25d8ec9 100644 --- a/backend/src/app.setup.ts +++ b/backend/src/app.setup.ts @@ -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 | 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): string { return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase(); } /** * 발송 요청인가 — `POST /api/messages`. - * 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다. + * 라우터가 발송 핸들러로 보내는 모양(끝의 '/' · 쿼리)이면 모두 해당하고, 대소문자 변형도 넓게 해당한다 — 그 변형은 라우터에서 404 (위 routedPath). */ export function isSendMessageRequest( req: Pick, @@ -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(FILE_SHARING_SETTINGS, { + strict: false, + }); + app.use(createPublicHostMiddleware(sharing.dedicatedPublicHost)); + // 믿을 프록시 뒤에서 실제 클라이언트 IP 를 읽는다 — 로그인 실패 한도·요청 수 한도의 기준 (TRUST_PROXY). // 운영 체인: 클라이언트 → 외부 리버스 프록시 → 프론트 nginx → 백엔드. 값이 없으면(비운영) 1 app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY)); diff --git a/backend/src/common/decorators/no-store.decorator.ts b/backend/src/common/decorators/no-store.decorator.ts new file mode 100644 index 0000000..322cac5 --- /dev/null +++ b/backend/src/common/decorators/no-store.decorator.ts @@ -0,0 +1,8 @@ +import { Header } from '@nestjs/common'; + +/** 공유 캐시 · 브라우저에 남기지 않는다 — 내 파일 · 링크처럼 직원만 보는 응답 (계약 4절 "공개 경로 밖의 응답은 private, no-store") */ +export const PRIVATE_NO_STORE = 'private, no-store'; + +/** 성공 응답에 `Cache-Control: private, no-store` 를 붙인다 (핸들러마다) */ +export const NoStore = (): MethodDecorator => + Header('Cache-Control', PRIVATE_NO_STORE); diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index 5f22c7a..b54f344 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -82,6 +82,29 @@ describe('에러 코드 레지스트리', () => { expect(ERROR_CODES.CONTACT_002.status).toBe(HttpStatus.CONFLICT); }); + it('대용량 첨부(11단계) — 상태가 맞지 않음 409 FILE_003 · 조각 손상 400 FILE_004 · 보관 한도 409 FILE_005 · 서버 공간 507 FILE_006 · 더 받을 수 없음 410 FILE_007 · 검사 중 409 FILE_008 · 쓸 수 없는 파일 400 MAIL_016', () => { + expect(ERROR_CODES.FILE_003.status).toBe(HttpStatus.CONFLICT); + expect(ERROR_CODES.FILE_004.status).toBe(HttpStatus.BAD_REQUEST); + expect(ERROR_CODES.FILE_005.status).toBe(HttpStatus.CONFLICT); + expect(ERROR_CODES.FILE_006.status).toBe(HttpStatus.INSUFFICIENT_STORAGE); + expect(ERROR_CODES.FILE_007.status).toBe(HttpStatus.GONE); + expect(ERROR_CODES.FILE_008.status).toBe(HttpStatus.CONFLICT); + expect(ERROR_CODES.MAIL_016.status).toBe(HttpStatus.BAD_REQUEST); + }); + + it('410 · 507 은 상태 역매핑에 넣지 않는다 — FILE_007 · FILE_006 은 BusinessException 으로만 나온다', () => { + expect(resolveCodeByStatus(HttpStatus.GONE)).toBe('BIZ_001'); + expect(resolveCodeByStatus(HttpStatus.INSUFFICIENT_STORAGE)).toBe( + 'SYS_001', + ); + }); + + it('대용량 첨부 한도 문구에는 숫자가 없다 — 한도가 env 로 바뀐다', () => { + for (const code of ['FILE_005', 'FILE_006', 'MAIL_016'] as const) { + expect(ERROR_CODES[code].message).not.toMatch(/\d/); + } + }); + it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => { const clientOnlyCodes = Object.entries(ERROR_CODES) .filter(([, definition]) => 'clientOnly' in definition) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 9331cb8..cf3489f 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -84,6 +84,40 @@ const DOMAIN_ERROR_CODES = { status: HttpStatus.PAYLOAD_TOO_LARGE, message: '파일 크기가 허용 범위를 초과했습니다.', }, + // 대용량 첨부(11단계 — 계약 4절) — 올리기 · 파일의 지금 상태와 맞지 않는 요청: 끝낸 올리기에 조각 · 같은 조각을 동시에 · 받은 조각과 내용이 다른 조각 · + // 빠진 조각이 있는데 끝내기 · 끝내기가 진행 중 · 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 — 클라이언트는 GET 으로 상태를 보고 잇는다 + FILE_003: { + status: HttpStatus.CONFLICT, + message: '파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요.', + }, + // 조각의 Content-Digest(sha-256)가 받은 바이트와 다르다 — 그 조각을 받지 않았다 (같은 조각을 다시 보낸다) + FILE_004: { + status: HttpStatus.BAD_REQUEST, + message: '파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요.', + }, + // 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다. 문구에 숫자를 넣지 않는다(한도가 env 로 바뀐다) + FILE_005: { + status: HttpStatus.CONFLICT, + message: + '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.', + }, + // 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 ENOSPC. 507 은 클라이언트의 저절로 다시 보내기 대상(502 · 503 · 504)과 상태로 갈린다 + FILE_006: { + status: HttpStatus.INSUFFICIENT_STORAGE, + message: + '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', + }, + // 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 (공개 받기 · 우리 웹메일 받기) + FILE_007: { + status: HttpStatus.GONE, + message: + '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.', + }, + // 아직 보안 검사(악성 검사) 중인 파일 — 끝나면 받는다 + FILE_008: { + status: HttpStatus.CONFLICT, + message: '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.', + }, // 메일 — 외부 메일 엔진(Stalwart) 장애를 SYS_001(우리 버그)과 구분하기 위해 분리한다 MAIL_001: { status: HttpStatus.SERVICE_UNAVAILABLE, @@ -176,6 +210,13 @@ const DOMAIN_ERROR_CODES = { message: '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', }, + // 보내거나 저장할 대용량 첨부(11단계 — 계약 4절) 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험. + // 메일은 나가지 않았다 · 저장하지 않았다. 클라이언트는 파일마다 GET /api/file-uploads/{id} 로 다시 물어 쓸 수 없는 것을 보인다 (MAIL_004 와 같은 흐름) + MAIL_016: { + status: HttpStatus.BAD_REQUEST, + message: + '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.', + }, // 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다). // 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절) CONTACT_001: { diff --git a/backend/src/common/filters/http-exception.filter.spec.ts b/backend/src/common/filters/http-exception.filter.spec.ts index 13d7fb0..017569c 100644 --- a/backend/src/common/filters/http-exception.filter.spec.ts +++ b/backend/src/common/filters/http-exception.filter.spec.ts @@ -193,3 +193,63 @@ describe('HttpExceptionFilter', () => { expect(result.body.error).not.toHaveProperty('traceId'); }); }); + +describe('HttpExceptionFilter — 공개 경로 옵션 · 이미 쓰기 시작한 응답 (11단계)', () => { + const hostFor = (response: object) => + ({ + switchToHttp: () => ({ + getRequest: () => ({ + method: 'GET', + url: '/api/public-links/x', + header: () => undefined, + }), + getResponse: () => response, + }), + }) as unknown as ArgumentsHost; + + afterEach(() => jest.restoreAllMocks()); + + it('quietStatuses 의 실패는 debug 로만 — 형식은 그대로 표준 실패', () => { + const debug = jest + .spyOn(Logger.prototype, 'debug') + .mockImplementation(() => undefined); + const warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + const json = jest.fn(); + const response = { status: jest.fn().mockReturnValue({ json }) }; + + new HttpExceptionFilter({ quietStatuses: [404, 409, 410] }).catch( + new BusinessException('FILE_007'), + hostFor(response), + ); + expect(response.status).toHaveBeenCalledWith(HttpStatus.GONE); + expect(json).toHaveBeenCalledWith({ + success: false, + error: { code: 'FILE_007', message: ERROR_CODES.FILE_007.message }, + }); + expect(debug).toHaveBeenCalledTimes(1); + expect(warn).not.toHaveBeenCalled(); + + new HttpExceptionFilter({ quietStatuses: [404] }).catch( + new BusinessException('REQ_001'), + hostFor(response), + ); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('응답을 이미 쓰기 시작했으면 실패 본문을 덧붙이지 않고 연결을 끊는다', () => { + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + const response = { + headersSent: true, + destroy: jest.fn(), + status: jest.fn(), + }; + new HttpExceptionFilter().catch( + new Error('stream broke'), + hostFor(response), + ); + expect(response.destroy).toHaveBeenCalledTimes(1); + expect(response.status).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index ae1d420..54613da 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -30,9 +30,23 @@ const CLIENT_ERROR_STATUS_THRESHOLD = 400; * 에러 코드 ↔ 메시지 ↔ HTTP 상태는 error-codes.ts 레지스트리가 SSOT 이며, * 이 필터는 조회만 한다(여기에 문구를 하드코딩하지 말 것). */ +/** 필터 옵션 — 컨트롤러에 따로 붙일 때만 쓴다 (전역 필터는 기본값) */ +export interface HttpExceptionFilterOptions { + /** + * debug 로만 남길 상태 코드 — 바깥 사람 · 봇이 잦게 부르는 공개 경로의 예상된 실패(없는 링크 404 · 검사 중 409 · 끝난 링크 410)가 + * warn 으로 쌓여 알림 피로가 되지 않게 (계약 4절 "공개 내려받기" — 외부 이미지 중계와 같은 결). 형식은 그대로 표준 실패다 + */ + readonly quietStatuses?: readonly number[]; +} + @Catch() export class HttpExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(HttpExceptionFilter.name); + private readonly quietStatuses: ReadonlySet; + + constructor(options: HttpExceptionFilterOptions = {}) { + this.quietStatuses = new Set(options.quietStatuses ?? []); + } catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp(); @@ -44,6 +58,12 @@ export class HttpExceptionFilter implements ExceptionFilter { this.log(exception, status, code, request, traceId); + // 응답을 이미 쓰기 시작했다(파일을 흘려보내던 중 등) — 실패 본문을 덧붙일 수 없다. 연결을 끊어 받는 쪽이 실패로 알게 한다 + if (response.headersSent) { + response.destroy(); + return; + } + response.status(status).json({ success: false, error: { code, message, ...(traceId ? { traceId } : {}) }, @@ -158,6 +178,10 @@ export class HttpExceptionFilter implements ExceptionFilter { return; } + if (this.quietStatuses.has(status)) { + this.logger.debug(`${prefix}${context}`); + return; + } this.logger.warn(`${prefix}${context}`); } } diff --git a/backend/src/main.ts b/backend/src/main.ts index 1780a8c..4d5c9d9 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,7 +9,7 @@ import type { Response as ExpressResponse, } from 'express'; -import { configureApp } from '@/app.setup'; +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'; @@ -22,11 +22,16 @@ import { const DEFAULT_PORT = 3000; async function bootstrap() { - const app = await NestFactory.create(AppModule, { - logger: WinstonModule.createLogger(winstonConfig), - // 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정) - bodyParser: false, - }); + // 어댑터는 createHttpAdapter — 라우팅이 대소문자를 가린다(어댑터를 만들 때 켜야 한다 — app.setup.ts). e2e 도 같은 함수를 쓴다 + const app = await NestFactory.create( + AppModule, + createHttpAdapter(), + { + logger: WinstonModule.createLogger(winstonConfig), + // 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정) + bodyParser: false, + }, + ); // 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 (컨테이너 healthcheck 가 읽는다) const expressApp = app.getHttpAdapter().getInstance(); diff --git a/backend/src/migrations/1793348306562-AddLargeFileSharing.ts b/backend/src/migrations/1793348306562-AddLargeFileSharing.ts new file mode 100644 index 0000000..7e061d7 --- /dev/null +++ b/backend/src/migrations/1793348306562-AddLargeFileSharing.ts @@ -0,0 +1,95 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 대용량 첨부 표 (docs/mail/api-contract.md 4절 "대용량 첨부" — 11단계). + * - stored_files: 파일(올리기 id = 파일 id) — 주인 · 이름 · 선언한 크기 · 상태 · 검사 결과 · 보관 끝(expires_at). 바이트는 파일 볼륨(/app/files) + * 부분 인덱스: 살아 있는 파일의 기간(정리) · 검사를 기다리는 파일 · 지운 파일(기록 정리) + * - stored_file_chunks: 받은 조각(번호 · 크기 · SHA-256) — 파일을 지우면 함께 지워진다 + * - share_links: 공유 링크(메일 한 통) — 토큰은 SHA-256 만(유일 인덱스), 보낸 사람 · 보낸 메일 · 끝 · 끊음 · 취소 + * - share_link_files: 링크의 파일 — 보낸 차례 · 그때의 이름 · 크기 · 받은 수. 링크를 지우면 함께 지워진다(파일 표와는 묶지 않는다) + * 새 표만 만든다 — 기존 표(auth_sessions · mail_signatures · mail_signature_images · mail_directory · mail_settings)는 건드리지 않는다. + * (자동 생성: npm run migration:generate — 엔티티는 modules/file-storage/*.entity.ts · modules/file-sharing/*.entity.ts) + */ +export class AddLargeFileSharing1793348306562 implements MigrationInterface { + name = 'AddLargeFileSharing1793348306562'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "share_links" ("id" uuid NOT NULL, "token_hash" bytea NOT NULL, "owner_email" character varying(254) NOT NULL, "sender_name" character varying(200), "purpose" character varying(32) NOT NULL, "message_id" character varying(255), "sent_email_id" character varying(255), "sent_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, "revoked_by" character varying(32), "canceled_at" TIMESTAMP WITH TIME ZONE, "cancel_reason" character varying(32), CONSTRAINT "PK_70320b79ecd8acab96419fcdd6d" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_share_links_token_hash" ON "share_links" ("token_hash") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_share_links_owner_message" ON "share_links" ("owner_email", "message_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_share_links_owner_sent_email" ON "share_links" ("owner_email", "sent_email_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_share_links_owner_sent" ON "share_links" ("owner_email", "sent_at") `, + ); + await queryRunner.query( + `CREATE TABLE "share_link_files" ("link_id" uuid NOT NULL, "file_id" uuid NOT NULL, "position" smallint NOT NULL, "name" character varying(255) NOT NULL, "size" bigint NOT NULL, "download_count" integer NOT NULL DEFAULT '0', "last_downloaded_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_2c813a6dd8adba8c8902c8314b4" PRIMARY KEY ("link_id", "file_id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_share_link_files_file" ON "share_link_files" ("file_id") `, + ); + await queryRunner.query( + `CREATE TABLE "stored_files" ("id" uuid NOT NULL, "owner_email" character varying(254) NOT NULL, "purpose" character varying(16) NOT NULL, "name" character varying(255) NOT NULL, "size" bigint NOT NULL, "chunk_size" integer NOT NULL, "chunk_count" integer NOT NULL, "last_modified" bigint, "state" character varying(16) NOT NULL, "removed_reason" character varying(16), "first_chunk_sha256" character varying(44), "scan_status" character varying(16), "scan_signature" character varying(255), "scan_attempts" integer NOT NULL DEFAULT '0', "scan_next_at" TIMESTAMP WITH TIME ZONE, "scanned_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_chunk_at" TIMESTAMP WITH TIME ZONE, "completed_at" TIMESTAMP WITH TIME ZONE, "draft_attached_at" TIMESTAMP WITH TIME ZONE, "linked_at" TIMESTAMP WITH TIME ZONE, "linked_until" TIMESTAMP WITH TIME ZONE, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "removed_at" TIMESTAMP WITH TIME ZONE, "bytes_deleted_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_5d5be862bf53851c1794b4adf4e" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_stored_files_removed" ON "stored_files" ("removed_at") WHERE "state" = 'removed'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_stored_files_scan_pending" ON "stored_files" ("scan_next_at") WHERE "scan_status" = 'pending'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_stored_files_alive_expires" ON "stored_files" ("expires_at") WHERE "state" <> 'removed'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_stored_files_owner_created" ON "stored_files" ("owner_email", "created_at") `, + ); + await queryRunner.query( + `CREATE TABLE "stored_file_chunks" ("file_id" uuid NOT NULL, "chunk_index" integer NOT NULL, "size" integer NOT NULL, "sha256" bytea NOT NULL, "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_ea6c24c7b1bd69e0a10dd48d2b3" PRIMARY KEY ("file_id", "chunk_index"))`, + ); + await queryRunner.query( + `ALTER TABLE "share_link_files" ADD CONSTRAINT "FK_def8bbe7f42c897e71e8c2472c7" FOREIGN KEY ("link_id") REFERENCES "share_links"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "stored_file_chunks" ADD CONSTRAINT "FK_37be6c847f971f1b9536e7dd076" FOREIGN KEY ("file_id") REFERENCES "stored_files"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "stored_file_chunks" DROP CONSTRAINT "FK_37be6c847f971f1b9536e7dd076"`, + ); + await queryRunner.query( + `ALTER TABLE "share_link_files" DROP CONSTRAINT "FK_def8bbe7f42c897e71e8c2472c7"`, + ); + await queryRunner.query(`DROP TABLE "stored_file_chunks"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_stored_files_owner_created"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_stored_files_alive_expires"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_stored_files_scan_pending"`, + ); + await queryRunner.query(`DROP INDEX "public"."IDX_stored_files_removed"`); + await queryRunner.query(`DROP TABLE "stored_files"`); + await queryRunner.query(`DROP INDEX "public"."IDX_share_link_files_file"`); + await queryRunner.query(`DROP TABLE "share_link_files"`); + await queryRunner.query(`DROP INDEX "public"."IDX_share_links_owner_sent"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_share_links_owner_sent_email"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_share_links_owner_message"`, + ); + await queryRunner.query(`DROP INDEX "public"."UQ_share_links_token_hash"`); + await queryRunner.query(`DROP TABLE "share_links"`); + } +} diff --git a/backend/src/migrations/1793358223047-AddFileCompletionStartedAt.ts b/backend/src/migrations/1793358223047-AddFileCompletionStartedAt.ts new file mode 100644 index 0000000..b1f65d1 --- /dev/null +++ b/backend/src/migrations/1793358223047-AddFileCompletionStartedAt.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 대용량 첨부 — 끝내기를 시작한 때 (11단계 리뷰 L4, 2026-10-30). + * - stored_files.completion_started_at: 끝내는 중(completing)일 때만 값이 있다. 정리 작업은 이것이 오래된 끝내기만 멈춘 것으로 되살린다 + * (마지막 조각의 때로 고르면 방금 시작한 끝내기를 되돌릴 수 있었다) + * 빈 값을 허용하는 칸 하나를 더한다 — 기본값이 없어 표를 다시 쓰지 않는다(곧 끝난다). 기존 행은 null 이고, 정리 작업은 그때 마지막 조각 · 만든 때로 본다. + * (자동 생성: npm run migration:generate — 엔티티는 modules/file-storage/stored-file.entity.ts) + */ + +export class AddFileCompletionStartedAt1793358223047 implements MigrationInterface { + name = 'AddFileCompletionStartedAt1793358223047'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "stored_files" ADD "completion_started_at" TIMESTAMP WITH TIME ZONE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "stored_files" DROP COLUMN "completion_started_at"`, + ); + } +} diff --git a/backend/src/modules/file-sharing/dto/share-link.dto.ts b/backend/src/modules/file-sharing/dto/share-link.dto.ts new file mode 100644 index 0000000..f6a2c72 --- /dev/null +++ b/backend/src/modules/file-sharing/dto/share-link.dto.ts @@ -0,0 +1,221 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from 'class-validator'; + +import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator'; +import { MaxCodeUnits } from '@/common/decorators/code-units.decorator'; +import { PageInfoDto } from '@/common/dto/pagination.dto'; +import type { + PublicLink, + PublicLinkFile, + SharedFile, + SharedFileStatus, + ShareLinkState, + ShareLinkStatus, +} from '../file-sharing.types'; + +/** 인용 속 링크를 한 번에 묻는 수 · 주소 길이 (계약 4절 ShareLinkLookupRequest) */ +export const MAX_SHARE_LINK_LOOKUP_URLS = 20; +export const MAX_SHARE_LINK_URL_LENGTH = 300; + +const SHARED_FILE_STATUSES: SharedFileStatus[] = [ + 'available', + 'unscannable', + 'scanning', + 'infected', + 'limit-reached', + 'revoked', + 'expired', + 'missing', +]; +const LINK_STATUSES: ShareLinkStatus[] = ['active', 'expired', 'revoked']; + +/** POST /api/share-link-lookups — 인용 속 공개 화면 주소 */ +export class ShareLinkLookupRequestDto { + @ApiProperty({ + description: `본문(인용)에서 찾은 공개 화면 주소 — 1~${MAX_SHARE_LINK_LOOKUP_URLS}개, 겹치지 않게, 한 개 ${MAX_SHARE_LINK_URL_LENGTH}자까지. 주소(토큰)를 쿼리에 싣지 않으려고 본문으로 받는다`, + type: [String], + example: [ + 'https://files.arasystem.it.kr/s/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCd', + ], + }) + // 형식 → 개수 → 겹침 → 항목 (적은 차례대로 돈다) + @applyDecorators( + IsArray({ message: '링크 목록 형식이 올바르지 않습니다.' }), + ArrayMinSize(1, { message: '확인할 링크가 없습니다.' }), + ArrayMaxSize(MAX_SHARE_LINK_LOOKUP_URLS, { + message: `링크는 한 번에 ${MAX_SHARE_LINK_LOOKUP_URLS}개까지 확인합니다.`, + }), + ArrayDistinct({ message: '같은 링크를 두 번 보냈습니다.' }), + IsString({ each: true, message: '링크 형식이 올바르지 않습니다.' }), + MaxCodeUnits(MAX_SHARE_LINK_URL_LENGTH, { + each: true, + message: `링크는 ${MAX_SHARE_LINK_URL_LENGTH}자 이하여야 합니다.`, + }), + ) + urls!: string[]; +} + +export class ShareLinkStateDto implements ShareLinkState { + @ApiProperty({ description: '보낸 그대로의 주소' }) + url!: string; + + @ApiProperty({ + description: + 'active 받을 수 있는 기간 · expired 기간 지남 · revoked 공유 멈춤 · unknown 우리 링크 모양이 아님 · 모르는 · 보내기를 취소한 · 기록을 지운 링크', + enum: [...LINK_STATUSES, 'unknown'], + }) + status!: ShareLinkStatus | 'unknown'; + + @ApiProperty({ + description: '끝나는 때 — unknown 이면 null', + nullable: true, + type: String, + }) + expiresAt!: string | null; +} + +export class ShareLinkLookupResultDto { + @ApiProperty({ description: '요청 차례 그대로', type: [ShareLinkStateDto] }) + links!: ShareLinkStateDto[]; +} + +export class SharedFileDto implements SharedFile { + @ApiProperty({ description: '파일 id' }) + id!: string; + + @ApiProperty({ description: '이름 (보낼 때의 이름)' }) + name!: string; + + @ApiProperty({ description: '바이트' }) + size!: number; + + @ApiProperty({ description: '파일 상태', enum: SHARED_FILE_STATUSES }) + status!: SharedFileStatus; + + @ApiProperty({ + description: + '받은 수 — 0번째 바이트부터 받기 시작한 수 (누가 받았는지는 남기지 않는다)', + }) + downloadCount!: number; + + @ApiProperty({ + description: '마지막으로 받은 때', + nullable: true, + type: String, + }) + lastDownloadedAt!: string | null; +} + +export class ShareLinkDto { + @ApiProperty({ description: '링크 id' }) + id!: string; + + @ApiProperty({ + description: + '보낸 사본의 메일 id — [보낸 메일 보기]. 보낸 결과를 모르면 null', + nullable: true, + type: String, + }) + sentEmailId!: string | null; + + @ApiProperty({ + description: + '보낸 메일 제목 — 메일 서버에서 읽는다 (지웠거나 읽지 못하면 null)', + nullable: true, + type: String, + }) + subject!: string | null; + + @ApiProperty({ description: '보낸 때 (예약은 보낼 시각)' }) + sentAt!: string; + + @ApiProperty({ description: '끝나는 때' }) + expiresAt!: string; + + @ApiProperty({ + description: 'active 사용 중 · expired 기간 지남 · revoked 끊음', + enum: LINK_STATUSES, + }) + status!: ShareLinkStatus; + + @ApiProperty({ + description: '끊은 때 (보낸 사람 · 운영) — revoked 일 때만 값이 있다', + nullable: true, + type: String, + example: '2026-10-22T05:12:00.000Z', + }) + revokedAt!: string | null; + + @ApiProperty({ + description: '링크의 파일 (보낸 차례)', + type: [SharedFileDto], + }) + files!: SharedFileDto[]; +} + +export class ShareLinkPageDto { + @ApiProperty({ + description: '내가 만든 링크 — 보낸 때 최근부터 (취소한 링크는 빠진다)', + type: [ShareLinkDto], + }) + items!: ShareLinkDto[]; + + @ApiProperty({ description: '페이지 정보 (total 포함)', type: PageInfoDto }) + pageInfo!: PageInfoDto; +} + +export class PublicLinkSenderDto { + @ApiProperty({ description: '보낸 사람 이름', nullable: true, type: String }) + name!: string | null; + + @ApiProperty({ description: '보낸 사람 주소' }) + email!: string; +} + +export class PublicLinkFileDto implements PublicLinkFile { + @ApiProperty({ description: '파일 id — 받기 경로에 쓴다' }) + id!: string; + + @ApiProperty({ description: '이름' }) + name!: string; + + @ApiProperty({ description: '바이트' }) + size!: number; + + @ApiProperty({ + description: '파일 상태', + enum: SHARED_FILE_STATUSES.filter( + (status) => status !== 'revoked' && status !== 'expired', + ), + }) + status!: PublicLinkFile['status']; +} + +export class PublicLinkDto implements PublicLink { + @ApiProperty({ description: '링크 상태', enum: LINK_STATUSES }) + status!: ShareLinkStatus; + + @ApiProperty({ + description: + '보낼 때 로그인한 계정의 이름 · 주소 — 보낸 사람이 꾸밀 수 없다', + type: PublicLinkSenderDto, + }) + sender!: PublicLinkSenderDto; + + @ApiProperty({ description: '보낸 때' }) + sentAt!: string; + + @ApiProperty({ description: '끝나는 때' }) + expiresAt!: string; + + @ApiProperty({ + description: + 'active 일 때만 — 끝난 링크는 빈 배열 (파일 이름도 보이지 않는다)', + type: [PublicLinkFileDto], + }) + files!: PublicLinkFileDto[]; + + @ApiProperty({ description: '신고 · 문의 주소' }) + reportAddress!: string; +} diff --git a/backend/src/modules/file-sharing/file-sharing.cleanup.ts b/backend/src/modules/file-sharing/file-sharing.cleanup.ts new file mode 100644 index 0000000..a23e6a9 --- /dev/null +++ b/backend/src/modules/file-sharing/file-sharing.cleanup.ts @@ -0,0 +1,38 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { FileSharingRepository } from './file-sharing.repository'; + +/** 끝나거나 끊기거나 취소된 링크 기록을 남기는 기간 — 그동안 공개 화면은 "기간 지남" · "공유 멈춤"이고 목록에 남는다 (계약 4절) */ +export const ENDED_SHARE_LINK_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; + +/** + * 링크 기록 정리 — 하루에 한 번. 끝나거나(기간) 끊기거나 취소된 지 90일이 지난 링크를 지운다 → 그 뒤는 "없는 링크"(404), 목록에서 사라진다. + * 파일의 바이트 · 기록은 파일 모듈의 정리 작업이 따로 지운다 (링크가 끝나면 파일의 보관 기간도 끝난다). + * ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 일을 한다 (결과는 같지만 낭비). + */ +@Injectable() +export class FileSharingCleanup { + private readonly logger = new Logger(FileSharingCleanup.name); + + constructor(private readonly repository: FileSharingRepository) {} + + @Cron(CronExpression.EVERY_DAY_AT_5AM, { name: 'share-link-cleanup' }) + async purgeEndedLinks(now: Date = new Date()): Promise { + try { + const removed = await this.repository.deleteEndedBefore( + new Date(now.getTime() - ENDED_SHARE_LINK_RETENTION_MS), + ); + // 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md) + if (removed > 0) + this.logger.debug(`끝난 링크 ${removed}개를 지웠습니다.`); + return removed; + } catch (error) { + // 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다 + this.logger.error( + `[SYS_001] 끝난 링크 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`, + ); + return 0; + } + } +} diff --git a/backend/src/modules/file-sharing/file-sharing.module.ts b/backend/src/modules/file-sharing/file-sharing.module.ts new file mode 100644 index 0000000..ee56109 --- /dev/null +++ b/backend/src/modules/file-sharing/file-sharing.module.ts @@ -0,0 +1,43 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { FileStorageModule } from '@/modules/file-storage/file-storage.module'; +import { FileSharingCleanup } from './file-sharing.cleanup'; +import { FileSharingRepository } from './file-sharing.repository'; +import { FileSharingService } from './file-sharing.service'; +import { + FILE_SHARING_SETTINGS, + type FileSharingSettings, + buildFileSharingSettings, +} from './file-sharing.settings'; +import { PublicLinkController } from './public-link.controller'; +import { ShareLinkFileEntity } from './share-link-file.entity'; +import { ShareLinkLookupController } from './share-link-lookup.controller'; +import { ShareLinkEntity } from './share-link.entity'; + +/** + * 공유 링크 모듈 (11단계 대용량 첨부 — 계약 4절 "공개 내려받기" · "인용 속 링크의 상태") — 로그인 없는 공개 링크 · 공개 내려받기. + * 의존 방향은 메일 → 공유(이 모듈) → 저장(FileStorageModule) 하나다 — 이 모듈은 메일을 가져오지 않는다. + * 보낸 사람의 링크 목록 · 끊기(메일 제목을 함께 보인다)는 메일 모듈의 컨트롤러가 이 서비스를 쓴다. 2부 파일 저장소가 같은 링크를 쓴다. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ShareLinkEntity, ShareLinkFileEntity]), + FileStorageModule, + ], + controllers: [PublicLinkController, ShareLinkLookupController], + providers: [ + { + provide: FILE_SHARING_SETTINGS, + inject: [ConfigService], + useFactory: (config: ConfigService): FileSharingSettings => + buildFileSharingSettings((key) => config.get(key)), + }, + FileSharingRepository, + FileSharingService, + FileSharingCleanup, + ], + exports: [FileSharingService, FILE_SHARING_SETTINGS, FileStorageModule], +}) +export class FileSharingModule {} diff --git a/backend/src/modules/file-sharing/file-sharing.repository.ts b/backend/src/modules/file-sharing/file-sharing.repository.ts new file mode 100644 index 0000000..67c08b2 --- /dev/null +++ b/backend/src/modules/file-sharing/file-sharing.repository.ts @@ -0,0 +1,205 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, type Repository } from 'typeorm'; + +import type { FileLinkRetention } from '@/modules/file-storage/file-storage.types'; +import type { ShareLinkRecord } from './file-sharing.types'; +import { ShareLinkFileEntity } from './share-link-file.entity'; +import { + ShareLinkEntity, + type ShareLinkCancelReason, + type ShareLinkRevoker, +} from './share-link.entity'; + +/** + * 공유 링크 · 링크 파일 표 접근 — 판단(상태 · 누구의 것)은 FileSharingService 가 한다. + */ +@Injectable() +export class FileSharingRepository { + constructor( + @InjectRepository(ShareLinkEntity) + private readonly links: Repository, + @InjectRepository(ShareLinkFileEntity) + private readonly linkFiles: Repository, + private readonly dataSource: DataSource, + ) {} + + /** 링크와 파일을 한 트랜잭션에 넣는다 */ + async insert(record: ShareLinkRecord): Promise { + await this.dataSource.transaction(async (manager) => { + await manager.insert(ShareLinkEntity, record.link); + await manager.insert(ShareLinkFileEntity, record.files); + }); + } + + async findByTokenHashes( + hashes: readonly Buffer[], + ): Promise { + if (hashes.length === 0) return []; + const links = await this.links.findBy({ tokenHash: In([...hashes]) }); + return this.withFiles(links); + } + + async findOwned(owner: string, id: string): Promise { + const link = await this.links.findOneBy({ id, ownerEmail: owner }); + if (!link) return null; + const [record] = await this.withFiles([link]); + return record; + } + + /** 보낸 사본의 메일 id 로 — 가장 늦게 만든 것 (취소한 링크 포함) */ + async findBySentEmail( + owner: string, + sentEmailId: string, + ): Promise { + const link = await this.links.findOne({ + where: { ownerEmail: owner, sentEmailId }, + order: { createdAt: 'DESC' }, + }); + if (!link) return null; + const [record] = await this.withFiles([link]); + return record; + } + + /** 같은 Message-ID 의 발송이 만든 링크 가운데 보낸 메일을 아직 모르는 것 (같은 키의 재시도) */ + findPendingByMessageId( + owner: string, + messageId: string, + ): Promise { + return this.links + .createQueryBuilder('link') + .where('link.owner_email = :owner', { owner }) + .andWhere('link.message_id = :messageId', { messageId }) + .andWhere('link.sent_email_id IS NULL') + .andWhere('link.canceled_at IS NULL') + .getMany(); + } + + /** 내 링크 — 취소한 링크는 빼고 보낸 때 최근부터 */ + async listOwned( + owner: string, + offset: number, + limit: number, + ): Promise<{ records: ShareLinkRecord[]; total: number }> { + const [links, total] = await this.links + .createQueryBuilder('link') + .where('link.owner_email = :owner', { owner }) + .andWhere('link.canceled_at IS NULL') + .orderBy('link.sentAt', 'DESC') + .addOrderBy('link.id', 'DESC') + .offset(offset) + .limit(limit) + .getManyAndCount(); + return { records: await this.withFiles(links), total }; + } + + async markSent(id: string, sentEmailId: string): Promise { + await this.links.update({ id }, { sentEmailId }); + } + + /** 취소한 링크로 — 이미 취소했으면 그대로 (true = 이번에 바꿨다) */ + async cancel( + id: string, + reason: ShareLinkCancelReason, + now: Date, + ): Promise { + const result = await this.links + .createQueryBuilder() + .update(ShareLinkEntity) + .set({ canceledAt: now, cancelReason: reason }) + .where('id = :id', { id }) + .andWhere('canceled_at IS NULL') + .execute(); + return (result.affected ?? 0) > 0; + } + + /** 끊는다 — 끊지 않았고 · 취소하지 않았고 · 기간 안일 때만 (true = 이번에 끊었다) */ + async revoke(id: string, by: ShareLinkRevoker, now: Date): Promise { + const result = await this.links + .createQueryBuilder() + .update(ShareLinkEntity) + .set({ revokedAt: now, revokedBy: by }) + .where('id = :id', { id }) + .andWhere('revoked_at IS NULL') + .andWhere('canceled_at IS NULL') + .andWhere('expires_at >= :now', { now }) + .execute(); + return (result.affected ?? 0) > 0; + } + + /** + * 받은 수를 하나 더한다 — 상한 아래일 때만 (한 문장이라 동시에 받아도 상한을 넘지 않는다). + * @returns 더했으면 true, 상한에 닿았으면 false + */ + async countDownload( + linkId: string, + fileId: string, + limit: number, + now: Date, + ): Promise { + const result = await this.dataSource.query<[unknown[], number]>( + `UPDATE "share_link_files" + SET "download_count" = "download_count" + 1, "last_downloaded_at" = $4 + WHERE "link_id" = $1 AND "file_id" = $2 AND "download_count" < $3 + RETURNING "download_count"`, + [linkId, fileId, limit, now], + ); + return result[0].length > 0; + } + + /** + * 파일마다의 링크 상태 — 취소하지 않은 링크의 가장 늦게 만든 때 · 끊지 않은 링크의 가장 늦은 끝 (파일 모듈의 보관 기간 계산). + * 링크가 없는 파일도 결과에 든다 (둘 다 null) + */ + async retentionOf(fileIds: readonly string[]): Promise { + if (fileIds.length === 0) return []; + const rows = await this.dataSource.query< + { fileId: string; linkedAt: Date | null; linkedUntil: Date | null }[] + >( + `SELECT f."file_id" AS "fileId", + MAX(l."created_at") FILTER (WHERE l."canceled_at" IS NULL) AS "linkedAt", + MAX(l."expires_at") FILTER (WHERE l."canceled_at" IS NULL AND l."revoked_at" IS NULL) AS "linkedUntil" + FROM "share_link_files" f + JOIN "share_links" l ON l."id" = f."link_id" + WHERE f."file_id" = ANY($1::uuid[]) + GROUP BY f."file_id"`, + [[...fileIds]], + ); + const found = new Map(rows.map((row) => [row.fileId, row])); + return [...new Set(fileIds)].map((fileId) => ({ + fileId, + linkedAt: found.get(fileId)?.linkedAt ?? null, + linkedUntil: found.get(fileId)?.linkedUntil ?? null, + })); + } + + /** + * 끝나거나(기간) 끊기거나 취소된 지 오래된 링크 기록을 지운다 (정리 작업 — 링크 파일은 함께 지워진다). + * @returns 지운 수 + */ + async deleteEndedBefore(cutoff: Date): Promise { + const result = await this.links + .createQueryBuilder() + .delete() + .from(ShareLinkEntity) + .where('LEAST(canceled_at, revoked_at, expires_at) < :cutoff', { + cutoff, + }) + .execute(); + return result.affected ?? 0; + } + + private async withFiles( + links: readonly ShareLinkEntity[], + ): Promise { + if (links.length === 0) return []; + const files = await this.linkFiles.find({ + where: { linkId: In(links.map((link) => link.id)) }, + order: { position: 'ASC' }, + }); + return links.map((link) => ({ + link, + files: files.filter((file) => file.linkId === link.id), + })); + } +} diff --git a/backend/src/modules/file-sharing/file-sharing.service.spec.ts b/backend/src/modules/file-sharing/file-sharing.service.spec.ts new file mode 100644 index 0000000..4fe6cfc --- /dev/null +++ b/backend/src/modules/file-sharing/file-sharing.service.spec.ts @@ -0,0 +1,613 @@ +import { Logger } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { PassThrough, Readable } from 'stream'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + FileContentMissingError, + type FileStorageService, +} from '@/modules/file-storage/file-storage.service'; +import type { + FileLinkRetention, + StoredFileView, +} from '@/modules/file-storage/file-storage.types'; +import type { FileSharingRepository } from './file-sharing.repository'; +import { FileSharingService } from './file-sharing.service'; +import type { FileSharingSettings } from './file-sharing.settings'; +import type { ShareLinkRecord } from './file-sharing.types'; +import { hashShareToken, shareUrl } from './share-token'; + +const ORIGIN = 'https://files.arasystem.test'; +const ARA = 'ara.kim@arasystem.test'; +const FILE_A = '0f8fad5b-d9cb-469f-a165-70867728950e'; +const FILE_B = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; +const CONTENT = Buffer.from('0123456789'); + +/** 링크 표 대역 — 해시로 찾기 · 원자적 받은 수 · 취소 · 끊기를 메모리에서 */ +class FakeRepository { + readonly records: ShareLinkRecord[] = []; + + insert(record: ShareLinkRecord) { + this.records.push(record); + return Promise.resolve(); + } + + findByTokenHashes(hashes: readonly Buffer[]) { + return Promise.resolve( + this.records.filter((record) => + hashes.some((hash) => hash.equals(record.link.tokenHash)), + ), + ); + } + + findOwned(owner: string, id: string) { + return Promise.resolve( + this.records.find( + (record) => record.link.id === id && record.link.ownerEmail === owner, + ) ?? null, + ); + } + + findBySentEmail(owner: string, sentEmailId: string) { + return Promise.resolve( + this.records.find( + (record) => + record.link.ownerEmail === owner && + record.link.sentEmailId === sentEmailId, + ) ?? null, + ); + } + + findPendingByMessageId(owner: string, messageId: string) { + return Promise.resolve( + this.records + .map((record) => record.link) + .filter( + (link) => + link.ownerEmail === owner && + link.messageId === messageId && + link.sentEmailId === null && + link.canceledAt === null, + ), + ); + } + + listOwned(owner: string, offset: number, limit: number) { + const all = this.records + .filter( + (record) => + record.link.ownerEmail === owner && record.link.canceledAt === null, + ) + .sort((a, b) => b.link.sentAt.getTime() - a.link.sentAt.getTime()); + return Promise.resolve({ + records: all.slice(offset, offset + limit), + total: all.length, + }); + } + + markSent(id: string, sentEmailId: string) { + const record = this.records.find((item) => item.link.id === id); + if (record) record.link.sentEmailId = sentEmailId; + return Promise.resolve(); + } + + cancel(id: string, reason: 'send-failed' | 'send-canceled', now: Date) { + const link = this.records.find((item) => item.link.id === id)?.link; + if (!link || link.canceledAt !== null) return Promise.resolve(false); + link.canceledAt = now; + link.cancelReason = reason; + return Promise.resolve(true); + } + + revoke(id: string, by: 'owner', now: Date) { + const link = this.records.find((item) => item.link.id === id)?.link; + if ( + !link || + link.revokedAt !== null || + link.canceledAt !== null || + link.expiresAt < now + ) { + return Promise.resolve(false); + } + link.revokedAt = now; + link.revokedBy = by; + return Promise.resolve(true); + } + + countDownload(linkId: string, fileId: string, limit: number, now: Date) { + const file = this.records + .find((item) => item.link.id === linkId) + ?.files.find((item) => item.fileId === fileId); + if (!file || file.downloadCount >= limit) return Promise.resolve(false); + file.downloadCount += 1; + file.lastDownloadedAt = now; + return Promise.resolve(true); + } + + retentionOf(fileIds: readonly string[]): Promise { + return Promise.resolve( + fileIds.map((fileId) => { + const links = this.records + .filter((record) => + record.files.some((file) => file.fileId === fileId), + ) + .map((record) => record.link); + const live = links.filter((link) => link.canceledAt === null); + return { + fileId, + linkedAt: live.length > 0 ? live[0].createdAt : null, + linkedUntil: + live.filter((link) => link.revokedAt === null)[0]?.expiresAt ?? + null, + }; + }), + ); + } +} + +/** 응답 대역 — 흘려보낸 바이트 · 상태 · 머리글을 모은다 */ +class FakeResponse extends PassThrough { + statusCode = 200; + readonly headers = new Map(); + readonly received: Buffer[] = []; + + constructor() { + super(); + this.on('data', (part: Buffer) => this.received.push(part)); + } + + status(code: number): this { + this.statusCode = code; + return this; + } + + setHeader(name: string, value: string): this { + this.headers.set(name.toLowerCase(), value); + return this; + } + + get body(): string { + return Buffer.concat(this.received).toString(); + } +} + +const view = (overrides: Partial = {}): StoredFileView => ({ + id: FILE_A, + ownerEmail: ARA, + name: '설계도.dwg', + size: CONTENT.length, + state: 'ready', + removedReason: null, + scanStatus: 'clean', + completedAt: new Date('2026-10-30T03:00:00.000Z'), + expiresAt: new Date(Date.now() + 86_400_000), + alive: true, + bytesAvailable: true, + ...overrides, +}); + +function setup(settings: Partial = {}) { + const repository = new FakeRepository(); + const views = new Map([ + [FILE_A, view()], + [FILE_B, view({ id: FILE_B, name: '영상.mp4', scanStatus: 'pending' })], + ]); + const files = { + limits: { maxFilesPerMessage: 10, retentionDays: 30 }, + views: jest.fn((ids: readonly string[]) => + Promise.resolve( + new Map( + ids.flatMap((id) => (views.has(id) ? [[id, views.get(id)]] : [])), + ), + ), + ), + applyLinkRetention: jest.fn().mockResolvedValue(undefined), + openContent: jest.fn( + (_id: string, range?: { start: number; end: number }) => + Promise.resolve( + Readable.from([ + range ? CONTENT.subarray(range.start, range.end + 1) : CONTENT, + ]), + ), + ), + }; + const service = new FileSharingService( + repository as unknown as FileSharingRepository, + { + publicOrigin: ORIGIN, + reportAddress: 'postmaster@arasystem.test', + downloadLimit: 2, + dedicatedPublicHost: null, + ...settings, + }, + files as unknown as FileStorageService, + ); + return { service, repository, files, views }; +} + +async function createLink(service: FileSharingService) { + return service.createMailLink({ + ownerEmail: 'Ara.Kim@arasystem.test', + senderName: '김아라', + files: [ + { id: FILE_A, name: '설계도.dwg', size: CONTENT.length }, + { id: FILE_B, name: '영상.mp4', size: CONTENT.length }, + ], + sentAt: new Date(), + messageId: 'ara.1@arasystem.test', + }); +} + +const request = (method: string, headers: Record = {}) => + ({ method, headers }) as unknown as Request; + +async function serve( + service: FileSharingService, + record: ShareLinkRecord, + fileId: string, + req: Request, + count = true, +) { + const res = new FakeResponse(); + await service.serveFile(req, res as unknown as Response, record, fileId, { + count, + }); + return res; +} + +async function codeOf(run: () => Promise): Promise { + try { + await run(); + return null; + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } +} + +describe('FileSharingService — 공유 링크 · 공개 내려받기 (11단계)', () => { + beforeEach(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + }); + afterEach(() => jest.restoreAllMocks()); + + it('링크를 만든다 — 토큰은 해시만, 끝은 보낸 날(한국) + 30일의 끝, 보낸 사람은 소문자 주소, 파일의 보관 기간을 다시 센다', async () => { + const { service, repository, files } = setup(); + const created = await createLink(service); + expect(created.url).toBe(shareUrl(ORIGIN, created.token)); + const [stored] = repository.records; + expect(stored.link.tokenHash).toEqual(hashShareToken(created.token)); + expect(stored.link.ownerEmail).toBe(ARA); + expect(stored.link.expiresAt.getUTCHours()).toBe(14); + expect(stored.files.map((file) => [file.fileId, file.position])).toEqual([ + [FILE_A, 0], + [FILE_B, 1], + ]); + expect(files.applyLinkRetention).toHaveBeenCalledWith([ + expect.objectContaining({ fileId: FILE_A }), + expect.objectContaining({ fileId: FILE_B }), + ]); + }); + + it('공개 링크 정보 — 보낸 사람 · 끝 · 파일 상태 · 신고 주소. 끊으면 파일 없이 revoked, 취소 · 모르는 링크는 404', async () => { + const { service } = setup(); + const { token, record } = await createLink(service); + const active = await service.publicLink(token); + expect(active).toMatchObject({ + status: 'active', + sender: { name: '김아라', email: ARA }, + reportAddress: 'postmaster@arasystem.test', + }); + expect(active.files.map((file) => file.status)).toEqual([ + 'available', + 'scanning', + ]); + + await service.revoke(ARA, record.link.id); + const revoked = await service.publicLink(token); + expect(revoked).toMatchObject({ status: 'revoked', files: [] }); + + expect(await codeOf(() => service.publicLink('x'.repeat(43)))).toBe( + 'RES_001', + ); + expect(await codeOf(() => service.publicLink('짧음'))).toBe('RES_001'); + await service.cancel(record, 'send-failed'); + expect(await codeOf(() => service.publicLink(token))).toBe('RES_001'); + }); + + describe('파일 응답 (Range · HEAD · 횟수)', () => { + it('전체 200 — 내려받기 머리글(형식 · 이름 · nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => { + const { service } = setup(); + const { record } = await createLink(service); + const res = await serve(service, record, FILE_A, request('GET')); + expect(res.statusCode).toBe(200); + expect(res.body).toBe('0123456789'); + expect(Object.fromEntries(res.headers)).toMatchObject({ + 'content-type': 'application/octet-stream', + 'x-content-type-options': 'nosniff', + 'content-security-policy': "default-src 'none'; sandbox", + 'cache-control': 'private, no-store', + 'referrer-policy': 'no-referrer', + 'x-robots-tag': 'noindex', + 'accept-ranges': 'bytes', + etag: `"${FILE_A}"`, + 'content-length': '10', + 'x-accel-buffering': 'no', + }); + expect(res.headers.get('content-disposition')).toContain('attachment'); + expect(record.files[0].downloadCount).toBe(1); + }); + + it('범위 하나 206 (0번부터면 센다 · 이어 받기는 세지 않는다) · 처음이 크기 이상이면 416 · HEAD 는 머리글만 세지 않는다', async () => { + const { service, files } = setup(); + const { record } = await createLink(service); + + const resumed = await serve( + service, + record, + FILE_A, + request('GET', { range: 'bytes=4-' }), + ); + expect(resumed.statusCode).toBe(206); + expect(resumed.body).toBe('456789'); + expect(resumed.headers.get('content-range')).toBe('bytes 4-9/10'); + expect(record.files[0].downloadCount).toBe(0); + + const head = await serve( + service, + record, + FILE_A, + request('HEAD', { range: 'bytes=0-1' }), + ); + expect(head.statusCode).toBe(206); + expect(head.headers.get('content-length')).toBe('2'); + expect(head.body).toBe(''); + expect(files.openContent).toHaveBeenCalledTimes(1); + + const unsatisfiable = await serve( + service, + record, + FILE_A, + request('GET', { range: 'bytes=10-' }), + ); + expect(unsatisfiable.statusCode).toBe(416); + expect(unsatisfiable.headers.get('content-range')).toBe('bytes */10'); + expect(record.files[0].downloadCount).toBe(0); + + const fromStart = await serve( + service, + record, + FILE_A, + request('GET', { range: 'bytes=0-3' }), + ); + expect(fromStart.body).toBe('0123'); + expect(record.files[0].downloadCount).toBe(1); + }); + + it('상한에 닿으면 410 FILE_007 (이어 받기도) · 링크를 만든 사람이 우리 웹메일로 받으면 세지 않는다', async () => { + const { service } = setup(); + const { record } = await createLink(service); + await serve(service, record, FILE_A, request('GET'), false); + expect(record.files[0].downloadCount).toBe(0); + await serve(service, record, FILE_A, request('GET')); + await serve(service, record, FILE_A, request('GET')); + expect( + await codeOf(() => serve(service, record, FILE_A, request('GET'))), + ).toBe('FILE_007'); + expect( + await codeOf(() => + serve(service, record, FILE_A, request('GET', { range: 'bytes=5-' })), + ), + ).toBe('FILE_007'); + }); + + it('검사 중 409 FILE_008 · 위험 · 찾을 수 없음 · 끊음 410 FILE_007 · 링크에 없는 파일 404 — 머리글을 쓰기 전에', async () => { + const { service, views, files } = setup(); + const { record } = await createLink(service); + expect( + await codeOf(() => serve(service, record, FILE_B, request('GET'))), + ).toBe('FILE_008'); + + views.set( + FILE_B, + view({ id: FILE_B, scanStatus: 'infected', bytesAvailable: false }), + ); + expect( + await codeOf(() => serve(service, record, FILE_B, request('GET'))), + ).toBe('FILE_007'); + + files.openContent.mockRejectedValueOnce( + new FileContentMissingError(FILE_A), + ); + expect( + await codeOf(() => serve(service, record, FILE_A, request('GET'))), + ).toBe('FILE_007'); + + expect( + await codeOf(() => + serve( + service, + record, + '11111111-2222-4333-8444-555555555555', + request('GET'), + ), + ), + ).toBe('RES_001'); + + await service.revoke(ARA, record.link.id); + expect( + await codeOf(() => serve(service, record, FILE_A, request('GET'))), + ).toBe('FILE_007'); + }); + }); + + it('끊기 — 그 링크의 파일 모두, 다시 끊어도 그대로 200 · 남의 링크 404 · 파일 보관 기간을 다시 센다', async () => { + const { service, files } = setup(); + const { record } = await createLink(service); + files.applyLinkRetention.mockClear(); + const revoked = await service.revoke(ARA, record.link.id); + expect(revoked.status).toBe('revoked'); + // 끊은 때 — revoked 일 때만 값이 있다 (다시 끊어도 처음 끊은 때 그대로) + expect(revoked.revokedAt).toBe(record.link.revokedAt?.toISOString()); + expect(typeof revoked.revokedAt).toBe('string'); + expect(revoked.files.map((file) => file.status)).toEqual([ + 'revoked', + 'revoked', + ]); + expect(files.applyLinkRetention).toHaveBeenCalledTimes(1); + + await expect(service.revoke(ARA, record.link.id)).resolves.toMatchObject({ + status: 'revoked', + revokedAt: revoked.revokedAt, + }); + expect(files.applyLinkRetention).toHaveBeenCalledTimes(1); + expect( + await codeOf(() => + service.revoke('doyun.lee@arasystem.test', record.link.id), + ), + ).toBe('RES_001'); + }); + + it('인용 속 링크의 상태 — 우리 링크면 상태 · 끝나는 날만, 모양이 틀린 · 모르는 · 취소한 링크는 unknown (요청 차례 그대로)', async () => { + const { service } = setup(); + const { url, record } = await createLink(service); + const other = await createLink(service); + await service.cancel(other.record, 'send-canceled'); + + const states = await service.lookup([ + `${url}?utm=1`, + 'https://elsewhere.example/s/abc', + other.url, + ]); + expect(states).toEqual([ + { + url: `${url}?utm=1`, + status: 'active', + expiresAt: record.link.expiresAt.toISOString(), + }, + { + url: 'https://elsewhere.example/s/abc', + status: 'unknown', + expiresAt: null, + }, + { url: other.url, status: 'unknown', expiresAt: null }, + ]); + }); + + it('같은 키의 재시도 — 보낸 메일을 모르는 링크에 보낸 사본을 잇는다', async () => { + const { service, repository } = setup(); + await createLink(service); + await service.attachRetry(ARA, 'ara.1@arasystem.test', 'sent-7'); + expect(repository.records[0].link.sentEmailId).toBe('sent-7'); + }); + + it('보낸 링크 목록 — 취소한 링크는 빠지고, 파일마다 상태 · 받은 수', async () => { + const { service } = setup(); + const { record } = await createLink(service); + const canceled = await createLink(service); + await service.cancel(canceled.record, 'send-failed'); + await serve(service, record, FILE_A, request('GET')); + + const page = await service.listOwned(ARA, { limit: 20 }); + expect(page.pageInfo).toMatchObject({ total: 1, hasNext: false }); + expect(page.items[0]).toMatchObject({ + id: record.link.id, + status: 'active', + revokedAt: null, + files: [ + expect.objectContaining({ + id: FILE_A, + status: 'available', + downloadCount: 1, + }), + expect.objectContaining({ + id: FILE_B, + status: 'scanning', + downloadCount: 0, + }), + ], + }); + }); + it('링크를 넣은 뒤 파일 기간을 다시 세지 못하면 그 링크를 취소해 두고 던진다 — 보내지 않을 링크가 살아 있는 링크로 남지 않는다', async () => { + const { service, repository, files } = setup(); + files.applyLinkRetention.mockRejectedValueOnce(new Error('db down')); + + await expect(createLink(service)).rejects.toThrow('db down'); + expect(repository.records).toHaveLength(1); + expect(repository.records[0].link).toMatchObject({ + cancelReason: 'send-failed', + }); + expect(repository.records[0].link.canceledAt).not.toBeNull(); + // 취소한 링크는 목록 · 공개 경로에 없다 + expect((await service.listOwned(ARA, { limit: 20 })).items).toEqual([]); + }); + + it('보낸 사람 이름은 표의 칸(200자 — 코드 포인트)에 맞춰 자른다 — 서로게이트 쌍을 반쪽으로 자르지 않는다', async () => { + const { service, repository } = setup(); + const long = '가'.repeat(199) + '😀' + '나'.repeat(50); + await service.createMailLink({ + ownerEmail: ARA, + senderName: long, + files: [{ id: FILE_A, name: '설계도.dwg', size: CONTENT.length }], + sentAt: new Date(), + messageId: null, + }); + const stored = repository.records[0].link.senderName ?? ''; + expect(Array.from(stored)).toHaveLength(200); + expect(stored.endsWith('😀')).toBe(true); + + await service.createMailLink({ + ownerEmail: ARA, + senderName: '', + files: [{ id: FILE_A, name: '설계도.dwg', size: CONTENT.length }], + sentAt: new Date(), + messageId: null, + }); + expect(repository.records[1].link.senderName).toBeNull(); + }); + + describe('서버 종료', () => { + /** 조건이 맞을 때까지 이벤트 루프를 몇 바퀴 돌린다 (고정 대기 없이) */ + async function settleUntil(condition: () => boolean): Promise { + for (let turn = 0; turn < 100 && !condition(); turn += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + } + + it('흘려보내는 파일 응답을 끊는다 — 오류 로그 없이 처리기가 끝나고, 그 뒤의 받기는 시작하지 않는다(세지 않는다)', async () => { + const error = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + const { service, files } = setup(); + const { record } = await createLink(service); + const slow = new PassThrough(); + files.openContent.mockResolvedValueOnce(slow); + slow.write(CONTENT.subarray(0, 4)); + + const res = new FakeResponse(); + const serving = service.serveFile( + request('GET'), + res as unknown as Response, + record, + FILE_A, + { count: true }, + ); + await settleUntil(() => res.body.length > 0); + expect(res.body).toBe('0123'); + expect(res.destroyed).toBe(false); + + service.beforeApplicationShutdown(); + await serving; + expect(res.destroyed).toBe(true); + expect(slow.destroyed).toBe(true); + expect(error).not.toHaveBeenCalled(); + expect(record.files[0].downloadCount).toBe(1); + + // 종료 중에 온 받기 — 파일을 열지 않고 끊는다 + files.openContent.mockClear(); + const late = await serve(service, record, FILE_A, request('GET')); + expect(late.destroyed).toBe(true); + expect(files.openContent).not.toHaveBeenCalled(); + expect(record.files[0].downloadCount).toBe(1); + }); + }); +}); diff --git a/backend/src/modules/file-sharing/file-sharing.service.ts b/backend/src/modules/file-sharing/file-sharing.service.ts new file mode 100644 index 0000000..947aaf6 --- /dev/null +++ b/backend/src/modules/file-sharing/file-sharing.service.ts @@ -0,0 +1,623 @@ +import { + type BeforeApplicationShutdown, + HttpStatus, + Inject, + Injectable, + Logger, +} from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import type { Request, Response } from 'express'; +import { pipeline } from 'stream/promises'; + +import { + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { + FileContentMissingError, + FileStorageService, +} from '@/modules/file-storage/file-storage.service'; +import type { StoredFileView } from '@/modules/file-storage/file-storage.types'; +import { currentRequestId, traceTag } from '@/shared/logger/request-context'; +import { buildAttachmentDisposition } from '@/shared/utils/content-disposition'; +import { decideRange } from '@/shared/utils/http-range'; +import { kstEndOfDayAfter } from '@/shared/utils/kst-date'; +import { maskEmail } from '@/shared/utils/mask'; +import { isStreamAbortError } from '@/shared/utils/stream-abort'; +import { + decodeOffsetCursor, + encodeOffsetCursor, +} from '@/shared/utils/offset-cursor'; +import { FileSharingRepository } from './file-sharing.repository'; +import { + FILE_SHARING_SETTINGS, + type FileSharingSettings, +} from './file-sharing.settings'; +import type { + CreatedShareLink, + MailLinkInput, + PublicLink, + PublicLinkFile, + SharedFile, + SharedFileStatus, + ShareLinkRecord, + ShareLinkState, + ShareLinkSummary, +} from './file-sharing.types'; +import type { ShareLinkFileEntity } from './share-link-file.entity'; +import { + SENDER_NAME_MAX_LENGTH, + ShareLinkEntity, + type ShareLinkCancelReason, +} from './share-link.entity'; +import { + createShareToken, + hashShareToken, + isShareToken, + shareUrl, + tokenFromShareUrl, +} from './share-token'; +import { + isDownloadable, + sharedFileStatus, + shareLinkStatus, +} from './shared-file-status'; + +/** 보낸 링크 목록의 커서 범위 (shared/utils/offset-cursor.ts) */ +const SHARE_LINKS_SCOPE = 'share-links'; + +/** 파일 응답의 머리글 (계약 4절 "파일 응답") — 형식은 서버가 정한다(늘 내려받기) */ +const FILE_CONTENT_TYPE = 'application/octet-stream'; +const FILE_CSP = "default-src 'none'; sandbox"; +const FILE_CACHE_CONTROL = 'private, no-store'; + +/** 내려받기 — 누가 받나 */ +export interface DownloadOptions { + /** 받은 수에 넣는다 (링크를 만든 사람이 우리 웹메일로 받으면 세지 않는다) */ + readonly count: boolean; +} + +/** 보낸 사람 이름 — 표의 칸 길이(코드 포인트)에 맞춰 자른다 (엔진의 이름 길이는 정해져 있지 않다). 비면 null */ +function senderNameOf(name: string | null): string | null { + if (!name) return null; + const codePoints = Array.from(name); + return codePoints.length > SENDER_NAME_MAX_LENGTH + ? codePoints.slice(0, SENDER_NAME_MAX_LENGTH).join('') + : name; +} + +/** 머리글 하나 (여럿이면 첫 값) */ +function firstHeader(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +/** 받는 쪽이 끊어 흐름이 멈췄다 — 로그를 남기지 않는다 (응답이 이미 닫혔거나 끊김 오류) */ +function isClientAbort(error: unknown, res: Response): boolean { + return res.destroyed || isStreamAbortError(error); +} + +/** + * 공유 링크 · 공개 내려받기 (11단계 — 계약 4절 "공개 내려받기" · "보낸 사람 관리"). 파일은 파일 모듈(FileStorageService)에서 읽는다. + * 메일 모듈이 발송 때 링크를 만들고(이 모듈은 메일을 모른다 — 메일 → 공유 → 저장 한 방향), 2부 파일 저장소가 같은 링크를 쓴다. + * + * 로그(계약 4절 "로그"): 토큰 · 링크 주소 · 파일 이름 · 받는 쪽 IP 는 남기지 않는다 — 링크 · 파일 id 와 개수만, 직원 주소는 가린다. + * 링크 만들기 · 끊기는 log, 내려받기 성공은 남기지 않는다. + */ +@Injectable() +export class FileSharingService implements BeforeApplicationShutdown { + private readonly logger = new Logger(FileSharingService.name); + /** 지금 흘려보내는 파일 응답 (공개 받기 · 우리 웹메일 받기) — 서버 종료 때 끊는다 */ + private readonly streaming = new Set(); + private shuttingDown = false; + + constructor( + private readonly repository: FileSharingRepository, + @Inject(FILE_SHARING_SETTINGS) + private readonly settings: FileSharingSettings, + private readonly files: FileStorageService, + ) {} + + /** 공개 화면의 출처 (덩이 · 덩이 알아보기) */ + get publicOrigin(): string { + return this.settings.publicOrigin; + } + + /** + * 서버 종료(HTTP 서버를 닫기 전) — 흘려보내는 파일 응답을 끊는다. 큰 내려받기가 종료를 붙잡아 종료 유예(30초)를 다 쓰고 강제 종료되지 않게 — + * 브라우저 · 내려받기 관리자는 Range 로 이어 받는다 (실시간 알림의 종료와 같은 결 — mail-events.service.ts). 그 뒤에 온 받기는 시작하지 않는다 + */ + beforeApplicationShutdown(): void { + this.shuttingDown = true; + for (const res of this.streaming) res.destroy(); + this.streaming.clear(); + } + + // ── 메일 발송이 쓰는 것 ─────────────────────────────────────────────────── + + /** + * 링크를 만든다 — 끝나는 때 = 보낸 때(예약은 보낼 시각)의 한국 날짜 + 보관 날 수의 끝. 파일의 보관 기간을 다시 계산한다. + * 토큰 원문은 돌려줄 때만 있다 (DB 에는 SHA-256). 넣은 뒤 파일 기간을 다시 세지 못하면 그 링크를 취소해 두고 던진다 — + * 보내지 않을 링크가 살아 있는 링크로 남지 않게(목록 · 공개 경로의 유령 링크) + */ + async createMailLink(input: MailLinkInput): Promise { + const token = createShareToken(); + const now = new Date(); + const link = new ShareLinkEntity(); + Object.assign(link, { + id: randomUUID(), + tokenHash: hashShareToken(token), + ownerEmail: input.ownerEmail.toLowerCase(), + senderName: senderNameOf(input.senderName), + purpose: 'mail', + messageId: input.messageId, + sentEmailId: null, + sentAt: input.sentAt, + expiresAt: kstEndOfDayAfter( + input.sentAt, + this.files.limits.retentionDays, + ), + createdAt: now, + revokedAt: null, + revokedBy: null, + canceledAt: null, + cancelReason: null, + } satisfies Partial); + const files = input.files.map( + (file, position): ShareLinkFileEntity => ({ + linkId: link.id, + fileId: file.id, + position, + name: file.name, + size: file.size, + downloadCount: 0, + lastDownloadedAt: null, + }), + ); + const record: ShareLinkRecord = { link, files }; + await this.repository.insert(record); + try { + await this.refreshRetention(files.map((file) => file.fileId)); + } catch (error) { + // 파일 기간은 아직 이 링크를 모른다 — 링크만 취소해 두면 앞뒤가 맞는다 (파일 기간을 다시 셀 필요가 없다) + await this.repository + .cancel(link.id, 'send-failed', new Date()) + .catch((cancelError: unknown) => + this.logger.error( + `[SYS_001]${traceTag()} 파일 기간을 세지 못한 링크를 취소하지도 못했습니다 — 보내지 않은 링크가 남습니다: link=${link.id}, reason=${cancelError instanceof Error ? cancelError.message : String(cancelError)}`, + ), + ); + throw error; + } + this.logger.log( + `${traceTag()} 대용량 첨부 링크를 만들었습니다: user=${maskEmail(link.ownerEmail)}, link=${link.id}, 파일 ${files.length}개, 끝=${link.expiresAt.toISOString()}`, + ); + return { record, token, url: shareUrl(this.publicOrigin, token) }; + } + + /** 보내기에 성공했다 — 보낸 사본의 메일 id 를 잇는다 */ + async markSent(linkId: string, sentEmailId: string): Promise { + await this.repository.markSent(linkId, sentEmailId); + } + + /** + * 같은 Idempotency-Key 의 재시도가 처음 메일을 찾았다 — 그 발송이 만든 링크(보낸 메일을 모르는 것)에 보낸 사본을 잇는다. + * 결과를 모르게 끝난 첫 시도의 링크가 이렇게 제자리를 찾는다 (계약 4절 "링크를 만드는 때") + */ + async attachRetry( + owner: string, + messageId: string, + sentEmailId: string, + ): Promise { + const pending = await this.repository.findPendingByMessageId( + owner.toLowerCase(), + messageId, + ); + for (const link of pending) { + await this.repository.markSent(link.id, sentEmailId); + } + } + + /** 링크를 취소한 링크로 둔다(보내지 못함 · 보내기 취소) — 공개 404 · 목록에서 빠진다. 파일의 보관 기간을 다시 계산한다 */ + async cancel( + record: ShareLinkRecord, + reason: ShareLinkCancelReason, + ): Promise { + const changed = await this.repository.cancel( + record.link.id, + reason, + new Date(), + ); + if (!changed) return; + await this.refreshRetention(record.files.map((file) => file.fileId)); + this.logger.log( + `${traceTag()} 대용량 첨부 링크를 취소했습니다(${reason === 'send-failed' ? '보내지 못함' : '보내기 취소'}): user=${maskEmail(record.link.ownerEmail)}, link=${record.link.id}`, + ); + } + + /** 보낸 사본의 메일 id 로 그 링크 (취소한 링크 포함) */ + findBySentEmail( + owner: string, + sentEmailId: string, + ): Promise { + return this.repository.findBySentEmail(owner.toLowerCase(), sentEmailId); + } + + /** + * 토큰으로 링크를 찾는다 — 취소한 링크는 빼고 (메일 읽기 화면의 덩이 알아보기). 돌려준 맵의 키는 토큰 + */ + async findByTokens( + tokens: readonly string[], + ): Promise> { + const valid = [...new Set(tokens)].filter(isShareToken); + const records = await this.repository.findByTokenHashes( + valid.map(hashShareToken), + ); + const byHash = new Map( + records + .filter((record) => record.link.canceledAt === null) + .map((record) => [record.link.tokenHash.toString('hex'), record]), + ); + const result = new Map(); + for (const token of valid) { + const record = byHash.get(hashShareToken(token).toString('hex')); + if (record) result.set(token, record); + } + return result; + } + + /** 링크 파일들의 상태 (메일 읽기 화면 · 보낸 사람 목록) — 링크 id:파일 id 로 찾는다 */ + async fileStatuses( + records: readonly ShareLinkRecord[], + ): Promise> { + const views = await this.files.views( + records.flatMap((record) => record.files.map((file) => file.fileId)), + ); + const now = new Date(); + const result = new Map(); + for (const record of records) { + for (const file of record.files) { + result.set( + `${record.link.id}:${file.fileId}`, + this.statusOf(record, file, views.get(file.fileId), now), + ); + } + } + return result; + } + + // ── 보낸 사람 관리 (/api/share-links — 메일 모듈의 컨트롤러가 제목을 더한다) ────────── + + /** 내 링크 — 취소한 링크는 빼고 보낸 때 최근부터, total 포함 */ + async listOwned( + owner: string, + query: { readonly cursor?: string; readonly limit: number }, + ): Promise> { + let offset = 0; + if (query.cursor !== undefined) { + const reading = decodeOffsetCursor(query.cursor, SHARE_LINKS_SCOPE); + if ('problem' in reading) { + throw new BusinessException('VAL_001', { + context: { reason: `invalid-share-link-cursor:${reading.problem}` }, + }); + } + offset = reading.offset; + } + const { records, total } = await this.repository.listOwned( + owner.toLowerCase(), + offset, + query.limit, + ); + const statuses = await this.fileStatuses(records); + const next = offset + records.length; + const hasNext = next < total; + return toPaginatedResult( + records.map((record) => this.summarize(record, statuses)), + { + hasNext, + nextCursor: hasNext + ? encodeOffsetCursor(next, SHARE_LINKS_SCOPE) + : null, + total, + }, + ); + } + + /** + * 링크 끊기 — 그 링크(메일)의 파일 모두. 되돌릴 수 없다. 이미 끊었거나 기간이 지났으면 바꾸지 않고 지금 상태. + * 남의 링크 · 없는 것 · 취소한 링크는 404 RES_001 + */ + async revoke(owner: string, id: string): Promise { + const ownerEmail = owner.toLowerCase(); + const record = await this.repository.findOwned(ownerEmail, id); + if (!record || record.link.canceledAt !== null) { + throw new BusinessException('RES_001', { + context: { reason: 'share-link-not-found', id }, + }); + } + const now = new Date(); + if (await this.repository.revoke(id, 'owner', now)) { + record.link.revokedAt = now; + record.link.revokedBy = 'owner'; + await this.refreshRetention(record.files.map((file) => file.fileId)); + this.logger.log( + `${traceTag()} 대용량 첨부 링크를 끊었습니다: user=${maskEmail(ownerEmail)}, link=${id}, 파일 ${record.files.length}개`, + ); + } + return this.summarize(record, await this.fileStatuses([record])); + } + + // ── 인용 속 링크의 상태 (POST /api/share-link-lookups) ───────────────────────── + + /** 주소마다 상태 · 끝나는 날만 — 누구의 링크든 같다. 요청 차례 그대로 */ + async lookup(urls: readonly string[]): Promise { + const tokens = urls.map((url) => + tokenFromShareUrl(this.publicOrigin, url, { allowQuery: true }), + ); + const found = await this.findByTokens( + tokens.filter((token): token is string => token !== null), + ); + const now = new Date(); + return urls.map((url, index) => { + const token = tokens[index]; + const record = token === null ? undefined : found.get(token); + if (!record) return { url, status: 'unknown', expiresAt: null }; + return { + url, + status: shareLinkStatus(record.link, now), + expiresAt: record.link.expiresAt.toISOString(), + }; + }); + } + + // ── 공개 경로 (/api/public-links — 로그인 없음) ───────────────────────────── + + /** + * 공개 링크 정보 — 모양이 틀린 · 모르는 · 취소한 · 기록을 지운 링크는 404 RES_001. 끝난 링크는 파일 없이(이름도 보이지 않게) + */ + async publicLink(token: string): Promise { + const record = await this.requirePublicRecord(token); + const now = new Date(); + const status = shareLinkStatus(record.link, now); + let files: PublicLinkFile[] = []; + if (status === 'active') { + const statuses = await this.fileStatuses([record]); + files = record.files.map((file) => ({ + id: file.fileId, + name: file.name, + size: file.size, + status: statuses.get( + `${record.link.id}:${file.fileId}`, + ) as PublicLinkFile['status'], + })); + } + return { + status, + sender: { name: record.link.senderName, email: record.link.ownerEmail }, + sentAt: record.link.sentAt.toISOString(), + expiresAt: record.link.expiresAt.toISOString(), + files, + reportAddress: this.settings.reportAddress, + }; + } + + /** 공개 받기 — 토큰의 링크 (취소 · 모르는 링크는 404) */ + async requirePublicRecord(token: string): Promise { + const record = isShareToken(token) + ? (await this.findByTokens([token])).get(token) + : undefined; + if (!record) { + throw new BusinessException('RES_001', { + context: { reason: 'share-link-unknown' }, + }); + } + return record; + } + + /** + * 파일 하나를 내려준다 (공개 받기 · 우리 웹메일 받기 공용 — 계약 4절 "파일 응답"). + * 실패는 머리글을 쓰기 **전에** 던진다(부르는 쪽의 필터가 표준 실패 형식으로): 링크에 없는 파일 404 RES_001, 검사 중 409 FILE_008, + * 기간 지남 · 끊음 · 위험 · 횟수 초과 · 찾을 수 없음 410 FILE_007. + * 범위: 없으면 200 전체, 하나면 206, 처음이 크기 이상이면 416(본문 없음 — 래핑 예외). HEAD 는 머리글만(파일을 열지 않고 세지 않는다). + * 횟수: GET 이고 0번째 바이트부터 보내는 응답만 — 머리글을 보내기 직전에 원자적으로 더한다(상한이면 410) + */ + async serveFile( + req: Request, + res: Response, + record: ShareLinkRecord, + fileId: string, + options: DownloadOptions, + ): Promise { + const entry = record.files.find((file) => file.fileId === fileId); + if (!entry) { + throw new BusinessException('RES_001', { + context: { reason: 'file-not-in-link' }, + }); + } + const view = (await this.files.views([fileId])).get(fileId); + const now = new Date(); + const status = this.statusOf(record, entry, view, now); + if (status === 'scanning') { + throw new BusinessException('FILE_008', { + context: { reason: 'file-scanning', fileId }, + }); + } + if (!isDownloadable(status) || !view) { + throw this.notDownloadable(status, fileId); + } + + const size = entry.size; + const etag = `"${fileId}"`; + const lastModified = view.completedAt ?? record.link.createdAt; + const decision = decideRange( + firstHeader(req.headers.range), + firstHeader(req.headers['if-range']), + size, + { etag, lastModified }, + ); + if (decision.kind === 'unsatisfiable') { + this.writeFileHeaders(res, entry.name, etag, lastModified); + res.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + res.setHeader('Content-Range', `bytes */${size}`); + res.setHeader('Content-Length', '0'); + res.end(); + return; + } + const range = + decision.kind === 'partial' + ? { start: decision.start, end: decision.end } + : undefined; + const length = range ? range.end - range.start + 1 : size; + const statusCode = range ? HttpStatus.PARTIAL_CONTENT : HttpStatus.OK; + + if (req.method === 'HEAD') { + this.writeFileHeaders(res, entry.name, etag, lastModified); + this.writeLength(res, statusCode, length, range, size); + res.end(); + return; + } + + if (this.shuttingDown) { + // 종료 중 — 시작하지 않는다(세지 않는다). 연결을 끊으면 받는 쪽이 다시 시도한다 + res.destroy(); + return; + } + let stream; + try { + stream = await this.files.openContent(fileId, range); + } catch (error) { + if (error instanceof FileContentMissingError) { + throw this.notDownloadable('missing', fileId); + } + throw error; + } + if (options.count && (range === undefined || range.start === 0)) { + const counted = await this.repository + .countDownload(record.link.id, fileId, this.settings.downloadLimit, now) + .catch((error: unknown) => { + stream.destroy(); + throw error; + }); + if (!counted) { + stream.destroy(); + throw this.notDownloadable('limit-reached', fileId); + } + } + + this.writeFileHeaders(res, entry.name, etag, lastModified); + this.writeLength(res, statusCode, length, range, size); + // 흐름 오류는 처리기가 끝난 뒤에 올 수 있다 — 추적 ID 를 지금 잡아 둔다 + const traceId = currentRequestId() ?? 'unknown'; + this.streaming.add(res); + try { + // 파일을 여는 사이 종료가 시작됐으면 흘려보내지 않는다 (종료 때 끊을 목록에 들기 전이었다) + if (this.shuttingDown) res.destroy(); + await pipeline(stream, res); + } catch (error) { + if (!isClientAbort(error, res)) { + this.logger.error( + `[SYS_001][${traceId}] 대용량 첨부 전송이 멈췄습니다: link=${record.link.id}, file=${fileId}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!res.destroyed) res.destroy(); + } finally { + this.streaming.delete(res); + } + } + + // ── 내부 ──────────────────────────────────────────────────────────────── + + private statusOf( + record: ShareLinkRecord, + file: ShareLinkFileEntity, + view: StoredFileView | undefined, + now: Date, + ): SharedFileStatus { + return sharedFileStatus({ + link: record.link, + file: view, + downloadCount: file.downloadCount, + downloadLimit: this.settings.downloadLimit, + now, + }); + } + + private summarize( + record: ShareLinkRecord, + statuses: Map, + ): ShareLinkSummary { + const now = new Date(); + return { + id: record.link.id, + sentEmailId: record.link.sentEmailId, + sentAt: record.link.sentAt.toISOString(), + expiresAt: record.link.expiresAt.toISOString(), + status: shareLinkStatus(record.link, now), + revokedAt: record.link.revokedAt?.toISOString() ?? null, + files: record.files.map( + (file): SharedFile => ({ + id: file.fileId, + name: file.name, + size: file.size, + status: statuses.get(`${record.link.id}:${file.fileId}`) ?? 'missing', + downloadCount: file.downloadCount, + lastDownloadedAt: file.lastDownloadedAt?.toISOString() ?? null, + }), + ), + }; + } + + /** 파일의 링크 상태를 다시 읽어 파일 모듈에 알린다 — 보관 기간이 바뀐다 */ + private async refreshRetention(fileIds: readonly string[]): Promise { + await this.files.applyLinkRetention( + await this.repository.retentionOf(fileIds), + ); + } + + private notDownloadable( + status: SharedFileStatus, + fileId: string, + ): BusinessException { + return new BusinessException('FILE_007', { + context: { reason: `file-${status}`, fileId }, + }); + } + + /** 파일 응답의 머리글 (계약 4절 "파일 응답") — 형식 · 이름 · 검증자 · 캐시 · 버퍼링 */ + private writeFileHeaders( + res: Response, + name: string, + etag: string, + lastModified: Date, + ): void { + res.setHeader('Content-Type', FILE_CONTENT_TYPE); + res.setHeader('Content-Disposition', buildAttachmentDisposition(name)); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', FILE_CSP); + res.setHeader('Cache-Control', FILE_CACHE_CONTROL); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Robots-Tag', 'noindex'); + res.setHeader('Accept-Ranges', 'bytes'); + res.setHeader('ETag', etag); + res.setHeader('Last-Modified', lastModified.toUTCString()); + // 앞단 nginx · 외부 프록시가 이 응답을 임시 파일에 받아 두지 않게 (느린 받는 사람마다 1GB 까지) + res.setHeader('X-Accel-Buffering', 'no'); + } + + private writeLength( + res: Response, + statusCode: number, + length: number, + range: { start: number; end: number } | undefined, + size: number, + ): void { + res.status(statusCode); + res.setHeader('Content-Length', String(length)); + if (range) { + res.setHeader( + 'Content-Range', + `bytes ${range.start}-${range.end}/${size}`, + ); + } + } +} diff --git a/backend/src/modules/file-sharing/file-sharing.settings.ts b/backend/src/modules/file-sharing/file-sharing.settings.ts new file mode 100644 index 0000000..9523759 --- /dev/null +++ b/backend/src/modules/file-sharing/file-sharing.settings.ts @@ -0,0 +1,65 @@ +import { parsePositiveInt } from '@/shared/utils/env'; +import { + DEV_DEFAULT_ORIGINS, + buildAllowedOrigins, + normalizeOrigin, + parseOriginList, +} from '@/shared/utils/request-origin'; + +/** + * 공개 링크 설정 (계약 4절 "공개 호스트" · "환경 변수") — 루트 env 에서 compose 가 넣는다. 검사는 env.validation.ts 가 기동 때 한다. + */ +export const FILE_SHARING_SETTINGS = Symbol('FILE_SHARING_SETTINGS'); + +export interface FileSharingSettings { + /** 공개 화면의 출처(스킴 · 호스트 · 포트 — 끝 `/` 없음) — 링크 · 덩이 · 덩이 알아보기가 쓴다. 운영 `https://files.arasystem.it.kr` */ + readonly publicOrigin: string; + /** 공개 화면의 신고 · 문의 주소 */ + readonly reportAddress: string; + /** 파일당 내려받기 상한 (받기 시작한 수) */ + readonly downloadLimit: number; + /** + * 공개 호스트를 포털과 따로 둘 때 그 호스트 이름(소문자, 포트 없이) — 공개 호스트에서는 공개 API 만 열고, 포털에서는 공개 API 를 닫는다. + * 공개 출처가 포털의 출처(허용 출처 목록)에 들어 있으면(개발 — 같은 주소) null — 둘 다 연다 + */ + readonly dedicatedPublicHost: string | null; +} + +export const DEFAULT_SHARE_DOWNLOAD_LIMIT = 500; +export const SHARE_DOWNLOAD_LIMIT_MAX = 100_000; + +/** env 값을 읽어 설정을 만든다 (순수 함수 — 읽기 함수를 주입받는다) */ +export function buildFileSharingSettings( + read: (key: string) => string | undefined, +): FileSharingSettings { + const configured = read('SHARE_PUBLIC_URL')?.trim(); + // 비운영에서 비우면 CORS_ORIGIN 의 첫 주소(포털과 같은 주소) — 운영은 env 검증이 필수로 막는다 + const publicOrigin = + (configured ? normalizeOrigin(configured) : null) ?? + parseOriginList(read('CORS_ORIGIN')).origins[0] ?? + DEV_DEFAULT_ORIGINS[0]; + + const domain = read('MAIL_DOMAIN')?.trim().toLowerCase(); + const reportAddress = + read('SHARE_REPORT_ADDRESS')?.trim() || + `postmaster@${domain || new URL(publicOrigin).hostname}`; + + // 호스트로 가른다(nginx 는 포트 없는 Host 를 넘긴다) — 포털과 호스트 이름이 같으면(포트만 다른 개발 등) 가를 수 없어 둘 다 연다. + // 운영은 env 검사가 같은 호스트를 막는다 (env.validation.ts) + const hostOf = (origin: string) => + new URL(origin).hostname.toLowerCase().replace(/\.+$/, ''); + const publicHost = hostOf(publicOrigin); + const portalHosts = new Set(buildAllowedOrigins(read).map(hostOf)); + return { + publicOrigin, + reportAddress, + downloadLimit: Math.min( + parsePositiveInt( + read('SHARE_DOWNLOAD_LIMIT'), + DEFAULT_SHARE_DOWNLOAD_LIMIT, + ), + SHARE_DOWNLOAD_LIMIT_MAX, + ), + dedicatedPublicHost: portalHosts.has(publicHost) ? null : publicHost, + }; +} diff --git a/backend/src/modules/file-sharing/file-sharing.types.ts b/backend/src/modules/file-sharing/file-sharing.types.ts new file mode 100644 index 0000000..e8d7cc4 --- /dev/null +++ b/backend/src/modules/file-sharing/file-sharing.types.ts @@ -0,0 +1,108 @@ +import type { ShareLinkFileEntity } from './share-link-file.entity'; +import type { ShareLinkEntity } from './share-link.entity'; + +/** + * 공유 링크 도메인 타입 — 계약 4절 "타입" 과 같은 모양이다 (11단계 대용량 첨부, 2부 파일 저장소의 공유 링크가 같은 표를 쓴다). + */ + +/** 링크 안 파일 하나의 상태 — 받는 쪽에서 본 것 */ +export type SharedFileStatus = + /** 받을 수 있다 (검사에서 깨끗함) */ + | 'available' + /** 받을 수 있다 — "검사하지 못함" */ + | 'unscannable' + /** 검사 중 — 끝나면 받는다 (그동안 받기는 409 FILE_008) */ + | 'scanning' + /** 위험 — 막았다 */ + | 'infected' + /** 이 파일의 내려받기 상한에 닿았다 */ + | 'limit-reached' + /** 보낸 사람(또는 운영)이 링크를 끊었다 */ + | 'revoked' + /** 기간이 지났다 */ + | 'expired' + /** 파일을 찾을 수 없다 (저장소 복원 등 — 드묾) */ + | 'missing'; + +/** 링크의 상태 — 취소한 링크는 없는 링크로 다루므로 여기 없다 */ +export type ShareLinkStatus = 'active' | 'expired' | 'revoked'; + +/** 링크 하나와 그 파일 (보낸 차례) */ +export interface ShareLinkRecord { + readonly link: ShareLinkEntity; + readonly files: ShareLinkFileEntity[]; +} + +/** 링크의 파일 하나 — 보낸 사람이 보는 것 (ShareLink.files) */ +export interface SharedFile { + readonly id: string; + readonly name: string; + readonly size: number; + readonly status: SharedFileStatus; + readonly downloadCount: number; + readonly lastDownloadedAt: string | null; +} + +/** 보낸 사람의 링크 하나 — 메일 제목(subject)은 메일 모듈이 엔진에서 읽어 더한다 (ShareLink) */ +export interface ShareLinkSummary { + readonly id: string; + readonly sentEmailId: string | null; + readonly sentAt: string; + readonly expiresAt: string; + readonly status: ShareLinkStatus; + /** 끊은 때 (보낸 사람 · 운영) — revoked 일 때만 값이 있다 */ + readonly revokedAt: string | null; + readonly files: SharedFile[]; +} + +/** POST /api/share-link-lookups 의 항목 — 누구의 링크든 상태 · 끝나는 날만 */ +export interface ShareLinkState { + readonly url: string; + /** unknown = 우리 링크 모양이 아님 · 모르는 · 보내기를 취소한 · 기록을 지운 링크 */ + readonly status: ShareLinkStatus | 'unknown'; + readonly expiresAt: string | null; +} + +/** 공개 화면의 파일 — 끝난 링크에는 없다 */ +export interface PublicLinkFile { + readonly id: string; + readonly name: string; + readonly size: number; + readonly status: Exclude; +} + +/** GET /api/public-links/{token} — 로그인 없음 */ +export interface PublicLink { + readonly status: ShareLinkStatus; + /** 보낼 때 로그인한 계정의 이름 · 주소 — 보낸 사람이 꾸밀 수 없다 */ + readonly sender: { readonly name: string | null; readonly email: string }; + readonly sentAt: string; + readonly expiresAt: string; + /** active 일 때만 — 끝난 링크는 빈 배열 (파일 이름도 보이지 않는다) */ + readonly files: PublicLinkFile[]; + /** 신고 · 문의 주소 */ + readonly reportAddress: string; +} + +/** 메일 발송이 만드는 링크 — 메일 모듈이 넘긴다 */ +export interface MailLinkInput { + readonly ownerEmail: string; + readonly senderName: string | null; + /** 싣는 파일 (보낸 차례 — 파일 모듈이 확인한 것) */ + readonly files: readonly { + readonly id: string; + readonly name: string; + readonly size: number; + }[]; + /** 보낸 때 (예약은 보낼 시각) — 끝나는 날은 이 날짜로 센다 */ + readonly sentAt: Date; + /** 중복 발송 방지의 Message-ID (있을 때) */ + readonly messageId: string | null; +} + +/** 만든 링크 — 토큰 원문은 이때만 있다(본문 덩이에 넣는다) */ +export interface CreatedShareLink { + readonly record: ShareLinkRecord; + readonly token: string; + readonly url: string; +} diff --git a/backend/src/modules/file-sharing/public-host.spec.ts b/backend/src/modules/file-sharing/public-host.spec.ts new file mode 100644 index 0000000..e5c0672 --- /dev/null +++ b/backend/src/modules/file-sharing/public-host.spec.ts @@ -0,0 +1,403 @@ +import { + request as expressRequest, + type NextFunction, + type Request, + type Response, +} from 'express'; + +import { + createPublicHostMiddleware, + hostnameOf, + normalizeRequestPath, + publicHostAllows, + requestPathViews, +} from './public-host'; + +const FILES = 'files.arasystem.test'; +const PORTAL = 'portal.arasystem.test'; +const TOKEN = 'Ab_-9Ab_-9Ab_-9Ab_-9Ab_-9Ab_-9Ab_-9Ab_-9xyz'; +const LINK = `/api/public-links/${TOKEN}`; +const FILE_ID = '0b6f1f6e-1d2c-4c5b-9a1e-6c9f0a1b2c3d'; +/** 역슬래시 하나 — 요청 대상에 그대로 넣는다 */ +const BS = '\\'; + +/** + * 실제 Express 요청 — 라우터가 쓰는 경로(req.path)를 Express 의 getter(parseurl)로 읽는다. + * 앱의 요청도 이 prototype 을 잇는다 (express 의 app.handle 이 Object.setPrototypeOf(req, app.request)) + */ +const expressRequestOf = ( + url: string, + headers: Record = {}, +): Request => + Object.assign(Object.create(expressRequest) as Request, { url, headers }); + +/** 이 Host · 요청 대상을 열까 — 라우터가 쓰는 경로는 실제 Express 로 읽는다 */ +const allows = ( + host: string | undefined, + url: string, + dedicated: string | null = FILES, +) => + publicHostAllows( + { host, url, routedPath: expressRequestOf(url).path }, + dedicated, + ); + +/** 여러 대상의 판정 — 실패하면 어느 대상인지 보이게 묶는다 */ +const verdictsOf = (host: string | undefined, urls: readonly string[]) => + urls.map((url) => ({ url, allowed: allows(host, url) })); +const expectAll = ( + host: string | undefined, + urls: readonly string[], + allowed: boolean, +) => + expect(verdictsOf(host, urls)).toEqual(urls.map((url) => ({ url, allowed }))); + +describe('publicHostAllows (공개 호스트 막기)', () => { + it('공개 호스트가 포털과 같으면(개발) 늘 연다', () => { + expect(allows('localhost:3000', LINK, null)).toBe(true); + expect(allows('localhost:3000', '/api/messages', null)).toBe(true); + expect(allows('localhost:3000', `http://x${LINK}`, null)).toBe(true); + }); + + it('공개 호스트에서는 공개 링크 API 만 — 포트 · 대소문자는 보지 않는다', () => { + expect(allows(FILES, LINK)).toBe(true); + expect(allows(`${FILES.toUpperCase()}:443`, `${LINK}/files/x`)).toBe(true); + expect(allows(FILES, LINK.toUpperCase())).toBe(true); + expectAll(FILES, ['/api/auth/login', '/api/messages?q=1', '/'], false); + }); + + it('포털 · 그 밖의 호스트에서는 공개 링크 API 를 닫는다', () => { + expect(allows(PORTAL, LINK)).toBe(false); + expect(allows(PORTAL, '/api/messages')).toBe(true); + expect(allows(undefined, LINK)).toBe(false); + expect(allows('[::1]:3000', '/api/health')).toBe(true); + }); + + it('평소 요청은 그대로 지난다 (회귀) — 포털의 API · 화면, 공개 호스트의 공개 링크 정보 · 파일', () => { + expectAll( + PORTAL, + [ + '/', + '/api/auth/me', + '/api/health', + '/api/messages?q=%2Fapi%2Fpublic-links%2Fx&cursor=abc', + // 쿼리의 `#` · `\` · 인코딩한 경로는 경로가 아니다 + '/api/messages?q=#/api/public-links/x', + `/api/messages/M1?view=${BS}api${BS}public-links${BS}x`, + '/api/mail-events', + `/api/file-uploads/${FILE_ID}/chunks/0`, + `/api/share-links/lookup?url=https%3A%2F%2F${FILES}%2Fs%2F${TOKEN}`, + ], + true, + ); + expectAll( + FILES, + [ + LINK, + `${LINK}?x=1`, + `${LINK}/`, + `${LINK}/files/${FILE_ID}`, + `${LINK}/files/${FILE_ID}?download=1`, + ], + true, + ); + }); +}); + +describe('경로의 여러 해석 — 원문 · 라우터(req.path) · WHATWG URL · 퍼센트 인코딩을 푼 모양 (2026-10-30)', () => { + it('requestPathViews — 원문(? 앞) · 라우터가 쓰는 경로 · WHATWG pathname · 인코딩을 한 번 푼 원문', () => { + expect(requestPathViews('/api/x?q=1', '/api/x')).toEqual([ + '/api/x', + '/api/x', + '/api/x', + '/api/x', + ]); + expect( + requestPathViews( + `http://${FILES}/api/public-links/x?y=1`, + '/api/public-links/x', + ), + ).toEqual([ + `http://${FILES}/api/public-links/x`, + '/api/public-links/x', + '/api/public-links/x', + `http://${FILES}/api/public-links/x`, + ]); + expect( + requestPathViews(`/api${BS}public-links${BS}x`, null).slice(2), + ).toEqual(['/api/public-links/x', `/api${BS}public-links${BS}x`]); + expect(requestPathViews('/api%2Fpublic-links/%2e%2e', null)[3]).toBe( + '/api/public-links/..', + ); + // 읽지 못한 모양은 null + expect(requestPathViews('//[/api', '//[/api')[2]).toBeNull(); + }); + + it('라우터는 절대 주소 꼴 · `#` 이 섞인 `\\` 를 원문과 다른 경로로 읽는다 (Express 5 — 막기가 이 모양을 함께 보는 까닭)', () => { + expect(expressRequestOf(`http://${FILES}${LINK}`).path).toBe(LINK); + expect(expressRequestOf(`HTTPS://user@${FILES}:443${LINK}?y=1`).path).toBe( + LINK, + ); + expect(expressRequestOf(`/api${BS}public-links${BS}${TOKEN}#a`).path).toBe( + LINK, + ); + // `#` 이 없으면 `\` 를 그대로 둔다 (parseurl 의 빠른 길) + expect(expressRequestOf(`/api${BS}public-links${BS}${TOKEN}`).path).toBe( + `/api${BS}public-links${BS}${TOKEN}`, + ); + }); + + it('절대 주소 꼴(absolute-form) — 포털에서는 공개 링크 경로면 닫는다(라우터가 그 경로로 보낸다), 공개 호스트는 이 꼴을 받지 않는다', () => { + expectAll( + PORTAL, + [ + `http://${FILES}${LINK}`, + `https://${FILES}${LINK}?y=1`, + `HTTP://${FILES.toUpperCase()}${LINK}`, + `http://user@${FILES}${LINK}`, + `http://${FILES}:80${LINK}/files/${FILE_ID}`, + `http://${PORTAL}/API/Public-Links/${TOKEN}`, + // WHATWG URL 은 못 읽지만(포트가 범위 밖) 라우터는 공개 링크 경로로 읽는다 + `http://${FILES}:99999${LINK}`, + ], + false, + ); + // 공개 링크가 아닌 경로는 포털 그대로 + expect(allows(PORTAL, `http://${PORTAL}/api/messages`)).toBe(true); + + expectAll( + FILES, + [ + `http://${FILES}${LINK}`, + `https://${FILES}${LINK}`, + `http://${PORTAL}/api/auth/me`, + ], + false, + ); + }); + + it('역슬래시 — 포털에서는 어느 해석으로든 공개 링크 경로면 닫고, 공개 호스트에서는 모든 해석이 공개 링크 경로여야 연다', () => { + expectAll( + PORTAL, + [ + `/api${BS}public-links${BS}${TOKEN}#a`, // 라우터가 공개 링크로 보낸다 + `/api${BS}public-links${BS}${TOKEN}`, // WHATWG 가 `/` 로 읽는다 + `/api${BS}public-links${BS}${TOKEN}?#`, + `${BS}api${BS}public-links${BS}${TOKEN}`, + `/api/public-links${BS}${TOKEN}`, + `/api/users/..${BS}public-links/${TOKEN}`, + ], + false, + ); + expectAll( + FILES, + [ + `/api/public-links${BS}${TOKEN}`, + `${LINK}${BS}..${BS}..${BS}auth${BS}me`, + `${BS}api${BS}public-links${BS}${TOKEN}`, + ], + false, + ); + }); + + it('퍼센트 인코딩(%2F · %2e%2e) — 앞단 nginx · WHATWG 가 푸는 모양까지 본다', () => { + expectAll( + PORTAL, + [ + `/api%2Fpublic-links/${TOKEN}`, + `/api%2fpublic-links%2f${TOKEN}`, + `/api/%2e%2e/api/public-links/${TOKEN}`, + `/api/.%2E/api/public-links/${TOKEN}`, + `/api/users/%2E%2E/public-links/${TOKEN}`, + `/%61pi/public-links/${TOKEN}`, + ], + false, + ); + expectAll( + FILES, + [ + `${LINK}/%2e%2e/%2e%2e/auth/me`, + `${LINK}%2F..%2F..%2Fauth%2Fme`, + `/api/public-links/%2E%2E/%2E%2E/auth/me`, + `/api/public-links%2F${TOKEN}`, + ], + false, + ); + }); + + it('대소문자 · 겹친 / · 끝 /', () => { + expectAll( + PORTAL, + [ + `/API/Public-Links/${TOKEN}`, + `//api//public-links/${TOKEN}`, + `/api//public-links/${TOKEN}`, + `${LINK}/`, + `${LINK}//`, + ], + false, + ); + expectAll(FILES, [`/API/PUBLIC-LINKS/${TOKEN}`, `${LINK}/`], true); + // `//api/…` 는 WHATWG 가 api 를 호스트로 읽는다 — 모든 해석이 공개 링크 경로가 아니다 + expectAll( + FILES, + [`//api//public-links/${TOKEN}`, `//api/public-links/${TOKEN}`], + false, + ); + }); + + it('읽지 못한 모양이 있으면 어느 호스트에서도 열지 않는다 (닫힌 쪽)', () => { + expect(allows(PORTAL, '//[/api')).toBe(false); + expect( + publicHostAllows({ host: FILES, url: LINK, routedPath: null }, FILES), + ).toBe(false); + expect( + publicHostAllows( + { host: PORTAL, url: '/api/messages', routedPath: null }, + FILES, + ), + ).toBe(false); + }); +}); + +describe('공개 호스트 막기 — 겹친 / · . · .. 조각과 Host 의 끝 점 · 포트 · 대문자 (nginx 의 정규화에 기대지 않는다 — 2026-10-30 리뷰 L1)', () => { + it('normalizeRequestPath — 쿼리를 떼고 겹친 / 를 하나로, . 조각은 지우고 .. 은 앞 조각을 지운다(소문자)', () => { + expect(normalizeRequestPath('//api//public-links/x?q=1')).toBe( + '/api/public-links/x', + ); + expect(normalizeRequestPath('/api/./public-links/x/')).toBe( + '/api/public-links/x/', + ); + expect(normalizeRequestPath('/API/users/../Public-Links/x')).toBe( + '/api/public-links/x', + ); + expect(normalizeRequestPath('/../../api')).toBe('/api'); + expect(normalizeRequestPath('/')).toBe('/'); + // 퍼센트 인코딩은 풀지 않는다 — 푼 모양은 requestPathViews 가 따로 낸다 + expect(normalizeRequestPath('/api/%2e/public-links/x')).toBe( + '/api/%2e/public-links/x', + ); + }); + + it('hostnameOf — 포트 · 대소문자 · 끝의 점 · 앞뒤 공백을 뗀다', () => { + expect(hostnameOf('FILES.arasystem.test.:443')).toBe(FILES); + expect(hostnameOf('files.arasystem.test..')).toBe(FILES); + expect(hostnameOf(' Files.Arasystem.Test:8443 ')).toBe(FILES); + expect(hostnameOf('[::1]:3000')).toBe('[::1]'); + expect(hostnameOf(undefined)).toBe(''); + }); + + it('포털 호스트에서는 정규화한 모양 가운데 하나라도 공개 링크 경로면 닫는다', () => { + expectAll( + PORTAL, + [ + '//api/public-links/x', + '/api//public-links/x', + '/api/./public-links/x', + '/api/users/../public-links/x', + '/API/./PUBLIC-LINKS/x', + ], + false, + ); + expect(allows(PORTAL, '/api//messages')).toBe(true); + }); + + it('Host 의 끝 점 · 포트 · 대문자 — 공개 호스트 이름이 같으면 같은 판정 (설정 값의 끝 점 · 대문자도)', () => { + for (const host of [ + `${FILES}.`, + `${FILES}..`, + `${FILES.toUpperCase()}.:443`, + `${FILES}:8443`, + ` ${FILES} `, + ]) { + expect({ host, link: allows(host, LINK) }).toEqual({ host, link: true }); + expect({ host, me: allows(host, '/api/auth/me') }).toEqual({ + host, + me: false, + }); + } + expect(allows(FILES.toUpperCase(), LINK, `${FILES}.`)).toBe(true); + expect(allows(FILES, '/api/auth/me', FILES.toUpperCase())).toBe(false); + // 포털 이름에 끝 점 · 포트 · 대문자가 붙어도 포털이다 + for (const host of [ + `${PORTAL}.`, + `${PORTAL.toUpperCase()}:443`, + 'files.arasystem.test.evil', + ]) { + expect({ host, link: allows(host, LINK) }).toEqual({ host, link: false }); + expect({ host, mail: allows(host, '/api/messages') }).toEqual({ + host, + mail: true, + }); + } + }); + + it('공개 호스트에서는 모든 모양이 공개 링크 경로일 때만 연다', () => { + expectAll( + FILES, + [ + `${LINK}/../../users`, + '/api/public-links/../auth/me', + '/api/public-links/x#/../../auth/me', + ], + false, + ); + expect(allows(FILES, `${LINK}/./files/x`)).toBe(true); + }); +}); + +describe('createPublicHostMiddleware', () => { + const setup = () => { + const middleware = createPublicHostMiddleware(FILES); + const next = jest.fn() as NextFunction; + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + const res = { setHeader: jest.fn(), status } as unknown as Response; + return { middleware, next, json, status, res }; + }; + const request = (url: string, host: string) => + expressRequestOf(url, { host, 'x-request-id': 'trace-1' }); + + it('막은 요청은 표준 실패 형식의 404 RES_001 (traceId 포함), 연 요청은 다음으로', () => { + const { middleware, next, json, status, res } = setup(); + + middleware(request(LINK, FILES), res, next); + expect(next).toHaveBeenCalledTimes(1); + + middleware(request('/api/auth/me', FILES), res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(status).toHaveBeenCalledWith(404); + const [body] = json.mock.calls[0] as [ + { success: boolean; error: { code: string; traceId?: string } }, + ]; + expect(body.success).toBe(false); + expect(body.error.code).toBe('RES_001'); + expect(body.error.traceId).toBe('trace-1'); + }); + + it('라우터가 쓰는 경로(req.path)를 함께 본다 — 포털에 절대 주소 꼴 · `\\…#` 로 보낸 공개 링크 경로도 404', () => { + const { middleware, next, status, res } = setup(); + + middleware(request(`http://${FILES}${LINK}`, PORTAL), res, next); + middleware( + request(`/api${BS}public-links${BS}${TOKEN}#a`, PORTAL), + res, + next, + ); + expect(next).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledTimes(2); + + middleware(request('/api/messages', PORTAL), res, next); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('공개 호스트가 포털과 같으면(개발) 경로를 읽지 않고 다음으로', () => { + const next = jest.fn() as NextFunction; + createPublicHostMiddleware(null)( + request(`http://${FILES}${LINK}`, PORTAL), + {} as Response, + next, + ); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/modules/file-sharing/public-host.ts b/backend/src/modules/file-sharing/public-host.ts new file mode 100644 index 0000000..e075483 --- /dev/null +++ b/backend/src/modules/file-sharing/public-host.ts @@ -0,0 +1,162 @@ +import { HttpStatus } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; + +import { getErrorDefinition } from '@/common/errors/error-codes'; +import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware'; + +/** 공개 링크 API 경로 (`/api/public-links/…`) — 로그인 없이 바깥 사람이 부른다 */ +export const PUBLIC_LINKS_ROUTE = 'public-links'; + +const PUBLIC_API_PREFIX = `/api/${PUBLIC_LINKS_ROUTE}/`; + +/** WHATWG URL 로 요청 대상을 읽을 때의 바탕 주소 — 경로만 쓴다 */ +const PATH_BASE_URL = 'http://localhost'; + +/** + * Host 머리글의 호스트 이름 (포트 없이, 소문자, 끝의 점 없이 — `files.arasystem.it.kr.` 도 같은 이름) — `[::1]:3000` 같은 IPv6 도 + */ +export function hostnameOf(host: string | undefined): string { + const value = (host ?? '').trim().toLowerCase(); + if (value.startsWith('[')) { + const end = value.indexOf(']'); + return end < 0 ? value : value.slice(0, end + 1); + } + return value.split(':')[0].replace(/\.+$/, ''); +} + +/** 경로 하나를 견줄 모양으로 — 겹친 `/` 를 하나로, `.` 조각을 지우고 `..` 은 앞 조각을 지운 뒤 소문자로 (쿼리는 떼지 않는다 — 이미 경로다) */ +function normalizePath(path: string): string { + const segments: string[] = []; + for (const segment of path.split('/')) { + if (segment === '' || segment === '.') continue; + if (segment === '..') { + segments.pop(); + continue; + } + segments.push(segment); + } + const trailingSlash = segments.length > 0 && path.endsWith('/') ? '/' : ''; + return `/${segments.join('/')}${trailingSlash}`.toLowerCase(); +} + +/** + * 견줄 경로 (순수 함수) — 쿼리를 떼고, 겹친 `/` 를 하나로, `.` 조각을 지우고 `..` 은 앞 조각을 지운 뒤 소문자로. + * 퍼센트 인코딩은 풀지 않는다(`%2e` 는 `.` 조각이 아니다) — 푼 모양 · `\` 를 `/` 로 읽는 모양은 requestPathViews 가 따로 낸다 + */ +export function normalizeRequestPath(url: string): string { + return normalizePath(url.split('?')[0]); +} + +/** `%XX` 를 한 번 푼다 — 하나씩 글자 하나로(바이트 그대로 — 견줄 글자는 ASCII 뿐이다). 틀린 모양은 그대로 둔다(던지지 않는다) */ +function percentDecodeOnce(path: string): string { + return path.replace(/%([0-9a-f]{2})/gi, (_match, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)), + ); +} + +/** WHATWG URL 의 pathname — 읽지 못하면 null */ +function whatwgPathname(url: string): string | null { + try { + return new URL(url, PATH_BASE_URL).pathname; + } catch { + return null; + } +} + +/** + * 이 요청의 경로를 스택이 읽을 수 있는 모양들 (순수 함수 — 정규화 전). 읽지 못한 모양은 null — 부르는 쪽이 닫는다. + * 1. 원문(`?` 앞) 그대로 + * 2. 라우터가 쓰는 경로 — Express 의 req.path(parseurl), 미들웨어가 넘긴다(routedPath). + * 절대 주소 꼴(`GET http://호스트/경로 HTTP/1.1` — absolute-form)은 스킴 · 호스트를 뗀 경로로 읽고, `#` · 공백 등이 섞이면 Node 의 옛 url.parse 로 읽어 + * `#` 뒤를 버리고 `?` · `#` 앞의 `\` 를 `/` 로 본다 — 원문과 다른 경로로 라우팅한다 + * (2026-10-30 확인 — Express 5.2.1 · router 2.2.0 · parseurl 1.3.3: `GET http://…/api/public-links/x` · `GET /api\public-links\x#a` 가 공개 링크 컨트롤러에 닿는다) + * 3. WHATWG URL 의 pathname — `\` 를 `/` 로, `%2e` 조각을 `.` 으로 읽어 `.` · `..` 을 풀고, `//이름/…` 은 이름을 호스트로 읽는다 + * 4. 퍼센트 인코딩을 한 번 푼 원문 — 앞단 nginx 가 location 을 고르는 모양(`%2F` 도 `/`) + */ +export function requestPathViews( + url: string, + routedPath: string | null, +): readonly (string | null)[] { + const raw = url.split('?')[0]; + return [raw, routedPath, whatwgPathname(url), percentDecodeOnce(raw)]; +} + +/** 공개 호스트 막기가 보는 요청 */ +export interface PublicHostRequest { + /** Host 머리글 */ + readonly host: string | undefined; + /** 요청 줄의 대상 그대로 (req.url — 쿼리 포함, 절대 주소 꼴이면 스킴 · 호스트도) */ + readonly url: string; + /** 라우터가 쓰는 경로 (Express 의 req.path) — 읽지 못했으면 null */ + readonly routedPath: string | null; +} + +/** + * 공개 호스트를 따로 둘 때(계약 4절 "공개 호스트") 이 요청을 열까 — 공개 호스트에서는 공개 링크 API 만, 그 밖의 호스트에서는 공개 링크 API 를 닫는다. + * 공개 호스트가 포털과 같은 주소면(dedicatedPublicHost 가 null — 개발) 늘 연다. + * + * 경로는 스택이 읽을 수 있는 **모든 모양**(requestPathViews — 원문 · 라우터가 쓰는 경로 · WHATWG URL · 퍼센트 인코딩을 푼 모양)을 + * 정규화해(겹친 `/` · `.` · `..` 조각, 대소문자) 본다: 공개 호스트에서는 모든 모양이 공개 링크 경로일 때만 열고, + * 그 밖의 호스트에서는 어느 모양도 공개 링크 경로가 아닐 때만 연다. 읽지 못한 모양이 있으면 어느 쪽에서도 열지 않는다 — 그 모양으로는 어디로 갈지 + * 알 수 없다(닫힌 쪽 — 예: 범위 밖 포트의 절대 주소 꼴은 WHATWG URL 이 못 읽어도 라우터는 공개 링크로 보낸다. 브라우저 · nginx 가 보내는 요청에서는 생기지 않는다). + * 그래서 공개 호스트는 절대 주소 꼴(absolute-form)을 받지 않는다 — 원문이 경로가 아니다. 브라우저는 서버에 그 꼴을 보내지 않고 앞단 nginx 도 경로만 넘긴다. + * 앞단 nginx 는 merge_slashes(기본 켜짐)로 겹친 `/` 를 합치고 `.` · `..` 조각을 풀어 넘기지만, 백엔드는 그것에 기대지 않는다 + * (nginx 를 거치지 않는 요청 · 설정이 바뀐 앞단에서도 같은 판정 — 2026-10-30 리뷰 L1, 절대 주소 꼴 · `\` 는 같은 날 메인 검토) + */ +export function publicHostAllows( + request: PublicHostRequest, + dedicatedPublicHost: string | null, +): boolean { + if (dedicatedPublicHost === null) return true; + const verdicts = requestPathViews(request.url, request.routedPath).map( + (view) => + view === null ? null : normalizePath(view).startsWith(PUBLIC_API_PREFIX), + ); + const onPublicHost = + hostnameOf(request.host) === + dedicatedPublicHost.toLowerCase().replace(/\.+$/, ''); + return onPublicHost + ? verdicts.every((isPublic) => isPublic === true) + : verdicts.every((isPublic) => isPublic === false); +} + +/** 라우터가 쓰는 경로 — Express 의 req.path(parseurl). 읽지 못하면(던지면) null */ +function routedPathOf(req: Request): string | null { + try { + const path: unknown = req.path; + return typeof path === 'string' ? path : null; + } catch { + return null; + } +} + +/** + * 공개 호스트 막기 (express 미들웨어 — app.setup.ts 의 configureApp 이 요청 추적 ID 바로 뒤, 본문 파서 앞에 붙인다). + * 막은 요청은 표준 실패 형식의 404 RES_001 — 로그를 남기지 않는다(봇이 두드리는 주소). 앞단 nginx(호스트마다 server)와 두 겹이다 (release-ops) + */ +export function createPublicHostMiddleware( + dedicatedPublicHost: string | null, +): (req: Request, res: Response, next: NextFunction) => void { + return (req, res, next) => { + if ( + dedicatedPublicHost === null || + publicHostAllows( + { host: req.headers.host, url: req.url, routedPath: routedPathOf(req) }, + dedicatedPublicHost, + ) + ) { + next(); + return; + } + const traceId = req.header(REQUEST_ID_HEADER) ?? undefined; + res.setHeader('Cache-Control', 'no-store'); + res.status(HttpStatus.NOT_FOUND).json({ + success: false, + error: { + code: 'RES_001', + message: getErrorDefinition('RES_001').message, + ...(traceId ? { traceId } : {}), + }, + }); + }; +} diff --git a/backend/src/modules/file-sharing/public-link.controller.ts b/backend/src/modules/file-sharing/public-link.controller.ts new file mode 100644 index 0000000..12e05ba --- /dev/null +++ b/backend/src/modules/file-sharing/public-link.controller.ts @@ -0,0 +1,132 @@ +import { + Controller, + Get, + HttpStatus, + Param, + Req, + Res, + UseFilters, +} from '@nestjs/common'; +import { + ApiOperation, + ApiProduces, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import type { Request, Response } from 'express'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; +import { HttpExceptionFilter } from '@/common/filters/http-exception.filter'; +import { PublicLinkDto } from './dto/share-link.dto'; +import { FileSharingService } from './file-sharing.service'; +import { PUBLIC_LINKS_ROUTE } from './public-host'; + +/** 공개 경로의 요청 수 한도 — IP 마다(IPv6 는 /64) 1분: 링크 정보 60회 · 파일 300회(HEAD 포함 — 내려받기 관리자가 범위를 나눠 부른다) */ +export const PUBLIC_LINK_THROTTLE_TTL_MS = 60_000; +export const PUBLIC_LINK_INFO_THROTTLE_LIMIT = 60; +export const PUBLIC_FILE_THROTTLE_LIMIT = 300; + +/** 공개 경로의 예상된 실패(없는 링크 404 · 검사 중 409 · 끝난 링크 410)는 debug 로만 — 받는 사람 · 봇이 잦게 부른다 */ +const QUIET_PUBLIC_FAILURES = [ + HttpStatus.NOT_FOUND, + HttpStatus.CONFLICT, + HttpStatus.GONE, +]; + +/** 공개 화면 · 링크 정보 응답 — 검색에 걸리지 않게, 나가는 요청에 주소가 실리지 않게 */ +function writePublicHeaders(res: Response): void { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Robots-Tag', 'noindex'); + res.setHeader('Referrer-Policy', 'no-referrer'); +} + +/** + * 공개 내려받기 (11단계 — 계약 4절 "공개 내려받기") — **로그인 없음**. 바깥 거래처 · 내려받기 관리자 · 받는 쪽 보안 장비가 부른다. + * 쿠키를 쓰지 않는다(읽지도 않는다 — 가드 없음). 상태를 바꾸지 않는 GET · HEAD 라 출처 검사도 해당 없다. + * 응답에 실리는 메일 정보는 보낸 사람 이름 · 주소뿐이다 — 제목 · 본문 · 받는 사람 · 메일 id 는 싣지 않는다. + * + * 404 · 409 · 410 은 표준 실패 형식이지만 이 컨트롤러의 필터가 쓴다 — 로그는 debug 만(외부 이미지 중계와 같은 결). 5xx 는 지금처럼 error. + * 공개 호스트를 따로 두면 그 호스트에서는 이 경로만 열린다 (public-host.ts — configureApp) + */ +@ApiTags('Public links') +@UseFilters(new HttpExceptionFilter({ quietStatuses: QUIET_PUBLIC_FAILURES })) +@Controller(PUBLIC_LINKS_ROUTE) +export class PublicLinkController { + constructor(private readonly sharing: FileSharingService) {} + + @Get(':token') + @Throttle({ + default: { + ttl: PUBLIC_LINK_THROTTLE_TTL_MS, + limit: PUBLIC_LINK_INFO_THROTTLE_LIMIT, + }, + }) + @ApiOperation({ + summary: '공개 링크 정보 (로그인 없음)', + description: + '보낸 사람 이름 · 주소, 보낸 때 · 끝나는 때, 파일 목록(active 일 때만 — 끝난 링크는 빈 배열), 신고 · 문의 주소. ' + + '모양이 틀린 · 모르는 토큰, 보내기를 취소한 링크, 기록을 지운 링크(끝난 뒤 90일)는 404. 기간 지남 · 끊음은 200 의 status. ' + + `Cache-Control: no-store · X-Robots-Tag: noindex · Referrer-Policy: no-referrer. 요청 수는 IP 마다 1분 ${PUBLIC_LINK_INFO_THROTTLE_LIMIT}회.`, + }) + @ApiStandardResponse(PublicLinkDto, { description: '링크 정보' }) + @ApiErrorResponse('RES_001', '없는 링크') + @ApiErrorResponse('REQ_001', '요청 수 한도') + info( + @Param('token') token: string, + @Res({ passthrough: true }) res: Response, + ) { + writePublicHeaders(res); + return this.sharing.publicLink(token); + } + + @Get(':token/files/:fileId') + @SkipTransform() + @Throttle({ + default: { + ttl: PUBLIC_LINK_THROTTLE_TTL_MS, + limit: PUBLIC_FILE_THROTTLE_LIMIT, + }, + }) + @ApiOperation({ + summary: + '공개 파일 받기 (로그인 없음 · GET · HEAD · Range — 표준 래퍼 없음)', + description: + '늘 application/octet-stream + Content-Disposition: attachment + nosniff + sandbox CSP + private, no-store. 강한 ETag("") · Last-Modified · Accept-Ranges: bytes. ' + + 'Range 하나면 206, 여럿 · 형식 오류면 200 전체, 처음이 크기 이상이면 416(본문 없음). HEAD 는 머리글만(세지 않는다). ' + + '받은 수는 GET 이고 0번째 바이트부터 보낸 응답만 센다 — 상한에 닿으면 이어 받기도 410. ' + + `실패: 없는 링크 · 그 링크에 없는 파일 404, 검사 중 409 FILE_008, 기간 지남 · 끊음 · 위험 · 횟수 초과 · 찾을 수 없음 410 FILE_007. 요청 수는 IP 마다 1분 ${PUBLIC_FILE_THROTTLE_LIMIT}회.`, + }) + @ApiProduces('application/octet-stream') + @ApiResponse({ + status: HttpStatus.OK, + description: '파일 (206 — 범위)', + schema: { type: 'string', format: 'binary' }, + }) + @ApiResponse({ + status: HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE, + description: '받을 수 없는 범위 — Content-Range: bytes */<크기>, 본문 없음', + }) + @ApiErrorResponse('RES_001', '없는 링크 · 그 링크에 없는 파일') + @ApiErrorResponse('FILE_008', '보안 검사 중 — 끝나면 받는다') + @ApiErrorResponse( + 'FILE_007', + '기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음', + ) + @ApiErrorResponse('REQ_001', '요청 수 한도') + async file( + @Param('token') token: string, + @Param('fileId') fileId: string, + @Req() req: Request, + @Res() res: Response, + ): Promise { + const record = await this.sharing.requirePublicRecord(token); + await this.sharing.serveFile(req, res, record, fileId.toLowerCase(), { + count: true, + }); + } +} diff --git a/backend/src/modules/file-sharing/share-link-file.entity.ts b/backend/src/modules/file-sharing/share-link-file.entity.ts new file mode 100644 index 0000000..fd89a00 --- /dev/null +++ b/backend/src/modules/file-sharing/share-link-file.entity.ts @@ -0,0 +1,50 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from 'typeorm'; + +import { BIGINT_NUMBER } from '@/shared/utils/bigint-column'; +import { ShareLinkEntity } from './share-link.entity'; + +/** 정리한 파일 이름 — 코드 포인트 255자 */ +const NAME_COLUMN_LENGTH = 255; + +/** + * 링크의 파일 하나 — 보낸 차례(position), 받은 수 · 마지막으로 받은 때(누가 · IP 는 남기지 않는다). + * 이름 · 크기는 보낼 때의 값을 함께 둔다 — 파일 기록(file-storage)이 먼저 지워져도 링크 기록(목록 · 공개 화면의 끝난 링크)이 남게. + * 파일 표와 외래 키로 묶지 않는다 — 두 모듈의 정리 작업이 서로를 기다리지 않게 (파일 기록이 없으면 "찾을 수 없음") + */ +@Entity({ name: 'share_link_files' }) +@Index('IDX_share_link_files_file', ['fileId']) +export class ShareLinkFileEntity { + @PrimaryColumn({ name: 'link_id', type: 'uuid' }) + linkId!: string; + + @PrimaryColumn({ name: 'file_id', type: 'uuid' }) + fileId!: string; + + /** 보낸 차례 (0 부터) */ + @Column({ type: 'smallint' }) + position!: number; + + @Column({ type: 'varchar', length: NAME_COLUMN_LENGTH }) + name!: string; + + @Column({ type: 'bigint', transformer: BIGINT_NUMBER }) + size!: number; + + /** 받은 수 — 0번째 바이트부터 보내기 시작한 GET 만 센다 */ + @Column({ name: 'download_count', type: 'integer', default: 0 }) + downloadCount!: number; + + @Column({ name: 'last_downloaded_at', type: 'timestamptz', nullable: true }) + lastDownloadedAt!: Date | null; + + @ManyToOne(() => ShareLinkEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'link_id' }) + link?: ShareLinkEntity; +} diff --git a/backend/src/modules/file-sharing/share-link-lookup.controller.ts b/backend/src/modules/file-sharing/share-link-lookup.controller.ts new file mode 100644 index 0000000..6015834 --- /dev/null +++ b/backend/src/modules/file-sharing/share-link-lookup.controller.ts @@ -0,0 +1,56 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { NoStore } from '@/common/decorators/no-store.decorator'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + ShareLinkLookupRequestDto, + ShareLinkLookupResultDto, +} from './dto/share-link.dto'; +import { FileSharingService } from './file-sharing.service'; + +/** 인용 속 링크의 상태 경로 (`/api/share-link-lookups`) */ +export const SHARE_LINK_LOOKUPS_ROUTE = 'share-link-lookups'; + +/** + * 인용 속 링크의 상태 (11단계 — 계약 4절 "인용 속 링크의 상태") — 쓰기 화면(전달 · 답장)이 인용에서 찾은 공개 화면 주소마다 상태 · 끝나는 날만. + * 누구의 링크든 같다(이름 · 보낸 사람 · 받은 수는 주지 않는다). 상태를 바꾸지 않지만 주소(토큰)를 쿼리에 싣지 않으려고 POST 본문으로 받는다. + * 로그에는 개수만 — 본문(주소)은 남기지 않는다 (계약 4절 "로그") + */ +@ApiTags('Files') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(SHARE_LINK_LOOKUPS_ROUTE) +export class ShareLinkLookupController { + constructor(private readonly sharing: FileSharingService) {} + + @Post() + @HttpCode(HttpStatus.OK) + @NoStore() + @ApiOperation({ + summary: '인용 속 대용량 첨부 링크의 상태', + description: + '`/s/<토큰>` 모양이 아니거나 모르는 · 보내기를 취소한 · 기록을 지운 링크는 unknown. 파일마다의 상태(검사 중 · 위험 등)는 주지 않는다.', + }) + @ApiStandardResponse(ShareLinkLookupResultDto, { + description: '링크마다 상태 (요청 차례 그대로)', + }) + @ApiErrorResponse('VAL_001', '1~20개 · 겹침 · 한 개 300자 초과') + async lookup( + @Body() dto: ShareLinkLookupRequestDto, + ): Promise { + return { links: await this.sharing.lookup(dto.urls) }; + } +} diff --git a/backend/src/modules/file-sharing/share-link.entity.ts b/backend/src/modules/file-sharing/share-link.entity.ts new file mode 100644 index 0000000..81786a7 --- /dev/null +++ b/backend/src/modules/file-sharing/share-link.entity.ts @@ -0,0 +1,105 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +/** 메일 주소 최대 길이 (RFC 5321 경로 한도) */ +const EMAIL_COLUMN_LENGTH = 254; +/** 표시 이름 — 받는 사람 이름과 같은 한도 (글자 수 — PostgreSQL varchar 는 코드 포인트로 센다). 넣기 전에 이 길이로 자른다 */ +export const SENDER_NAME_MAX_LENGTH = 200; +const NAME_COLUMN_LENGTH = SENDER_NAME_MAX_LENGTH; +/** 메일 id · Message-ID — 엔진 id 는 255자까지 */ +const MAIL_ID_COLUMN_LENGTH = 255; +/** 쓰임 · 까닭 같은 짧은 낱말 */ +const WORD_COLUMN_LENGTH = 32; + +/** 취소한 까닭 — 보내지 못함(확실한 실패) · 보내기 취소(9단계의 취소) */ +export type ShareLinkCancelReason = 'send-failed' | 'send-canceled'; +/** 끊은 사람 — 보낸 사람 · 운영 */ +export type ShareLinkRevoker = 'owner' | 'operator'; + +/** + * 공유 링크 하나 (계약 4절 — 메일 한 통의 파일 묶음). 받는 사람별 링크는 없다(보낸 사본과 받는 사람의 사본이 같은 메일이다). + * - 토큰은 SHA-256 만(`token_hash`) — 원문은 메일 본문에만 있다 + * - 보낸 사람 이름 · 주소는 보낼 때 로그인한 계정의 값 (꾸밀 수 없다 — 공개 화면에 보인다) + * - `message_id`: 같은 Idempotency-Key 의 재시도가 처음 메일을 찾으면 그 메일 id(`sent_email_id`)를 잇는 데 쓴다 + * - 취소한 링크(`canceled_at` — 보내지 못함 · 보내기 취소)는 없는 링크처럼 다룬다(공개 404 · 목록에서 빠짐). 끊은 링크(`revoked_at`)는 "공유 멈춤" + * - 기록은 끝나거나(expires_at) 끊기거나 취소된 뒤 90일 지나면 정리 작업이 지운다 + */ +@Entity({ name: 'share_links' }) +@Index('IDX_share_links_owner_sent', ['ownerEmail', 'sentAt']) +@Index('IDX_share_links_owner_sent_email', ['ownerEmail', 'sentEmailId']) +@Index('IDX_share_links_owner_message', ['ownerEmail', 'messageId']) +export class ShareLinkEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + /** 토큰의 SHA-256 */ + @Index('UQ_share_links_token_hash', { unique: true }) + @Column({ name: 'token_hash', type: 'bytea' }) + tokenHash!: Buffer; + + @Column({ name: 'owner_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH }) + ownerEmail!: string; + + /** 보낼 때 로그인한 계정의 이름 */ + @Column({ + name: 'sender_name', + type: 'varchar', + length: NAME_COLUMN_LENGTH, + nullable: true, + }) + senderName!: string | null; + + /** 쓰임 — 'mail' */ + @Column({ type: 'varchar', length: WORD_COLUMN_LENGTH }) + purpose!: string; + + /** 중복 발송 방지의 Message-ID (Idempotency-Key 가 있는 발송만) */ + @Column({ + name: 'message_id', + type: 'varchar', + length: MAIL_ID_COLUMN_LENGTH, + nullable: true, + }) + messageId!: string | null; + + /** 보낸 사본의 메일 id — 보낸 결과를 모르면 null */ + @Column({ + name: 'sent_email_id', + type: 'varchar', + length: MAIL_ID_COLUMN_LENGTH, + nullable: true, + }) + sentEmailId!: string | null; + + /** 보낸 때 (예약은 보낼 시각) */ + @Column({ name: 'sent_at', type: 'timestamptz' }) + sentAt!: Date; + + /** 끝나는 때 — 보낸 때의 한국 날짜 + 보관 날 수의 끝 */ + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt!: Date; + + @Column({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt!: Date | null; + + @Column({ + name: 'revoked_by', + type: 'varchar', + length: WORD_COLUMN_LENGTH, + nullable: true, + }) + revokedBy!: ShareLinkRevoker | null; + + @Column({ name: 'canceled_at', type: 'timestamptz', nullable: true }) + canceledAt!: Date | null; + + @Column({ + name: 'cancel_reason', + type: 'varchar', + length: WORD_COLUMN_LENGTH, + nullable: true, + }) + cancelReason!: ShareLinkCancelReason | null; +} diff --git a/backend/src/modules/file-sharing/share-token.spec.ts b/backend/src/modules/file-sharing/share-token.spec.ts new file mode 100644 index 0000000..4df5af0 --- /dev/null +++ b/backend/src/modules/file-sharing/share-token.spec.ts @@ -0,0 +1,63 @@ +import { createHash } from 'crypto'; + +import { + SHARE_TOKEN_PATTERN, + createShareToken, + hashShareToken, + isShareToken, + shareUrl, + tokenFromShareUrl, +} from './share-token'; + +const ORIGIN = 'https://files.arasystem.test'; +const TOKEN = 'Ab_-9'.repeat(8) + 'xyz'; + +describe('공개 링크 토큰', () => { + it('256비트 base64url 43자 — 매번 다르다', () => { + const tokens = new Set(Array.from({ length: 50 }, createShareToken)); + expect(tokens.size).toBe(50); + for (const token of tokens) expect(token).toMatch(SHARE_TOKEN_PATTERN); + }); + + it('DB 에는 SHA-256 만 둔다', () => { + expect(hashShareToken(TOKEN)).toEqual( + createHash('sha256').update(TOKEN).digest(), + ); + expect(isShareToken(TOKEN)).toBe(true); + expect(isShareToken(`${TOKEN}=`)).toBe(false); + }); + + it('공개 화면 주소 — <출처>/s/<토큰>', () => { + expect(shareUrl(ORIGIN, TOKEN)).toBe(`${ORIGIN}/s/${TOKEN}`); + }); +}); + +describe('tokenFromShareUrl', () => { + it('우리 공개 화면 주소의 토큰 — 호스트 대소문자 · 기본 포트는 같은 주소로 본다', () => { + expect(tokenFromShareUrl(ORIGIN, `${ORIGIN}/s/${TOKEN}`)).toBe(TOKEN); + expect( + tokenFromShareUrl(ORIGIN, `https://FILES.arasystem.test:443/s/${TOKEN}`), + ).toBe(TOKEN); + }); + + it.each([ + `https://files.arasystem.test.evil.example/s/${TOKEN}`, + `http://files.arasystem.test/s/${TOKEN}`, + `https://files.arasystem.test/s/${TOKEN}/`, + `https://files.arasystem.test/x/${TOKEN}`, + `https://files.arasystem.test/s/${TOKEN.slice(1)}`, + `https://user@files.arasystem.test/s/${TOKEN}`, + `https://files.arasystem.test/s/${TOKEN}?utm=1`, + '그냥 글', + ])('우리 링크 모양이 아니면 null — %s', (value) => { + expect(tokenFromShareUrl(ORIGIN, value)).toBeNull(); + }); + + it('allowQuery 면 뒤에 붙은 쿼리 · 조각은 무시한다 (인용 속 주소)', () => { + expect( + tokenFromShareUrl(ORIGIN, `${ORIGIN}/s/${TOKEN}?utm=1#x`, { + allowQuery: true, + }), + ).toBe(TOKEN); + }); +}); diff --git a/backend/src/modules/file-sharing/share-token.ts b/backend/src/modules/file-sharing/share-token.ts new file mode 100644 index 0000000..69f1ecb --- /dev/null +++ b/backend/src/modules/file-sharing/share-token.ts @@ -0,0 +1,67 @@ +import { createHash, randomBytes } from 'crypto'; + +/** + * 공개 링크 토큰 (순수 함수) — 계약 4절 "공개 내려받기"의 토큰. + * + * - 링크마다 `crypto.randomBytes(32)` 의 base64url(43자, `=` 없음 — 256비트). 서버 서명 키(JWT_SECRET)에서 만들지 않는다 (보안 규칙) + * - DB 에는 **SHA-256 만** 두고 그것으로 찾는다 — 원문은 메일 본문에만 있고, 서버는 링크를 다시 만들 수 없다. + * 맞혀 볼 수 없는 값이라 느린 해시는 필요 없다 + * - 경로에 둔다(`/s/<토큰>` · `/api/public-links/<토큰>`) — 로그에서는 가린다(mask.ts) + */ + +/** 토큰 바이트 수 (256비트) */ +const SHARE_TOKEN_BYTES = 32; + +/** 토큰 모양 — 아니면 DB 에 묻지 않고 404 */ +export const SHARE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; + +/** 공개 화면 경로 — `/s/<토큰>` (frontend 라우트 `/s/:token` 과 짝) */ +export const SHARE_PAGE_PATH = '/s/'; + +/** 이보다 긴 주소는 링크로 보지 않는다 (공개 주소 + 경로 + 토큰은 100자 안쪽) */ +const MAX_SHARE_URL_LENGTH = 2_048; + +export function createShareToken(): string { + return randomBytes(SHARE_TOKEN_BYTES).toString('base64url'); +} + +export function hashShareToken(token: string): Buffer { + return createHash('sha256').update(token, 'utf8').digest(); +} + +export function isShareToken(value: string): boolean { + return SHARE_TOKEN_PATTERN.test(value); +} + +/** 공개 화면 주소 — 덩이 · `MessageLargeFile.url` 이 쓴다. publicOrigin 은 정규화한 출처(끝 `/` 없음) */ +export function shareUrl(publicOrigin: string, token: string): string { + return `${publicOrigin}${SHARE_PAGE_PATH}${token}`; +} + +/** + * 주소가 우리 공개 화면(`/s/<토큰>`)이면 그 토큰, 아니면 null. + * 출처는 비교할 모양으로(스킴 · 호스트 소문자, 기본 포트 생략) 견주고, 경로는 정확히 `/s/<토큰>` 이어야 한다. + * `allowQuery` 면 뒤에 붙은 쿼리 · 조각(#)을 무시한다 (인용 속 주소를 묻는 때 — 받는 쪽 장비가 붙였을 수 있다) + */ +export function tokenFromShareUrl( + publicOrigin: string, + value: string, + options: { readonly allowQuery?: boolean } = {}, +): string | null { + if (value.length > MAX_SHARE_URL_LENGTH) return null; + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + if (url.origin !== publicOrigin || url.username || url.password) { + return null; + } + if (!options.allowQuery && (url.search !== '' || url.hash !== '')) { + return null; + } + if (!url.pathname.startsWith(SHARE_PAGE_PATH)) return null; + const token = url.pathname.slice(SHARE_PAGE_PATH.length); + return isShareToken(token) ? token : null; +} diff --git a/backend/src/modules/file-sharing/shared-file-status.spec.ts b/backend/src/modules/file-sharing/shared-file-status.spec.ts new file mode 100644 index 0000000..4832120 --- /dev/null +++ b/backend/src/modules/file-sharing/shared-file-status.spec.ts @@ -0,0 +1,92 @@ +import type { StoredFileView } from '@/modules/file-storage/file-storage.types'; +import { + isDownloadable, + sharedFileStatus, + shareLinkStatus, +} from './shared-file-status'; + +const NOW = new Date('2026-11-10T00:00:00.000Z'); +const ACTIVE = { + revokedAt: null, + expiresAt: new Date('2026-11-29T14:59:59.999Z'), +}; + +const view = (overrides: Partial = {}): StoredFileView => ({ + id: 'f1', + ownerEmail: 'ara.kim@arasystem.test', + name: '설계도.dwg', + size: 1000, + state: 'ready', + removedReason: null, + scanStatus: 'clean', + completedAt: new Date('2026-10-30T03:00:00.000Z'), + expiresAt: ACTIVE.expiresAt, + alive: true, + bytesAvailable: true, + ...overrides, +}); + +const status = ( + overrides: Partial[0]> = {}, +) => + sharedFileStatus({ + link: ACTIVE, + file: view(), + downloadCount: 0, + downloadLimit: 500, + now: NOW, + ...overrides, + }); + +describe('shareLinkStatus', () => { + it('끊음 → 기간 지남 → 사용 중', () => { + expect(shareLinkStatus(ACTIVE, NOW)).toBe('active'); + expect(shareLinkStatus({ ...ACTIVE, revokedAt: NOW }, NOW)).toBe('revoked'); + expect(shareLinkStatus(ACTIVE, new Date('2026-11-29T15:00:00.000Z'))).toBe( + 'expired', + ); + // 끝나는 때 그 순간까지는 받을 수 있다 (지금 ≤ expiresAt) + expect(shareLinkStatus(ACTIVE, ACTIVE.expiresAt)).toBe('active'); + }); +}); + +describe('sharedFileStatus (계약 4절 "상태별 응답")', () => { + it('링크가 끝났으면 링크의 상태가 먼저', () => { + expect(status({ link: { ...ACTIVE, revokedAt: NOW } })).toBe('revoked'); + expect( + status({ + now: new Date('2026-12-01T00:00:00.000Z'), + file: view({ scanStatus: 'infected' }), + }), + ).toBe('expired'); + }); + + it('위험 → 찾을 수 없음 → 횟수 초과 → 검사 중 → 검사하지 못함 · 받을 수 있음', () => { + expect( + status({ file: view({ scanStatus: 'infected', bytesAvailable: false }) }), + ).toBe('infected'); + expect(status({ file: undefined })).toBe('missing'); + expect(status({ file: view({ bytesAvailable: false }) })).toBe('missing'); + expect(status({ downloadCount: 500 })).toBe('limit-reached'); + expect(status({ file: view({ scanStatus: 'pending' }) })).toBe('scanning'); + expect(status({ file: view({ scanStatus: 'unscannable' }) })).toBe( + 'unscannable', + ); + expect(status()).toBe('available'); + }); + + it('받을 수 있는 상태는 받을 수 있음 · 검사하지 못함뿐', () => { + expect(isDownloadable('available')).toBe(true); + expect(isDownloadable('unscannable')).toBe(true); + for (const other of [ + 'scanning', + 'infected', + 'limit-reached', + 'revoked', + 'expired', + 'missing', + ] as const) { + expect(isDownloadable(other)).toBe(false); + } + }); +}); diff --git a/backend/src/modules/file-sharing/shared-file-status.ts b/backend/src/modules/file-sharing/shared-file-status.ts new file mode 100644 index 0000000..2f21846 --- /dev/null +++ b/backend/src/modules/file-sharing/shared-file-status.ts @@ -0,0 +1,42 @@ +import type { StoredFileView } from '@/modules/file-storage/file-storage.types'; +import type { SharedFileStatus, ShareLinkStatus } from './file-sharing.types'; + +/** + * 링크 · 파일의 상태 (순수 함수) — 계약 4절 "상태별 응답". 요청마다 지금 시각으로 본다(정리 작업이 늦어도 정확하다). + */ + +/** 링크의 상태 — 끊음이 먼저, 그다음 기간 (`지금 ≤ expiresAt` 이면 받을 수 있는 기간) */ +export function shareLinkStatus( + link: { readonly revokedAt: Date | null; readonly expiresAt: Date }, + now: Date, +): ShareLinkStatus { + if (link.revokedAt !== null) return 'revoked'; + return now > link.expiresAt ? 'expired' : 'active'; +} + +/** + * 링크 안 파일 하나의 상태 — 링크(끊음 · 기간) → 위험 → 찾을 수 없음(기록 · 바이트가 없음) → 횟수 초과 → 검사 중 → 검사하지 못함 · 받을 수 있음 + */ +export function sharedFileStatus(input: { + readonly link: { readonly revokedAt: Date | null; readonly expiresAt: Date }; + /** 파일 기록 — 지워졌으면 undefined */ + readonly file: StoredFileView | undefined; + readonly downloadCount: number; + readonly downloadLimit: number; + readonly now: Date; +}): SharedFileStatus { + const linkStatus = shareLinkStatus(input.link, input.now); + if (linkStatus !== 'active') return linkStatus; + const { file } = input; + if (file?.scanStatus === 'infected') return 'infected'; + if (!file || !file.bytesAvailable) return 'missing'; + if (input.downloadCount >= input.downloadLimit) return 'limit-reached'; + if (file.scanStatus === 'clean') return 'available'; + if (file.scanStatus === 'unscannable') return 'unscannable'; + return 'scanning'; +} + +/** 받을 수 있는 상태인가 — 받을 수 있음 · 검사하지 못함 */ +export function isDownloadable(status: SharedFileStatus): boolean { + return status === 'available' || status === 'unscannable'; +} diff --git a/backend/src/modules/file-storage/chunk-upload.spec.ts b/backend/src/modules/file-storage/chunk-upload.spec.ts new file mode 100644 index 0000000..2584139 --- /dev/null +++ b/backend/src/modules/file-storage/chunk-upload.spec.ts @@ -0,0 +1,87 @@ +import { createHash } from 'crypto'; +import { Readable } from 'stream'; + +import { + ChunkGate, + chunkLength, + digestStream, + parseContentDigest, +} from './chunk-upload'; +import { ChunkLengthMismatchError } from './file-blob-store'; + +const sha = (text: string) => createHash('sha256').update(text).digest(); + +describe('parseContentDigest (RFC 9530)', () => { + it('sha-256 값을 읽는다 — 다른 알고리즘과 함께 와도 · 매개변수가 붙어도', () => { + const value = sha('조각').toString('base64'); + expect(parseContentDigest(`sha-256=:${value}:`)).toEqual(sha('조각')); + expect( + parseContentDigest(`sha-512=:AAAA:, sha-256=:${value}:;x=1`), + ).toEqual(sha('조각')); + }); + + it('없으면(다른 알고리즘만 포함) null, 모양이 틀리면 invalid', () => { + expect(parseContentDigest(undefined)).toBeNull(); + expect(parseContentDigest('')).toBeNull(); + expect(parseContentDigest('sha-512=:AAAA:')).toBeNull(); + expect(parseContentDigest('sha-256=abc')).toBe('invalid'); + expect(parseContentDigest('sha-256=:AAAA:')).toBe('invalid'); + expect(parseContentDigest(`sha-256=:${'A'.repeat(2_000)}:`)).toBe( + 'invalid', + ); + }); +}); + +describe('chunkLength', () => { + it('마지막 조각만 짧다', () => { + const file = { size: 25, chunkSize: 10 }; + expect([0, 1, 2].map((index) => chunkLength(file, index))).toEqual([ + 10, 10, 5, + ]); + }); +}); + +describe('digestStream', () => { + it('끝까지 읽어 SHA-256 만 — 길이가 다르면 ChunkLengthMismatchError', async () => { + await expect( + digestStream(Readable.from([Buffer.from('ab'), Buffer.from('c')]), 3), + ).resolves.toEqual(sha('abc')); + await expect( + digestStream(Readable.from([Buffer.from('ab')]), 3), + ).rejects.toBeInstanceOf(ChunkLengthMismatchError); + await expect( + digestStream(Readable.from([Buffer.from('abcd')]), 3), + ).rejects.toBeInstanceOf(ChunkLengthMismatchError); + }); +}); + +describe('ChunkGate (동시 수 · 같은 조각)', () => { + it('같은 조각은 하나 · 직원마다 · 서버 전체 수를 넘으면 거절 — 놓으면 다시 받는다', () => { + const gate = new ChunkGate(3, 2); + const a = gate.tryEnter('ara', 'f1', 0); + expect(typeof a).toBe('object'); + expect(gate.tryEnter('ara', 'f1', 0)).toBe('chunk-busy'); + const b = gate.tryEnter('ara', 'f1', 1); + expect(gate.tryEnter('ara', 'f2', 0)).toBe('user-busy'); + const c = gate.tryEnter('doyun', 'f3', 0); + expect(gate.tryEnter('minji', 'f4', 0)).toBe('server-busy'); + expect(gate.isActive('f1')).toBe(true); + + for (const ticket of [a, b, c]) { + if (typeof ticket === 'object') ticket.release(); + } + expect(gate.isActive('f1')).toBe(false); + expect(typeof gate.tryEnter('minji', 'f4', 0)).toBe('object'); + }); + + it('두 번 놓아도 한 번만 센다', () => { + const gate = new ChunkGate(1, 1); + const ticket = gate.tryEnter('ara', 'f1', 0); + if (typeof ticket !== 'object') throw new Error('자리를 받지 못함'); + ticket.release(); + ticket.release(); + const again = gate.tryEnter('ara', 'f1', 0); + expect(typeof again).toBe('object'); + expect(gate.tryEnter('doyun', 'f2', 0)).toBe('server-busy'); + }); +}); diff --git a/backend/src/modules/file-storage/chunk-upload.ts b/backend/src/modules/file-storage/chunk-upload.ts new file mode 100644 index 0000000..755592c --- /dev/null +++ b/backend/src/modules/file-storage/chunk-upload.ts @@ -0,0 +1,136 @@ +import { createHash } from 'crypto'; +import type { Readable } from 'stream'; + +import { ChunkLengthMismatchError } from './file-blob-store'; + +/** + * 조각 올리기 도우미 (순수 함수 · 작은 셈) — 계약 4절 "올리기" 2. + */ + +/** SHA-256 의 바이트 수 */ +const SHA256_BYTES = 32; + +/** `Content-Digest` 의 sha-256 값 (RFC 9530) — 없으면 null(다른 알고리즘만 있어도 없는 것), 모양이 틀리면 'invalid' */ +export type ContentDigest = Buffer | null | 'invalid'; + +/** RFC 8941 바이트 열 `:base64:` — 앞뒤 콜론 사이 */ +const BYTE_SEQUENCE = /^:([A-Za-z0-9+/]*={0,2}):$/; + +/** + * `Content-Digest: sha-256=::` 를 읽는다 — 여러 알고리즘이 콤마로 이어질 수 있다(`sha-512=:…:, sha-256=:…:`). + * 매개변수(`;…`)는 보지 않는다. sha-256 이 없으면 null, 있는데 32바이트 base64 가 아니면 'invalid' + */ +export function parseContentDigest(header: string | undefined): ContentDigest { + if (header === undefined || header.trim() === '') return null; + // 머리글 하나에 알고리즘 몇 개 — 이보다 길면 읽지 않는다(모양이 틀린 것으로) + if (header.length > 1_024) return 'invalid'; + for (const member of header.split(',')) { + const separator = member.indexOf('='); + if (separator < 0) continue; + const key = member.slice(0, separator).trim(); + if (key !== 'sha-256') continue; + const value = member + .slice(separator + 1) + .split(';')[0] + .trim(); + const match = BYTE_SEQUENCE.exec(value); + if (!match) return 'invalid'; + const bytes = Buffer.from(match[1], 'base64'); + return bytes.length === SHA256_BYTES ? bytes : 'invalid'; + } + return null; +} + +/** 조각 번호의 길이 — 마지막 조각만 짧다 */ +export function chunkLength( + file: { readonly size: number; readonly chunkSize: number }, + index: number, +): number { + const start = index * file.chunkSize; + return Math.min(file.chunkSize, file.size - start); +} + +/** 본문을 끝까지 읽어 SHA-256 만 계산한다 (이미 받은 조각을 다시 보낸 경우 — 쓰지 않고 견준다). 길이가 다르면 ChunkLengthMismatchError */ +export async function digestStream( + source: Readable, + expectedBytes: number, +): Promise { + const hash = createHash('sha256'); + let received = 0; + for await (const piece of source) { + const data = Buffer.isBuffer(piece) + ? piece + : Buffer.from(piece as Uint8Array); + received += data.length; + if (received > expectedBytes) { + throw new ChunkLengthMismatchError(expectedBytes, received); + } + hash.update(data); + } + if (received !== expectedBytes) { + throw new ChunkLengthMismatchError(expectedBytes, received); + } + return hash.digest(); +} + +/** 조각을 받지 못하게 된 까닭 — 동시 수 · 같은 조각 */ +export type ChunkGateRefusal = 'server-busy' | 'user-busy' | 'chunk-busy'; + +/** 받는 자리 — 끝나면(성공 · 실패) 놓는다 */ +export interface ChunkTicket { + release(): void; +} + +/** + * 동시에 받는 조각의 자리 (계약 4절 "요청 수 · 동시 수") — 서버 전체 · 직원마다 수, 같은 올리기의 같은 조각은 하나. + * 기다리지 않는다 — 자리가 없으면 거절하고 클라이언트가 잠시 뒤 같은 조각을 다시 보낸다. + * ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 셈 · 잠금으로 옮긴다 (nestjs 스킬 "확장 때 옮길 것") + */ +export class ChunkGate { + private total = 0; + private readonly perUser = new Map(); + private readonly chunks = new Set(); + private readonly perFile = new Map(); + + constructor( + private readonly maxTotal: number, + private readonly maxPerUser: number, + ) {} + + tryEnter( + user: string, + fileId: string, + index: number, + ): ChunkTicket | ChunkGateRefusal { + const chunkKey = `${fileId}:${index}`; + if (this.chunks.has(chunkKey)) return 'chunk-busy'; + if (this.total >= this.maxTotal) return 'server-busy'; + const userCount = this.perUser.get(user) ?? 0; + if (userCount >= this.maxPerUser) return 'user-busy'; + + this.total += 1; + this.perUser.set(user, userCount + 1); + this.chunks.add(chunkKey); + this.perFile.set(fileId, (this.perFile.get(fileId) ?? 0) + 1); + let released = false; + return { + release: () => { + if (released) return; + released = true; + this.total -= 1; + const left = (this.perUser.get(user) ?? 1) - 1; + if (left > 0) this.perUser.set(user, left); + else this.perUser.delete(user); + this.chunks.delete(chunkKey); + const fileLeft = (this.perFile.get(fileId) ?? 1) - 1; + if (fileLeft > 0) this.perFile.set(fileId, fileLeft); + else this.perFile.delete(fileId); + }, + }; + } + + /** 이 올리기의 조각을 받는 중인가 — 끝내기는 그동안 409 */ + isActive(fileId: string): boolean { + return this.perFile.has(fileId); + } +} diff --git a/backend/src/modules/file-storage/clamav-client.spec.ts b/backend/src/modules/file-storage/clamav-client.spec.ts new file mode 100644 index 0000000..729447d --- /dev/null +++ b/backend/src/modules/file-storage/clamav-client.spec.ts @@ -0,0 +1,469 @@ +import net, { + createServer, + type AddressInfo, + type Server, + type Socket, +} from 'net'; +import { Readable } from 'stream'; + +import { + ClamavUnavailableError, + classifyClamavReply, + scanWithClamav, + type ClamavConnection, + type ClamavFailure, +} from './clamav-client'; + +/** + * 가짜 clamd 가 받은 INSTREAM 내용으로 답을 고르는 함수 — 글이면 `\0` 을 붙여 보내고 닫는다, null 이면 답하지 않는다, + * 함수면 그 소켓으로 직접 한다 (끊기 · 반쪽 글 · 늦은 답 등) + */ +type FakeReply = string | null | ((socket: Socket) => void); +type FakeVerdict = (content: Buffer) => FakeReply; + +/** + * 크기 한도(StreamMaxLength)를 넘었을 때 — 실제 clamd 처럼 크기 오류 글을 보내고 닫거나(error-reply), + * 그 글이 RST 에 묻혀 오지 않은 것처럼 바로 끊는다(reset — 받는 쪽에는 EPIPE · ECONNRESET 만 남는다) + */ +type OverLimit = 'error-reply' | 'reset'; + +/** + * 가짜 clamd (테스트 안 TCP 서버) — zINSTREAM 명령 뒤의 [길이 4바이트 + 바이트] 조각을 모아, 길이 0 이 오면 답한다. + * `limit` 을 넘게 받으면 overLimit 대로 한다(StreamMaxLength) + */ +async function startFakeClamd( + verdict: FakeVerdict, + limit = Number.POSITIVE_INFINITY, + overLimit: OverLimit = 'error-reply', +): Promise<{ server: Server; port: number; commands: string[] }> { + const commands: string[] = []; + const server = createServer((socket: Socket) => { + let buffer = Buffer.alloc(0); + let commandRead = false; + const content: Buffer[] = []; + let total = 0; + // 끊은 쪽의 오류(ECONNRESET 등)는 테스트와 상관없다 + socket.on('error', () => undefined); + socket.on('data', (data: Buffer) => { + buffer = Buffer.concat([buffer, data]); + if (!commandRead) { + const end = buffer.indexOf(0); + if (end < 0) return; + commands.push(buffer.subarray(0, end).toString('latin1')); + buffer = buffer.subarray(end + 1); + commandRead = true; + } + while (buffer.length >= 4) { + const length = buffer.readUInt32BE(0); + if (length === 0) { + const reply = verdict(Buffer.concat(content)); + if (typeof reply === 'function') reply(socket); + else if (reply !== null) socket.end(`${reply}\0`); + return; + } + if (buffer.length < 4 + length) return; + content.push(buffer.subarray(4, 4 + length)); + total += length; + buffer = buffer.subarray(4 + length); + if (total > limit) { + if (overLimit === 'reset') socket.resetAndDestroy(); + else socket.end('INSTREAM size limit exceeded. ERROR\0'); + return; + } + } + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { server, port: (server.address() as AddressInfo).port, commands }; +} + +const options = ( + port: number, + overrides: Partial = {}, +): ClamavConnection => ({ + host: '127.0.0.1', + port, + connectTimeoutMs: 2_000, + idleTimeoutMs: 5_000, + verdictTimeoutMs: () => 5_000, + keepAliveInitialDelayMs: 60_000, + ...overrides, +}); + +/** + * scanWithClamav 가 여는 소켓을 잡는다 — keepalive 설정을 보고, 커널이 keepalive 로 끊는 경우를 흉내 내려고. + * 진짜 net.connect 를 그대로 부르고 소켓만 모은다 (describe 의 afterEach 가 되돌린다) + */ +function captureClamdSockets(): { + sockets: Socket[]; + keepAlive: jest.SpyInstance[]; +} { + const realConnect = net.connect; + const sockets: Socket[] = []; + const keepAlive: jest.SpyInstance[] = []; + jest.spyOn(net, 'connect').mockImplementation((...args: unknown[]) => { + const socket = (realConnect as (...params: unknown[]) => Socket)(...args); + sockets.push(socket); + keepAlive.push(jest.spyOn(socket, 'setKeepAlive')); + return socket; + }); + return { sockets, keepAlive }; +} + +/** + * keepalive 가 상대를 잃었을 때 Node 가 내는 오류 — 커널이 물음 10번에 답을 못 받으면 읽기가 ETIMEDOUT 으로 끝난다 + * (2026-10-30 실측: 상대 컨테이너의 네트워크를 내리자 `read ETIMEDOUT` · code ETIMEDOUT) + */ +const keepAliveTimeout = () => + Object.assign(new Error('read ETIMEDOUT'), { + code: 'ETIMEDOUT', + errno: -110, + syscall: 'read', + }); + +/** 끊김 글의 끝 `(까닭)` 을 뗀 것 — 끊긴 까닭만 다른 두 글을 견준다 */ +const withoutCause = (message: string) => message.replace(/ \([^()]*\)$/, ''); + +/** 끝나지 않는 내용 — 앞부분만 보내고 흘려보내는 중에 머문다 */ +const unfinishedFile = (head: string) => { + const source = new Readable({ read: () => undefined }); + source.push(Buffer.from(head)); + return source; +}; + +const fileOf = (text: string, pieces = 3) => { + const data = Buffer.from(text); + const size = Math.ceil(data.length / pieces); + return Readable.from( + Array.from({ length: pieces }, (_, index) => + data.subarray(index * size, (index + 1) * size), + ).filter((piece) => piece.length > 0), + ); +}; + +/** 판정이 없을 때의 까닭 (ClamavUnavailableError 가 아니면 'NOT_CLAMAV') */ +async function failureOf( + run: () => Promise, +): Promise { + try { + await run(); + return null; + } catch (error) { + return error instanceof ClamavUnavailableError + ? error.failure + : 'NOT_CLAMAV'; + } +} + +/** 바로 던지는 함수의 까닭 — 약속으로 감싸 failureOf 와 같게 본다 */ +const failureOfSync = (run: () => unknown) => + failureOf(() => Promise.resolve().then(run)); + +/** 판정이 없을 때의 오류 (까닭 · 글) — 판정을 받으면 테스트를 실패시킨다 */ +async function unavailableOf( + run: () => Promise, +): Promise { + try { + await run(); + } catch (error) { + if (error instanceof ClamavUnavailableError) return error; + throw error; + } + throw new Error('판정을 받았습니다 — 실패를 기대했습니다'); +} + +describe('classifyClamavReply (clamd 응답 → 판정 — 견주기 한 곳)', () => { + it.each([ + ['stream: OK\0', { kind: 'clean' }], + [ + 'stream: Win.Test.EICAR_HDB-1 FOUND\0', + { kind: 'infected', signature: 'Win.Test.EICAR_HDB-1' }, + ], + [ + 'stream: Heuristics.Encrypted.Zip FOUND\0', + { kind: 'unscannable', signature: 'Heuristics.Encrypted.Zip' }, + ], + [ + 'stream: Heuristics.Limits.Exceeded.MaxFileSize FOUND\0', + { + kind: 'unscannable', + signature: 'Heuristics.Limits.Exceeded.MaxFileSize', + }, + ], + [ + 'stream: Heuristics.Limits.Exceeded FOUND\0', + { kind: 'unscannable', signature: 'Heuristics.Limits.Exceeded' }, + ], + // 이름만 비슷한 다른 휴리스틱은 위험 — 닫힌 쪽 + [ + 'stream: Heuristics.EncryptedLookalike FOUND\0', + { kind: 'infected', signature: 'Heuristics.EncryptedLookalike' }, + ], + [ + 'stream: Heuristics.Phishing.Email.SpoofedDomain FOUND\0', + { + kind: 'infected', + signature: 'Heuristics.Phishing.Email.SpoofedDomain', + }, + ], + ])('%s', (reply, verdict) => { + expect(classifyClamavReply(reply)).toEqual(verdict); + }); + + it('흐름 크기 한도(StreamMaxLength)는 판정이 아니라 설정 오류 — misconfigured', async () => { + expect( + await failureOfSync(() => + classifyClamavReply('INSTREAM size limit exceeded. ERROR\0'), + ), + ).toBe('misconfigured'); + }); + + it('그 밖의 오류 응답 · 모르는 모양 · 이름 없는 FOUND 는 판정이 아니다 — error-reply (다시 한다)', async () => { + for (const reply of [ + "Can't allocate memory ERROR\0", + 'hello\0', + 'stream: FOUND\0', + 'stream: OKAY\0', + ]) { + expect(await failureOfSync(() => classifyClamavReply(reply))).toBe( + 'error-reply', + ); + } + }); + + it('`\\0` 이 오기 전의 글은 판정이 아니다 — unavailable (끊긴 판정 글을 깨끗함으로 읽지 않는다)', async () => { + for (const partial of ['stream: OK', 'stream: Win.Test.EIC', '']) { + expect(await failureOfSync(() => classifyClamavReply(partial))).toBe( + 'unavailable', + ); + } + }); +}); + +describe('scanWithClamav (가짜 clamd)', () => { + const servers: Server[] = []; + afterEach(async () => { + jest.restoreAllMocks(); + await Promise.all( + servers + .splice(0) + .map((server) => new Promise((resolve) => server.close(resolve))), + ); + }); + + const start = async ( + verdict: FakeVerdict, + limit?: number, + overLimit?: OverLimit, + ) => { + const fake = await startFakeClamd(verdict, limit, overLimit); + servers.push(fake.server); + return fake; + }; + + it('조각을 [길이 + 바이트]로 흘려보내고 길이 0 으로 끝낸다 — 내용 그대로 clamd 가 받는다', async () => { + let received = ''; + const fake = await start((content) => { + received = content.toString(); + return 'stream: OK'; + }); + + const verdict = await scanWithClamav( + options(fake.port), + fileOf('평범한 견적서 내용입니다'), + ); + expect(verdict).toEqual({ kind: 'clean' }); + expect(received).toBe('평범한 견적서 내용입니다'); + expect(fake.commands).toEqual(['zINSTREAM']); + }); + + it('위험 판정 · 암호 압축(검사하지 못함)을 가른다', async () => { + const fake = await start((content) => + content.toString().includes('EICAR') + ? 'stream: Win.Test.EICAR_HDB-1 FOUND' + : 'stream: Heuristics.Encrypted.Zip FOUND', + ); + + await expect( + scanWithClamav(options(fake.port), fileOf('X5O!P%@AP EICAR test')), + ).resolves.toEqual({ kind: 'infected', signature: 'Win.Test.EICAR_HDB-1' }); + await expect( + scanWithClamav(options(fake.port), fileOf('PK encrypted')), + ).resolves.toEqual({ + kind: 'unscannable', + signature: 'Heuristics.Encrypted.Zip', + }); + }); + + it('clamd 의 흐름 크기 한도를 넘으면 설정 오류(misconfigured) — 먼저 닫아도 받은 글을 읽는다, 판정은 없다', async () => { + const fake = await start(() => 'stream: OK', 8); + expect( + await failureOf(() => + scanWithClamav(options(fake.port), fileOf('0123456789abcdefghij', 4)), + ), + ).toBe('misconfigured'); + }); + + it('판정 글을 보내기 전에 끊으면(RST · 빈 응답 · 끝나지 않은 글) unavailable — 다시 한다', async () => { + const reset = await start(() => (socket) => socket.resetAndDestroy()); + expect( + await failureOf(() => + scanWithClamav(options(reset.port), fileOf('내용')), + ), + ).toBe('unavailable'); + + const empty = await start(() => (socket) => socket.end()); + expect( + await failureOf(() => + scanWithClamav(options(empty.port), fileOf('내용')), + ), + ).toBe('unavailable'); + + // `\0` 없이 끝난 "stream: OK" 를 깨끗함으로 읽지 않는다 + const partial = await start(() => (socket) => socket.end('stream: OK')); + expect( + await failureOf(() => + scanWithClamav(options(partial.port), fileOf('내용')), + ), + ).toBe('unavailable'); + }); + + it('clamd 의 오류 응답은 error-reply — 다시 한다', async () => { + const fake = await start(() => "Can't allocate memory ERROR"); + expect( + await failureOf(() => scanWithClamav(options(fake.port), fileOf('내용'))), + ).toBe('error-reply'); + }); + + it('검사기에 닿지 못하거나 답이 없으면 unavailable — 판정 없이 열지 않는다', async () => { + const closed = await startFakeClamd(() => 'stream: OK'); + const port = closed.port; + await new Promise((resolve) => closed.server.close(resolve)); + const refused = await unavailableOf(() => + scanWithClamav(options(port), fileOf('내용')), + ); + expect(refused.failure).toBe('unavailable'); + expect(refused.message).toContain('clamd 에 닿지 못했습니다'); + + const silent = await start(() => null); + expect( + await failureOf(() => + scanWithClamav( + options(silent.port, { verdictTimeoutMs: () => 300 }), + fileOf('내용'), + ), + ), + ).toBe('unavailable'); + }); + + it('다 보낸 뒤에는 보낸 바이트 수로 정한 시간만큼 판정을 기다린다 — 흘려보내는 동안의 유휴 시간보다 길어도', async () => { + // 판정을 600ms 뒤에 보낸다 — 흘려보내는 동안의 유휴 시간(150ms)보다 길고 판정 대기(5초)보다 짧다 + const slow = await start(() => (socket) => { + setTimeout(() => socket.end('stream: OK\0'), 600); + }); + const verdictTimeoutMs = jest.fn(() => 5_000); + const content = '평범한 견적서 내용입니다'; + await expect( + scanWithClamav( + options(slow.port, { idleTimeoutMs: 150, verdictTimeoutMs }), + fileOf(content), + ), + ).resolves.toEqual({ kind: 'clean' }); + // 보낸 바이트 수(UTF-8)로 한 번 정한다 + expect(verdictTimeoutMs.mock.calls).toEqual([[Buffer.byteLength(content)]]); + }); + + it('판정 대기 시간이 지나면 unavailable — 어느 단계 · 보낸 바이트 수를 글에 남긴다', async () => { + const silent = await start(() => null); + const error = await unavailableOf(() => + scanWithClamav( + options(silent.port, { + idleTimeoutMs: 60_000, + verdictTimeoutMs: (bytes) => (bytes === 6 ? 200 : 60_000), + }), + fileOf('내용'), + ), + ); + expect(error.failure).toBe('unavailable'); + expect(error.message).toContain( + '다 받은 뒤 0.2초 안에 판정을 보내지 않았습니다', + ); + expect(error.message).toContain('보낸 6바이트'); + }); + + it('흘려보내는 동안은 유휴 시간(idleTimeoutMs)으로 끊는다 — 판정 대기 시간은 아직 쓰지 않는다', async () => { + const silent = await start(() => null); + const verdictTimeoutMs = jest.fn(() => 60_000); + const error = await unavailableOf(() => + scanWithClamav( + options(silent.port, { idleTimeoutMs: 200, verdictTimeoutMs }), + unfinishedFile('앞부분'), + ), + ); + expect(error.failure).toBe('unavailable'); + expect(error.message).toContain('흐름을 받지 않았습니다'); + expect(error.message).toContain('흘려보내는 중(보낸 9바이트)'); + expect(verdictTimeoutMs).not.toHaveBeenCalled(); + }); + + it('크기 한도를 넘은 흐름을 clamd 가 RST 로 끊어 오류 글이 오지 않으면 unavailable — 크기 오류인지 알 수 없으니 다른 끊김처럼 다시 하고, 흘려보내는 중 · 보낸 바이트 수를 남긴다', async () => { + const reset = await start(() => 'stream: OK', 8, 'reset'); + const source = unfinishedFile('0123456789abcdefghij'); + const error = await unavailableOf(() => + scanWithClamav(options(reset.port), source), + ); + expect(error.failure).toBe('unavailable'); + expect(error.message).toContain('판정 글을 보내기 전에 연결을 닫았습니다'); + expect(error.message).toContain('흘려보내는 중(보낸 20바이트)'); + expect(source.destroyed).toBe(true); + }); + + it('연결하면 keepalive 를 켜고, 그것이 사라진 상대를 끊으면(read ETIMEDOUT — RST 도 없이) 기존 끊김과 같다 — unavailable(미뤄서 다시) · 같은 글에 까닭만 다르다', async () => { + // 기존 끊김 — 다 보낸 뒤 clamd 가 RST 로 끊는다 + const reset = await start(() => (socket) => socket.resetAndDestroy()); + const byReset = await unavailableOf(() => + scanWithClamav(options(reset.port), fileOf('내용')), + ); + + // keepalive — 답 없는 clamd 앞에서 커널이 끊는다. 판정을 기다리기 시작한 때 Node 가 그때 하는 대로 소켓을 그 오류로 닫는다 + const silent = await start(() => null); + const captured = captureClamdSockets(); + const byKeepAlive = await unavailableOf(() => + scanWithClamav( + options(silent.port, { + keepAliveInitialDelayMs: 45_000, + verdictTimeoutMs: () => { + setImmediate(() => captured.sockets[0].destroy(keepAliveTimeout())); + return 60_000; + }, + }), + fileOf('내용'), + ), + ); + + expect(captured.sockets).toHaveLength(1); + expect(captured.keepAlive[0]).toHaveBeenCalledWith(true, 45_000); + expect(byKeepAlive.failure).toBe('unavailable'); + expect(byKeepAlive.failure).toBe(byReset.failure); + expect(byKeepAlive.message).toBe( + 'clamd 가 판정 글을 보내기 전에 연결을 닫았습니다 — 다 보낸 뒤(보낸 6바이트), 받은 0바이트 (read ETIMEDOUT)', + ); + expect(withoutCause(byKeepAlive.message)).toBe( + withoutCause(byReset.message), + ); + }); + + it('검사를 멈추면(서버 종료) 연결과 파일을 닫는다', async () => { + const silent = await start(() => null); + const controller = new AbortController(); + const source = fileOf('내용'); + const scanning = scanWithClamav( + options(silent.port), + source, + controller.signal, + ); + setTimeout(() => controller.abort(), 50); + await expect(scanning).rejects.toBeInstanceOf(ClamavUnavailableError); + expect(source.destroyed).toBe(true); + }); +}); diff --git a/backend/src/modules/file-storage/clamav-client.ts b/backend/src/modules/file-storage/clamav-client.ts new file mode 100644 index 0000000..ca69609 --- /dev/null +++ b/backend/src/modules/file-storage/clamav-client.ts @@ -0,0 +1,291 @@ +import { connect } from 'net'; +import type { Readable } from 'stream'; + +/** + * clamd 의 INSTREAM 규약을 직접 구현한다 (새 의존성 없음 — 계약 4절 "악성 검사"). + * + * `zINSTREAM\0` 을 보낸 뒤 내용을 조각마다 [길이 4바이트(빅엔디언) + 바이트]로 흘려보내고, 길이 0 으로 끝낸다. + * clamd 는 판정 한 줄을 `\0` 으로 끝내 보내고 연결을 닫는다 — **`\0` 까지 받은 글만 판정으로 본다** + * (그 전에 끊기면(RST · 빈 응답 · 끝나지 않은 글) 판정이 없는 것이다 — 다시 한다). 판정 글의 갈래는 아래 CLAMAV_REPLY 한 곳에서 본다: + * - `stream: OK` → 깨끗함 + * - `stream: <이름> FOUND` → 위험 — 이름이 암호 압축 · 압축 안의 검사 한도(Heuristics.Encrypted.* · Heuristics.Limits.Exceeded.*)면 검사하지 못함 + * - `INSTREAM size limit exceeded. ERROR` → **설정 오류**(clamd 의 StreamMaxLength 가 한 파일 한도보다 작다) — 판정 없음, 부르는 쪽이 error 로 남기고 다시 한다 + * - 그 밖의 `… ERROR` · 모르는 모양 → 판정 없음 (다시 한다) + * 판정이 없으면 ClamavUnavailableError(까닭 `failure`) — 부르는 쪽이 검사 중 그대로 두고 간격을 늘려 다시 한다. + * **판정 없이 받을 수 있게 되는 일은 없다**(닫힌 쪽). + * + * 기다리는 시간은 세 단계다 — 연결(connectTimeoutMs), 흘려보내는 동안 아무것도 오가지 않는 시간(idleTimeoutMs), + * 다 보낸 뒤 판정까지(verdictTimeoutMs(보낸 바이트 수) — clamd 는 다 받은 뒤에야 검사하고 그동안 아무것도 보내지 않는다. 큰 파일일수록 길다). + * 연결하면 TCP keepalive 를 켠다(keepAliveInitialDelayMs) — 긴 판정 대기 동안 사라진 상대(RST 도 없이)를 커널이 `read ETIMEDOUT` 으로 끊는다. + * 시간 초과 · 끊김의 글에는 어느 단계였는지와 보낸 바이트 수를 싣는다 — StreamMaxLength 를 넘은 흐름을 clamd 가 RST 로 끊으면 + * 크기 오류 글이 오지 않고 EPIPE · ECONNRESET 만 남는다(판정 없음 → 다른 끊김처럼 다시 하고, 이어지면 부르는 쪽이 error 로 남긴다). + * 그때 "흘려보내는 중 · 보낸 N바이트"가 시도마다 같으면 크기 한도를 의심한다 + * + * clamd 는 암호화하지 않은 TCP 다 — 백엔드와만 잇는 내부 네트워크에 둔다 (release-ops). + */ + +/** clamd 판정 */ +export type ClamavVerdict = + | { readonly kind: 'clean' } + | { readonly kind: 'infected'; readonly signature: string } + /** 검사하지 못함 — 암호 압축 · 압축 안의 검사 한도 (signature 는 clamd 가 준 이름) */ + | { readonly kind: 'unscannable'; readonly signature: string }; + +/** + * 판정을 받지 못한 까닭 — 어느 것이든 검사 중 그대로 두고 다시 한다 + * - unavailable: 닿지 못함 · 시간 초과 · 판정 글을 다 받기 전에 끊김(RST · 빈 응답) · 서버 종료 + * - error-reply: clamd 가 오류로 답했다 · 모르는 모양의 답 + * - misconfigured: 설정 오류 — 흐름이 clamd 의 StreamMaxLength 를 넘었다(한 파일 한도보다 작게 설정됨). 다시 해도 같으니 바로 error 로 남긴다 + */ +export type ClamavFailure = 'unavailable' | 'error-reply' | 'misconfigured'; + +/** 판정을 받지 못했다 — 까닭은 `failure` */ +export class ClamavUnavailableError extends Error { + constructor( + message: string, + readonly failure: ClamavFailure = 'unavailable', + ) { + super(message); + this.name = 'ClamavUnavailableError'; + } +} + +/** + * clamd 의 판정 글 — 견주기는 이 표와 classifyClamavReply 한 곳에만 둔다. + * ⚠️ 실제 clamd 의 응답 모양은 release-ops 가 재는 중이다(2026-10-30) — 실측이 다르면 이 표(와 clamav-client.spec.ts 의 표본)만 고친다 + */ +export const CLAMAV_REPLY = { + /** INSTREAM 판정 줄의 머리 — `stream: …` */ + streamPrefix: 'stream: ', + /** 깨끗함 — `stream: OK` */ + clean: 'OK', + /** 판정 이름의 뒤 — `stream: <이름> FOUND` */ + foundSuffix: ' FOUND', + /** clamd 의 오류 응답의 끝 — `<글> ERROR` */ + errorSuffix: 'ERROR', + /** 흐름이 StreamMaxLength 를 넘었다는 오류 응답 — `INSTREAM size limit exceeded. ERROR` (설정 오류) */ + streamSizeLimit: /\bsize limit exceeded\b/i, + /** 위험이 아니라 "검사하지 못함"인 판정 이름 — 암호 압축 · 압축 안의 검사 한도(파일 크기 · 개수 · 깊이 등) */ + unscannableSignature: /^Heuristics\.(?:Encrypted|Limits\.Exceeded)(?:\.|$)/, +} as const; + +export interface ClamavConnection { + readonly host: string; + readonly port: number; + /** 연결까지 */ + readonly connectTimeoutMs: number; + /** 흘려보내는 동안 아무것도 오가지 않는 시간의 상한 */ + readonly idleTimeoutMs: number; + /** + * 다 보낸 뒤(길이 0 을 쓴 뒤) 판정을 기다리는 시간 — 보낸 바이트 수로 정한다 (file-storage.constants.ts 의 clamavVerdictTimeoutMs). + * 마지막 쓰기가 끝난 때부터 센다(소켓의 유휴 시간이라 쓰기가 끝날 때마다 다시 센다) + */ + readonly verdictTimeoutMs: (streamedBytes: number) => number; + /** TCP keepalive 의 첫 대기 — 연결하면 켠다(사라진 상대를 판정 대기보다 일찍 알아차린다 — CLAMAV_KEEPALIVE_INITIAL_DELAY_MS) */ + readonly keepAliveInitialDelayMs: number; +} + +/** 판정 한 줄 상한 — 그보다 길게 `\0` 없이 오면 clamd 가 아니다 */ +const MAX_REPLY_BYTES = 4_096; + +const INSTREAM_COMMAND = Buffer.from('zINSTREAM\0', 'latin1'); +const END_OF_STREAM = Buffer.alloc(4); + +/** + * clamd 응답 → 판정 (순수 함수 — 판정 글 견주기는 여기 한 곳). `\0` 까지 받은 글만 판정이다. + * 판정이 아니면 ClamavUnavailableError 를 던진다 — 끝나지 않은 글 unavailable, 크기 한도 misconfigured, 그 밖의 오류 · 모르는 모양 error-reply + */ +export function classifyClamavReply(raw: string): ClamavVerdict { + const end = raw.indexOf('\0'); + if (end < 0) { + throw new ClamavUnavailableError( + `clamd 가 판정 글을 끝까지 보내지 않았습니다 (${Buffer.byteLength(raw)}바이트)`, + ); + } + const reply = raw.slice(0, end).trim(); + if (reply.endsWith(CLAMAV_REPLY.errorSuffix)) { + if (CLAMAV_REPLY.streamSizeLimit.test(reply)) { + throw new ClamavUnavailableError( + `clamd 의 흐름 크기 한도(StreamMaxLength)에 걸렸습니다: ${reply.slice(0, 200)}`, + 'misconfigured', + ); + } + throw new ClamavUnavailableError( + `clamd 오류 응답: ${reply.slice(0, 200)}`, + 'error-reply', + ); + } + if (reply === `${CLAMAV_REPLY.streamPrefix}${CLAMAV_REPLY.clean}`) { + return { kind: 'clean' }; + } + if ( + reply.startsWith(CLAMAV_REPLY.streamPrefix) && + reply.endsWith(CLAMAV_REPLY.foundSuffix) + ) { + const signature = reply + .slice(CLAMAV_REPLY.streamPrefix.length, -CLAMAV_REPLY.foundSuffix.length) + .trim(); + if (signature !== '') { + return CLAMAV_REPLY.unscannableSignature.test(signature) + ? { kind: 'unscannable', signature } + : { kind: 'infected', signature }; + } + } + throw new ClamavUnavailableError( + `clamd 응답을 해석하지 못했습니다: ${reply.slice(0, 200)}`, + 'error-reply', + ); +} + +/** 검사가 어디까지 왔나 — 시간 초과 · 끊김의 글에 싣는다 */ +type ScanPhase = 'connecting' | 'streaming' | 'awaiting-verdict'; + +/** 기다린 시간(초) — 오류 글용 (`2,647` · `0.2`) */ +const secondsOf = (ms: number) => + (ms / 1000).toLocaleString('en-US', { maximumFractionDigits: 1 }); + +/** + * 내용을 clamd 에 흘려보내고 판정을 받는다 — 내용은 다 읽거나 실패하면 닫는다(파일 핸들을 남기지 않는다). + * `signal` 이 끊기면(서버 종료) 연결을 닫고 ClamavUnavailableError + */ +export function scanWithClamav( + connection: ClamavConnection, + source: Readable, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const socket = connect({ host: connection.host, port: connection.port }); + const replyParts: Buffer[] = []; + let replyBytes = 0; + let streamedBytes = 0; + let phase: ScanPhase = 'connecting'; + let verdictTimeoutMs = 0; + let settled = false; + let lastError: unknown = null; + + /** 시간이 지났을 때의 글 — 어느 단계에서 얼마나 기다렸는지 */ + const timeoutMessage = (): string => { + const sent = `보낸 ${streamedBytes.toLocaleString('en-US')}바이트`; + switch (phase) { + case 'connecting': + return `clamd 에 ${secondsOf(connection.connectTimeoutMs)}초 안에 닿지 못했습니다`; + case 'streaming': + return `clamd 가 ${secondsOf(connection.idleTimeoutMs)}초 동안 흐름을 받지 않았습니다 — 흘려보내는 중(${sent})`; + case 'awaiting-verdict': + return `clamd 가 다 받은 뒤 ${secondsOf(verdictTimeoutMs)}초 안에 판정을 보내지 않았습니다 — ${sent}`; + } + }; + /** 판정 글 전에 끊겼을 때의 단계 */ + const closedWhere = (): string => { + const sent = `보낸 ${streamedBytes.toLocaleString('en-US')}바이트`; + switch (phase) { + case 'connecting': + return '연결하는 중'; + case 'streaming': + return `흘려보내는 중(${sent})`; + case 'awaiting-verdict': + return `다 보낸 뒤(${sent})`; + } + }; + + const cleanup = () => { + signal?.removeEventListener('abort', onAbort); + if (!source.destroyed) source.destroy(); + if (!socket.destroyed) socket.destroy(); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject( + error instanceof ClamavUnavailableError + ? error + : new ClamavUnavailableError( + error instanceof Error ? error.message : String(error), + ), + ); + }; + const finishWithReply = () => { + if (settled) return; + settled = true; + cleanup(); + try { + resolve( + classifyClamavReply(Buffer.concat(replyParts).toString('utf8')), + ); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }; + function onAbort() { + fail(new ClamavUnavailableError('검사를 멈췄습니다 (서버 종료)')); + } + + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener('abort', onAbort, { once: true }); + + socket.setTimeout(connection.connectTimeoutMs); + socket.on('timeout', () => + fail(new ClamavUnavailableError(timeoutMessage())), + ); + socket.on('error', (error) => { + // 크기 한도로 clamd 가 먼저 닫으면 쓰기가 EPIPE · ECONNRESET 으로 끝난다 — 판정 글(`\0` 까지)을 이미 받았으면 그것을 쓴다 + lastError = error; + }); + socket.on('close', () => { + // `\0` 까지 받았으면 data 에서 이미 끝냈다 — 여기 오면 판정 글 전에 끊긴 것이다(RST · 빈 응답 · 끝나지 않은 글): 다시 한다. + // 크기 한도를 RST 로 끊어 오류 글이 오지 않은 것도 여기다 — 크기 오류인지 알 수 없으니 다른 끊김과 같이 다룬다(단계 · 보낸 바이트 수를 남긴다) + const cause = lastError instanceof Error ? ` (${lastError.message})` : ''; + fail( + new ClamavUnavailableError( + phase === 'connecting' + ? `clamd 에 닿지 못했습니다${cause}` + : `clamd 가 판정 글을 보내기 전에 연결을 닫았습니다 — ${closedWhere()}, 받은 ${replyBytes}바이트${cause}`, + ), + ); + }); + socket.on('data', (data: Buffer) => { + replyParts.push(data); + replyBytes += data.length; + if (data.includes(0)) finishWithReply(); + else if (replyBytes > MAX_REPLY_BYTES) { + fail( + new ClamavUnavailableError('clamd 응답이 너무 깁니다', 'error-reply'), + ); + } + }); + + socket.once('connect', () => { + phase = 'streaming'; + // 판정 대기가 길다(최대 1시간 남짓) — 컨테이너째 사라져 RST 도 오지 않는 상대는 커널이 묻고 답이 없으면 ETIMEDOUT 으로 끊는다(아래 close — 다른 끊김과 같다) + socket.setKeepAlive(true, connection.keepAliveInitialDelayMs); + socket.setTimeout(connection.idleTimeoutMs); + socket.write(INSTREAM_COMMAND); + source.on('data', (piece: Buffer | string) => { + const data = Buffer.isBuffer(piece) ? piece : Buffer.from(piece); + if (data.length === 0) return; + streamedBytes += data.length; + const header = Buffer.alloc(4); + header.writeUInt32BE(data.length, 0); + socket.write(header); + // 소켓이 밀리면 파일 읽기를 멈춘다 — 2GB 를 메모리에 쌓지 않게 + if (!socket.write(data)) { + source.pause(); + socket.once('drain', () => source.resume()); + } + }); + source.once('end', () => { + if (socket.destroyed) return; + socket.write(END_OF_STREAM); + // 여기서부터 clamd 가 검사한다 — 판정까지 아무것도 오가지 않으니 보낸 크기에 맞춰 기다린다 + phase = 'awaiting-verdict'; + verdictTimeoutMs = connection.verdictTimeoutMs(streamedBytes); + socket.setTimeout(verdictTimeoutMs); + }); + source.once('error', fail); + }); + }); +} diff --git a/backend/src/modules/file-storage/dto/file-upload.dto.ts b/backend/src/modules/file-storage/dto/file-upload.dto.ts new file mode 100644 index 0000000..f26de94 --- /dev/null +++ b/backend/src/modules/file-storage/dto/file-upload.dto.ts @@ -0,0 +1,221 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +import { + MaxCodeUnits, + MinCodeUnits, +} from '@/common/decorators/code-units.decorator'; +import { PageInfoDto } from '@/common/dto/pagination.dto'; +import { + FILE_CHUNK_SIZE, + FILE_PURPOSES, + MAX_UPLOAD_NAME_LENGTH, +} from '../file-storage.constants'; +import type { + FileQuota, + LargeFile, + MissingLargeFile, + ScanStatus, +} from '../file-storage.types'; + +const SCAN_STATUSES: ScanStatus[] = [ + 'pending', + 'clean', + 'infected', + 'unscannable', +]; + +/** POST /api/file-uploads — 계약 4절 "올리기" 1 */ +export class FileUploadCreateDto { + @ApiProperty({ + description: `파일 이름 (1~${MAX_UPLOAD_NAME_LENGTH}자 — JS 문자열 길이). 서버가 정리한 이름(응답의 name)으로 둔다 — 경로 · 제어 · 방향 제어 문자를 지운다. 형식은 정리한 이름의 확장자로만 본다(밖이면 400 FILE_001)`, + example: '현장영상_1029.mp4', + }) + // 형식 → 길이 차례로 (적은 차례대로 돈다 — applyDecorators) + @applyDecorators( + IsString({ message: '파일 이름 형식이 올바르지 않습니다.' }), + MinCodeUnits(1, { message: '파일 이름이 비어 있습니다.' }), + MaxCodeUnits(MAX_UPLOAD_NAME_LENGTH, { + message: `파일 이름은 ${MAX_UPLOAD_NAME_LENGTH}자 이하여야 합니다.`, + }), + ) + name!: string; + + @ApiProperty({ + description: + '바이트 — 1 이상 정수. 한 파일 한도(GET /api/file-quota 의 maxFileBytes)를 넘으면 413 FILE_002', + example: 1_288_490_189, + }) + @IsInt({ message: '파일 크기는 정수여야 합니다.' }) + @Min(1, { message: '빈 파일은 올릴 수 없습니다.' }) + @Max(Number.MAX_SAFE_INTEGER, { message: '파일 크기가 너무 큽니다.' }) + size!: number; + + @ApiProperty({ + description: "쓰임 — 11단계는 'mail' 만 (2부 파일 저장소가 더한다)", + enum: FILE_PURPOSES, + example: 'mail', + }) + @IsIn(FILE_PURPOSES, { + message: '파일의 쓰임이 올바르지 않습니다.', + }) + purpose!: string; + + @ApiPropertyOptional({ + description: + '브라우저 File.lastModified (ms, 0 이상 정수) — 새로고침 뒤 다시 고른 파일이 같은지 맞춰 볼 때 쓴다', + example: 1_793_241_600_000, + nullable: true, + type: Number, + }) + @IsOptional() + @IsInt({ message: '파일의 고친 시각 형식이 올바르지 않습니다.' }) + @Min(0, { message: '파일의 고친 시각 형식이 올바르지 않습니다.' }) + @Max(Number.MAX_SAFE_INTEGER, { + message: '파일의 고친 시각 형식이 올바르지 않습니다.', + }) + lastModified?: number | null; +} + +export class LargeFileDto implements LargeFile { + @ApiProperty({ + description: '올리기 id = 다 올린 뒤의 파일 id (바뀌지 않는다)', + example: '0f8fad5b-d9cb-469f-a165-70867728950e', + }) + id!: string; + + @ApiProperty({ + description: '서버가 정리한 이름', + example: '현장영상_1029.mp4', + }) + name!: string; + + @ApiProperty({ description: '바이트', example: 1_288_490_189 }) + size!: number; + + @ApiProperty({ + description: '올리는 중 · 다 올림', + enum: ['uploading', 'ready'], + }) + status!: 'uploading' | 'ready'; + + @ApiProperty({ + description: `조각 크기 — ${FILE_CHUNK_SIZE.toLocaleString('ko-KR')} (마지막 조각만 짧다)`, + example: FILE_CHUNK_SIZE, + }) + chunkSize!: number; + + @ApiProperty({ description: 'ceil(size / chunkSize)', example: 77 }) + chunkCount!: number; + + @ApiProperty({ + description: '받은 조각 번호(0 부터, 오름차순) — ready 면 모두', + type: [Number], + example: [0, 1, 2], + }) + receivedChunks!: number[]; + + @ApiProperty({ + description: + '받은 0번 조각의 SHA-256 (base64) — 받기 전이면 null. 새로고침 뒤 다시 고른 파일이 같은지 맞춰 본다', + nullable: true, + type: String, + }) + firstChunkSha256!: string | null; + + @ApiProperty({ + description: + '악성 검사 — ready 일 때만 (uploading 은 null). pending 검사 중 · clean 깨끗함 · infected 위험 · unscannable 검사하지 못함', + enum: SCAN_STATUSES, + nullable: true, + }) + scanStatus!: ScanStatus | null; + + @ApiProperty({ description: '만든 때 (ISO 8601 UTC)' }) + createdAt!: string; + + @ApiProperty({ + description: + '이대로 두면 지워지는 때 — 올리는 중은 마지막 조각 뒤 24시간, 어디에도 실리지 않은 파일은 다 올린 뒤 24시간, 임시 저장에 실린 파일은 다 올린 날(한국)부터 30일째 날 끝, 보낸 파일은 링크가 끝나는 때', + }) + expiresAt!: string; + + @ApiProperty({ + description: '만들 때 받은 File.lastModified (ms)', + nullable: true, + type: Number, + }) + lastModified!: number | null; +} + +export class MissingLargeFileDto implements MissingLargeFile { + @ApiProperty({ description: '파일 id' }) + id!: string; + + @ApiProperty({ description: '없는 파일', enum: ['missing'] }) + status!: 'missing'; + + @ApiProperty({ + description: + 'deleted 지운 파일(설정 · 쓰기 화면에서 지움) · expired 기간이 지남(올리다 멈춘 지 24시간 · 임시 저장 30일 등)', + enum: ['deleted', 'expired'], + }) + reason!: 'deleted' | 'expired'; + + @ApiProperty({ + description: '기록이 남아 있으면(지운 뒤 30일) 이름', + nullable: true, + type: String, + }) + name!: string | null; + + @ApiProperty({ + description: '기록이 남아 있으면 크기', + nullable: true, + type: Number, + }) + size!: number | null; +} + +export class LargeFilePageDto { + @ApiProperty({ + description: + '보내지 않은 내 파일 — 살아 있는 링크에 실리지 않은 것(올리는 중 포함), 만든 때 최근부터', + type: [LargeFileDto], + }) + items!: LargeFileDto[]; + + @ApiProperty({ description: '페이지 정보 (total 포함)', type: PageInfoDto }) + pageInfo!: PageInfoDto; +} + +export class FileQuotaDto implements FileQuota { + @ApiProperty({ + description: + '지금 잡고 있는 양 — 올리는 중(선언한 크기) + 보내지 않은 파일 + 기간 안의 보낸 파일 (위험 판정 파일은 빠진다)', + example: 6_657_199_309, + }) + usedBytes!: number; + + @ApiProperty({ description: '직원당 보관량 한도', example: 10_737_418_240 }) + limitBytes!: number; + + @ApiProperty({ description: '한 파일 한도', example: 2_146_435_072 }) + maxFileBytes!: number; + + @ApiProperty({ + description: '메일 한 통에 새로 싣는 대용량 파일 수', + example: 10, + }) + maxFilesPerMessage!: number; + + @ApiProperty({ + description: '조각 크기 — 올리기 응답의 chunkSize 와 같다', + example: FILE_CHUNK_SIZE, + }) + chunkSize!: number; + + @ApiProperty({ description: '보낸 날부터 받을 수 있는 날 수', example: 30 }) + retentionDays!: number; +} diff --git a/backend/src/modules/file-storage/file-blob-store.ts b/backend/src/modules/file-storage/file-blob-store.ts new file mode 100644 index 0000000..bd31a21 --- /dev/null +++ b/backend/src/modules/file-storage/file-blob-store.ts @@ -0,0 +1,84 @@ +import type { Readable } from 'stream'; + +/** + * 파일 바이트 저장 포트 (11단계 — 계약 4절 "엔진에 두지 않는다") — 서비스는 이 포트만 안다. + * 지금은 로컬 디스크(local-disk-blob-store.ts — 볼륨 `/app/files`), 인스턴스를 늘리면 S3 호환 어댑터를 같은 포트로 만든다 + * (조각 하나 = multipart 의 part 하나, 끝내기 = CompleteMultipartUpload — nestjs 스킬 "확장 때 옮길 것"). + * + * 키는 서버가 만든 파일 id(UUID)다 — 바깥 값이 경로가 되지 않는다. + */ +export const FILE_BLOB_STORE = Symbol('FILE_BLOB_STORE'); + +/** 조각을 다 쓴 결과 */ +export interface WrittenChunk { + /** 쓴 바이트 수 — expectedBytes 와 같을 때만 돌아온다 */ + readonly bytes: number; + /** 받은 바이트의 SHA-256 */ + readonly sha256: Buffer; +} + +/** 조각 본문이 선언한 길이보다 짧게 끝났다(클라이언트가 끊음) · 길다 — 그 조각은 받지 않은 것이다 */ +export class ChunkLengthMismatchError extends Error { + constructor( + readonly expected: number, + readonly received: number, + ) { + super(`조각 길이가 맞지 않습니다: 기대 ${expected}, 받음 ${received}`); + } +} + +/** 저장소의 한 항목 (정리 작업용) */ +export interface StoredBlob { + readonly key: string; + /** 올리는 중의 자리 · 다 올린 파일 */ + readonly stage: 'incoming' | 'stored'; + readonly modifiedAt: Date; +} + +/** 끝 포함 바이트 범위 */ +export interface BlobRange { + readonly start: number; + readonly end: number; +} + +export interface FileBlobStore { + /** 올리기 자리를 만든다 — 선언한 크기(조각을 차례 없이 제자리에 쓴다) */ + begin(key: string, size: number): Promise; + /** + * 조각 하나를 offset 부터 쓴다 — source 를 끝까지 읽으며 SHA-256 을 함께 계산하고, 다 쓴 뒤 디스크에 내려쓴다(datasync). + * 길이가 expectedBytes 와 다르면 ChunkLengthMismatchError, 디스크가 차면 ENOSPC 오류 그대로 + */ + writeChunk( + key: string, + offset: number, + expectedBytes: number, + source: Readable, + ): Promise; + /** 올리기를 끝낸다 — 읽을 수 있는 파일로 옮긴다 (원자적) */ + complete(key: string): Promise; + /** 다 올린 파일이 있는가 (끊긴 끝내기를 되살릴 때) */ + isStored(key: string): Promise; + /** 올리는 중의 자리가 있는가 */ + isIncoming(key: string): Promise; + /** + * 다 올린 파일을 읽는다 — 파일을 연 뒤에 돌아온다(없으면 ENOENT 오류 그대로 — 머리글을 쓰기 전에 알 수 있게). + * range 가 있으면 그 범위만(끝 포함) + */ + openRead(key: string, range?: BlobRange): Promise; + /** 올리는 중의 자리와 다 올린 파일을 모두 지운다 — 이미 없으면 그대로 */ + remove(key: string): Promise; + /** 저장소의 모든 항목 (정리 작업 — 표에 없는 파일 찾기) */ + list(): Promise; + /** 남은 공간(바이트) — 알 수 없으면 null */ + freeBytes(): Promise; +} + +/** 디스크가 찼다 (ENOSPC) — 507 FILE_006 */ +export function isDiskFull(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOSPC'; +} + +/** 없는 파일 (ENOENT) */ +export function isMissingFile(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'; +} diff --git a/backend/src/modules/file-storage/file-id.pipe.spec.ts b/backend/src/modules/file-storage/file-id.pipe.spec.ts new file mode 100644 index 0000000..6b2fd6d --- /dev/null +++ b/backend/src/modules/file-storage/file-id.pipe.spec.ts @@ -0,0 +1,58 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { ChunkIndexPipe, FileIdPipe } from './file-id.pipe'; + +const codeOf = (run: () => unknown) => { + try { + run(); + return null; + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } +}; + +describe('FileIdPipe · ChunkIndexPipe', () => { + it('UUID 모양만 받고 소문자로 — 아니면 404 RES_001', () => { + const pipe = new FileIdPipe(); + expect(pipe.transform('0F8FAD5B-D9CB-469F-A165-70867728950E')).toBe( + '0f8fad5b-d9cb-469f-a165-70867728950e', + ); + expect(codeOf(() => pipe.transform('abc'))).toBe('RES_001'); + expect(codeOf(() => pipe.transform('../etc/passwd'))).toBe('RES_001'); + }); + + it('조각 번호는 앞자리 0 없는 10진수 글만 — 아니면 400 VAL_001', () => { + const pipe = new ChunkIndexPipe(); + expect(pipe.transform('0')).toBe(0); + expect(pipe.transform('127')).toBe(127); + expect(pipe.transform('999999')).toBe(999_999); + for (const value of [ + '01', + '007', + '-1', + '+3', + ' 2', + '2 ', + '1.5', + '1.0', + '1e1', + '0x1', + '0b11', + 'a', + '', + '1000000', + '١', + ]) { + expect({ value, code: codeOf(() => pipe.transform(value)) }).toEqual({ + value, + code: 'VAL_001', + }); + } + }); + + it('숫자로 오면 거절한다 — 전역 ValidationPipe 가 바꾼 값(매개변수를 number 로 적은 것)을 받지 않는다', () => { + const pipe = new ChunkIndexPipe(); + for (const value of [0, 1, Number.NaN, null, undefined]) { + expect(codeOf(() => pipe.transform(value))).toBe('VAL_001'); + } + }); +}); diff --git a/backend/src/modules/file-storage/file-id.pipe.ts b/backend/src/modules/file-storage/file-id.pipe.ts new file mode 100644 index 0000000..ba1ab97 --- /dev/null +++ b/backend/src/modules/file-storage/file-id.pipe.ts @@ -0,0 +1,42 @@ +import { Injectable, PipeTransform } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; + +/** 파일 · 링크 id — 서버가 만든 UUID (대소문자 무시, 소문자로 다룬다) */ +export const FILE_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * 경로의 파일 · 링크 id 형식 확인 — UUID 모양이 아니면 "그런 대상이 없다"와 같으므로 404 RES_001 (계약 4절 — DB 에 묻지 않는다) + */ +@Injectable() +export class FileIdPipe implements PipeTransform { + transform(value: unknown): string { + if (typeof value === 'string' && FILE_ID_PATTERN.test(value)) { + return value.toLowerCase(); + } + throw new BusinessException('RES_001', { + context: { reason: 'malformed-file-id' }, + }); + } +} + +/** 조각 번호 — 0 이상 정수, 앞자리 0 없는 10진수 6자리까지(999,999 — 2,047MB 는 128조각이라 넉넉하다) */ +const CHUNK_INDEX_PATTERN = /^(?:0|[1-9][0-9]{0,5})$/; + +/** + * 경로의 조각 번호 — 앞자리 0 없는 10진수 글만 받는다(`1e1` · `0x1` · `+3` · ` 2` · `007` · `1.0` · `0b11` 은 400 VAL_001). 범위(chunkCount)는 서비스가 본다. + * ⚠️ 처리기의 매개변수 타입을 `unknown` 으로 둔다 — `number` 로 적으면 전역 ValidationPipe(transform)가 이 파이프 **앞에서** 글을 `+value` 로 + * 숫자로 바꿔 넘겨 위의 모양을 받아 버린다(2026-10-30 리뷰 L3). 그래서 숫자가 오면 거절한다 — 매개변수 타입이 바뀐 것을 테스트가 알아챈다 + */ +@Injectable() +export class ChunkIndexPipe implements PipeTransform { + transform(value: unknown): number { + if (typeof value === 'string' && CHUNK_INDEX_PATTERN.test(value)) { + return Number(value); + } + throw new BusinessException('VAL_001', { + context: { reason: 'malformed-chunk-index' }, + }); + } +} diff --git a/backend/src/modules/file-storage/file-retention.spec.ts b/backend/src/modules/file-storage/file-retention.spec.ts new file mode 100644 index 0000000..b7673c3 --- /dev/null +++ b/backend/src/modules/file-storage/file-retention.spec.ts @@ -0,0 +1,144 @@ +import { + fileExpiresAt, + isOnLiveLink, + type RetentionInput, +} from './file-retention'; + +const DAY = 24 * 60 * 60 * 1000; +const at = (iso: string) => new Date(iso); +const NOW = at('2026-10-30T05:00:00.000Z'); + +const base = (overrides: Partial = {}): RetentionInput => ({ + state: 'ready', + createdAt: at('2026-10-30T01:00:00.000Z'), + lastChunkAt: at('2026-10-30T02:00:00.000Z'), + completedAt: at('2026-10-30T03:00:00.000Z'), + draftAttachedAt: null, + linkedAt: null, + linkedUntil: null, + scanStatus: 'clean', + scannedAt: null, + removedAt: null, + ...overrides, +}); + +describe('fileExpiresAt — 계약 4절 "기간 · 보관 · 정리"', () => { + it('올리는 중은 마지막 조각(없으면 만든 때) 뒤 24시간 — 임시 저장에 실려도 같다', () => { + expect( + fileExpiresAt( + base({ state: 'uploading', completedAt: null, scanStatus: null }), + 30, + NOW, + ).toISOString(), + ).toBe('2026-10-31T02:00:00.000Z'); + expect( + fileExpiresAt( + base({ + state: 'uploading', + completedAt: null, + lastChunkAt: null, + scanStatus: null, + draftAttachedAt: NOW, + }), + 30, + NOW, + ).toISOString(), + ).toBe('2026-10-31T01:00:00.000Z'); + }); + + it('다 올렸지만 어디에도 실리지 않은 파일은 다 올린 뒤 24시간', () => { + expect(fileExpiresAt(base(), 30, NOW).toISOString()).toBe( + '2026-10-31T03:00:00.000Z', + ); + }); + + it('임시 저장에 실린 파일은 다 올린 날(한국)부터 30일째 날 끝', () => { + expect( + fileExpiresAt(base({ draftAttachedAt: NOW }), 30, NOW).toISOString(), + ).toBe('2026-11-29T14:59:59.999Z'); + }); + + it('보낸 파일은 살아 있는 링크의 끝 — 보낸 뒤로 임시 저장에 실리지 않았으면 임시 저장 기간은 남기지 않는다', () => { + const linkEnd = at('2026-12-01T14:59:59.999Z'); + expect( + fileExpiresAt( + base({ + draftAttachedAt: at('2026-10-30T04:00:00.000Z'), + linkedAt: at('2026-10-30T04:30:00.000Z'), + linkedUntil: linkEnd, + }), + 30, + NOW, + ), + ).toEqual(linkEnd); + }); + + it('링크를 끊으면(끊지 않은 링크가 없음) 곧 — 다른 쓰임이 없을 때', () => { + expect( + fileExpiresAt( + base({ + draftAttachedAt: at('2026-10-30T04:00:00.000Z'), + linkedAt: at('2026-10-30T04:30:00.000Z'), + linkedUntil: null, + }), + 30, + NOW, + ), + ).toEqual(NOW); + }); + + it('보낸 뒤에 다시 임시 저장에 실리면(취소해 되돌린 발송 등) 임시 저장 기간으로 남는다', () => { + expect( + fileExpiresAt( + base({ + draftAttachedAt: at('2026-10-30T04:45:00.000Z'), + linkedAt: at('2026-10-30T04:30:00.000Z'), + linkedUntil: null, + }), + 30, + NOW, + ).toISOString(), + ).toBe('2026-11-29T14:59:59.999Z'); + }); + + it('위험 판정 파일은 판정 뒤 30일(기록) — 살아 있는 링크가 더 늦게 끝나면 그때까지', () => { + const scannedAt = at('2026-10-30T04:00:00.000Z'); + expect( + fileExpiresAt( + base({ scanStatus: 'infected', scannedAt, draftAttachedAt: NOW }), + 30, + NOW, + ), + ).toEqual(new Date(scannedAt.getTime() + 30 * DAY)); + const linkEnd = at('2027-01-01T00:00:00.000Z'); + expect( + fileExpiresAt( + base({ + scanStatus: 'infected', + scannedAt, + linkedAt: scannedAt, + linkedUntil: linkEnd, + }), + 30, + NOW, + ), + ).toEqual(linkEnd); + }); + + it('지운 파일은 지운 때', () => { + const removedAt = at('2026-10-30T04:00:00.000Z'); + expect( + fileExpiresAt(base({ state: 'removed', removedAt }), 30, NOW), + ).toEqual(removedAt); + }); +}); + +describe('isOnLiveLink', () => { + it('끊지 않은 링크의 끝이 지금보다 뒤일 때만', () => { + expect(isOnLiveLink({ linkedUntil: null }, NOW)).toBe(false); + expect( + isOnLiveLink({ linkedUntil: new Date(NOW.getTime() + 1) }, NOW), + ).toBe(true); + expect(isOnLiveLink({ linkedUntil: NOW }, NOW)).toBe(false); + }); +}); diff --git a/backend/src/modules/file-storage/file-retention.ts b/backend/src/modules/file-storage/file-retention.ts new file mode 100644 index 0000000..829f5cc --- /dev/null +++ b/backend/src/modules/file-storage/file-retention.ts @@ -0,0 +1,83 @@ +import { kstEndOfDayAfter } from '@/shared/utils/kst-date'; +import { + INFECTED_FILE_RECORD_RETENTION_MS, + UNATTACHED_FILE_RETENTION_MS, + UPLOAD_IDLE_RETENTION_MS, +} from './file-storage.constants'; +import type { StoredFileState } from './stored-file.entity'; + +/** + * 파일이 남는 기간 (순수 함수) — 계약 4절 "기간 · 보관 · 정리"의 표: + * + * | 대상 | 남는 기간 | + * | --- | --- | + * | 올리는 중 | 마지막 조각(없으면 만든 때) 뒤 24시간 — 임시 저장에 실려도 같다 | + * | 다 올렸지만 어디에도 실리지 않은 파일 | 다 올린 뒤 24시간 | + * | 임시 저장에 한 번이라도 실린 파일 | 다 올린 날(한국)부터 보관 날 수(30)째 날 끝 | + * | 보낸 파일 | 그 파일을 실은 살아 있는 링크가 끝나는 때 가운데 가장 늦은 것 (임시 저장 기간보다 늦으면) | + * | 끊은 링크의 파일 · 위험 판정 파일 | 다른 쓰임이 없으면 곧 — 위험 판정 파일은 기록만 판정 뒤 30일 | + * + * "보낸 뒤"의 임시 저장 기간: 보낸 파일은 보낸 때까지의 임시 저장 기록으로 남지 않는다 — 링크를 끊으면 바로 빠져야 해서다. + * 보낸 **뒤에** 다시 임시 저장에 실리면(다른 저장본 · 취소해 되돌린 발송) 그때부터 다시 임시 저장 기간이다. + * 링크는 공유 모듈이 알려 준다(`linkedAt` — 취소하지 않은 링크의 가장 늦게 만든 때 · `linkedUntil` — 끊지 않은 링크의 가장 늦은 끝). + */ +export interface RetentionInput { + readonly state: StoredFileState; + readonly createdAt: Date; + readonly lastChunkAt: Date | null; + readonly completedAt: Date | null; + readonly draftAttachedAt: Date | null; + readonly linkedAt: Date | null; + readonly linkedUntil: Date | null; + readonly scanStatus: string | null; + readonly scannedAt: Date | null; + readonly removedAt: Date | null; +} + +const later = (a: Date | null, b: Date | null): Date | null => + a === null ? b : b === null ? a : a > b ? a : b; + +/** + * 이대로 두면 지워지는 때 — 지금(now)이 이 때를 지나면 보관량에서 빠지고 정리 작업이 지운다. + * @param retentionDays 임시 저장에 실린 파일이 남는 날 수 (MAIL_LARGE_FILE_RETENTION_DAYS) + */ +export function fileExpiresAt( + file: RetentionInput, + retentionDays: number, + now: Date, +): Date { + if (file.state === 'removed') return file.removedAt ?? now; + if (file.state !== 'ready' || file.completedAt === null) { + // 올리는 중 · 끝내는 중 + const lastActivity = file.lastChunkAt ?? file.createdAt; + return new Date(lastActivity.getTime() + UPLOAD_IDLE_RETENTION_MS); + } + + let own: Date | null; + if (file.scanStatus === 'infected') { + // 바이트는 곧 지우고 기록(이름 · 판정)만 남긴다 + own = new Date( + (file.scannedAt ?? now).getTime() + INFECTED_FILE_RECORD_RETENTION_MS, + ); + } else if ( + file.draftAttachedAt !== null && + (file.linkedAt === null || file.draftAttachedAt > file.linkedAt) + ) { + own = kstEndOfDayAfter(file.completedAt, retentionDays); + } else if (file.linkedAt === null) { + own = new Date(file.completedAt.getTime() + UNATTACHED_FILE_RETENTION_MS); + } else { + // 보냈고 그 뒤 임시 저장에 실리지 않았다 — 링크가 남는 동안만 + own = null; + } + // 아무것도 붙잡지 않으면 곧(지금) — 끊은 링크의 파일 + return later(own, file.linkedUntil) ?? now; +} + +/** 살아 있는 링크(끊지 않고 기간 안)에 실린 파일인가 — 지우기는 409 FILE_003, 보내지 않은 파일 목록에서 빠진다 */ +export function isOnLiveLink( + file: Pick, + now: Date, +): boolean { + return file.linkedUntil !== null && file.linkedUntil > now; +} diff --git a/backend/src/modules/file-storage/file-scan.service.spec.ts b/backend/src/modules/file-storage/file-scan.service.spec.ts new file mode 100644 index 0000000..d2f2bfd --- /dev/null +++ b/backend/src/modules/file-storage/file-scan.service.spec.ts @@ -0,0 +1,372 @@ +import { Logger } from '@nestjs/common'; +import { Readable } from 'stream'; + +import { ClamavUnavailableError, type ClamavVerdict } from './clamav-client'; +import type { FileBlobStore } from './file-blob-store'; +import { FileScanService, type FileScanner } from './file-scan.service'; +import { + FILE_SCAN_ERROR_AFTER_ATTEMPTS, + FILE_SCAN_RETRY_BASE_MS, +} from './file-storage.constants'; +import type { FileStorageRepository } from './file-storage.repository'; +import type { FileStorageSettings } from './file-storage.settings'; +import type { FileScanVerdictEvent } from './file-storage.types'; +import type { StoredFileEntity, StoredFileState } from './stored-file.entity'; + +const NOW = new Date(); +const DAY = 24 * 60 * 60 * 1000; + +const file = ( + id: string, + overrides: Partial = {}, +): StoredFileEntity => ({ + id, + ownerEmail: 'ara.kim@arasystem.test', + purpose: 'mail', + name: `${id}.zip`, + size: 10, + chunkSize: 16, + chunkCount: 1, + lastModified: null, + state: 'ready', + removedReason: null, + firstChunkSha256: null, + scanStatus: 'pending', + scanSignature: null, + scanAttempts: 0, + scanNextAt: new Date(NOW.getTime() - 1), + scannedAt: null, + createdAt: new Date(NOW.getTime() - 60_000), + lastChunkAt: new Date(NOW.getTime() - 60_000), + completedAt: new Date(NOW.getTime() - 30_000), + completionStartedAt: null, + draftAttachedAt: null, + linkedAt: null, + linkedUntil: null, + expiresAt: new Date(NOW.getTime() + DAY), + removedAt: null, + bytesDeletedAt: null, + ...overrides, +}); + +/** 검사 차례 표 대역 — 검사를 기다리는 파일(다 올림 · 검사 중 · 바이트 있음 · 때가 됨)을 먼저 끝낸 차례로 */ +class FakeRepository { + readonly rows = new Map(); + + findScanCandidates(now: Date, exclude: readonly string[], limit: number) { + return Promise.resolve( + [...this.rows.values()] + .filter( + (row) => + row.state === 'ready' && + row.scanStatus === 'pending' && + row.bytesDeletedAt === null && + row.scanNextAt !== null && + row.scanNextAt <= now && + !exclude.includes(row.id), + ) + .sort( + (a, b) => + (a.completedAt?.getTime() ?? 0) - (b.completedAt?.getTime() ?? 0), + ) + .slice(0, limit) + .map((row) => ({ ...row })), + ); + } + + findById(id: string) { + const row = this.rows.get(id); + return Promise.resolve(row ? { ...row } : null); + } + + transition( + id: string, + from: readonly StoredFileState[], + changes: Partial, + ) { + const row = this.rows.get(id); + if (!row || !from.includes(row.state)) return Promise.resolve(false); + Object.assign(row, changes); + return Promise.resolve(true); + } + + update(id: string, changes: Partial) { + const row = this.rows.get(id); + if (row) Object.assign(row, changes); + return Promise.resolve(); + } +} + +const settings = { retentionDays: 30 } as FileStorageSettings; + +describe('FileScanService — 악성 검사 차례 (11단계)', () => { + let repository: FakeRepository; + let blobs: { openRead: jest.Mock; remove: jest.Mock }; + let verdicts: Map; + let scanned: string[]; + let service: FileScanService; + let warn: jest.SpyInstance; + let error: jest.SpyInstance; + + const scanner: FileScanner = async (source) => { + const parts: Buffer[] = []; + for await (const part of source) parts.push(part as Buffer); + const id = Buffer.concat(parts).toString(); + scanned.push(id); + const verdict = verdicts.get(id) ?? { kind: 'clean' }; + if (verdict instanceof Error) throw verdict; + return verdict; + }; + + const build = (withScanner: FileScanner | null = scanner) => { + service = new FileScanService( + repository as unknown as FileStorageRepository, + blobs as unknown as FileBlobStore, + settings, + withScanner, + ); + }; + + beforeEach(() => { + repository = new FakeRepository(); + verdicts = new Map(); + scanned = []; + blobs = { + // 내용 = 파일 id (가짜 검사기가 그것으로 판정을 고른다) + openRead: jest.fn((id: string) => + Promise.resolve(Readable.from([Buffer.from(id)])), + ), + remove: jest.fn().mockResolvedValue(undefined), + }; + build(); + warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + error = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + service.onApplicationShutdown(); + jest.restoreAllMocks(); + }); + + const run = async () => { + service.kick(); + await service.settle(); + }; + + it('먼저 끝낸 것부터 검사한다 — 깨끗함 · 검사하지 못함(암호 압축 · 압축 안의 검사 한도 — 로그 없음)', async () => { + repository.rows.set( + 'b', + file('b', { completedAt: new Date(NOW.getTime() - 10_000) }), + ); + repository.rows.set( + 'a', + file('a', { completedAt: new Date(NOW.getTime() - 20_000) }), + ); + repository.rows.set( + 'c', + file('c', { completedAt: new Date(NOW.getTime() - 5_000) }), + ); + verdicts.set('b', { + kind: 'unscannable', + signature: 'Heuristics.Encrypted.Zip', + }); + verdicts.set('c', { + kind: 'unscannable', + signature: 'Heuristics.Limits.Exceeded.MaxScanSize', + }); + + await run(); + expect(scanned.slice(0, 2).sort()).toEqual(['a', 'b']); + expect(scanned).toHaveLength(3); + expect(repository.rows.get('a')).toMatchObject({ + scanStatus: 'clean', + scanNextAt: null, + }); + expect(repository.rows.get('b')).toMatchObject({ + scanStatus: 'unscannable', + scanSignature: 'Heuristics.Encrypted.Zip', + }); + expect(repository.rows.get('c')?.scanStatus).toBe('unscannable'); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it('흐름 크기 한도(clamd 의 StreamMaxLength — 설정 오류)는 판정이 아니다 — 검사 중 그대로 두고 처음부터 error, 같은 간격으로 다시', async () => { + repository.rows.set('big', file('big', { size: 1_500_000_000 })); + verdicts.set( + 'big', + new ClamavUnavailableError( + 'clamd 의 흐름 크기 한도(StreamMaxLength)에 걸렸습니다', + 'misconfigured', + ), + ); + + await run(); + const row = repository.rows.get('big'); + expect(row).toMatchObject({ scanStatus: 'pending', scanAttempts: 1 }); + expect((row?.scanNextAt?.getTime() ?? 0) - Date.now()).toBeGreaterThan( + FILE_SCAN_RETRY_BASE_MS - 5_000, + ); + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith( + expect.stringContaining('StreamMaxLength'), + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it('판정 글 전에 끊김(RST · 빈 응답 · keepalive 의 ETIMEDOUT)도 다시 한다 — 처음에는 warn', async () => { + repository.rows.set('cut', file('cut')); + repository.rows.set( + 'gone', + file('gone', { completedAt: new Date(NOW.getTime() - 10_000) }), + ); + verdicts.set( + 'cut', + new ClamavUnavailableError( + 'clamd 가 판정 글을 보내기 전에 연결을 닫았습니다 — 다 보낸 뒤(보낸 3바이트), 받은 0바이트 (read ECONNRESET)', + ), + ); + // keepalive 가 사라진 clamd 를 끊은 것 — clamav-client 가 같은 까닭(unavailable)과 같은 글로 낸다 + verdicts.set( + 'gone', + new ClamavUnavailableError( + 'clamd 가 판정 글을 보내기 전에 연결을 닫았습니다 — 다 보낸 뒤(보낸 4바이트), 받은 0바이트 (read ETIMEDOUT)', + ), + ); + await run(); + for (const id of ['cut', 'gone']) { + expect({ id, ...repository.rows.get(id) }).toMatchObject({ + id, + scanStatus: 'pending', + scanAttempts: 1, + }); + } + expect(warn).toHaveBeenCalledTimes(2); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('ETIMEDOUT')); + expect(error).not.toHaveBeenCalled(); + }); + + it('크기 한도를 RST 로 끊어 오류 글이 오지 않으면(흘려보내는 중 끊김) 설정 오류로 단정하지 않는다 — 다른 끊김처럼 미루고, 이어지면 error (단계 · 보낸 바이트 수가 남는다)', async () => { + repository.rows.set('huge', file('huge', { size: 2_000_000_000 })); + verdicts.set( + 'huge', + new ClamavUnavailableError( + 'clamd 가 판정 글을 보내기 전에 연결을 닫았습니다 — 흘려보내는 중(보낸 1,073,741,824바이트), 받은 0바이트 (write EPIPE)', + ), + ); + + await run(); + const row = repository.rows.get('huge'); + expect(row).toMatchObject({ scanStatus: 'pending', scanAttempts: 1 }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('흘려보내는 중(보낸 1,073,741,824바이트)'), + ); + expect(error).not.toHaveBeenCalled(); + + if (row) { + row.scanAttempts = FILE_SCAN_ERROR_AFTER_ATTEMPTS - 1; + row.scanNextAt = new Date(NOW.getTime() - 1); + } + await run(); + expect(repository.rows.get('huge')?.scanStatus).toBe('pending'); + expect(error).toHaveBeenCalledWith( + expect.stringContaining('흘려보내는 중(보낸 1,073,741,824바이트)'), + ); + }); + + it('위험 — 바이트를 곧 지우고 기록은 남긴다(판정 뒤 30일), warn 하고 주인에게 알린다', async () => { + repository.rows.set('bad', file('bad')); + verdicts.set('bad', { + kind: 'infected', + signature: 'Win.Test.EICAR_HDB-1', + }); + const events: FileScanVerdictEvent[] = []; + service.onVerdict((event) => events.push(event)); + + await run(); + const row = repository.rows.get('bad'); + expect(row).toMatchObject({ + state: 'ready', + scanStatus: 'infected', + scanSignature: 'Win.Test.EICAR_HDB-1', + }); + expect(row?.bytesDeletedAt).not.toBeNull(); + expect(blobs.remove).toHaveBeenCalledWith('bad'); + expect((row?.expiresAt.getTime() ?? 0) - Date.now()).toBeGreaterThan( + 29 * DAY, + ); + expect(events).toEqual([ + { + ownerEmail: 'ara.kim@arasystem.test', + fileId: 'bad', + name: 'bad.zip', + scanStatus: 'infected', + }, + ]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Win.Test.EICAR_HDB-1'), + ); + }); + + it('검사기에 닿지 못하면 검사 중 그대로 두고 1분부터 늘려 다시 — 이어서 실패하면 error (닫힌 쪽)', async () => { + repository.rows.set('x', file('x')); + verdicts.set('x', new ClamavUnavailableError('connect ECONNREFUSED')); + + await run(); + const row = repository.rows.get('x'); + expect(row).toMatchObject({ scanStatus: 'pending', scanAttempts: 1 }); + expect((row?.scanNextAt?.getTime() ?? 0) - Date.now()).toBeGreaterThan( + FILE_SCAN_RETRY_BASE_MS - 5_000, + ); + expect(warn).toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + + // 이어서 실패한 끝 — error 로 남긴다 + if (row) { + row.scanAttempts = FILE_SCAN_ERROR_AFTER_ATTEMPTS - 1; + row.scanNextAt = new Date(NOW.getTime() - 1); + } + await run(); + expect(repository.rows.get('x')?.scanAttempts).toBe( + FILE_SCAN_ERROR_AFTER_ATTEMPTS, + ); + expect(error).toHaveBeenCalled(); + }); + + it('바이트가 저장소에 없으면 검사하지 않고 사라진 것으로 적는다', async () => { + repository.rows.set('gone', file('gone')); + blobs.openRead.mockRejectedValue( + Object.assign(new Error('없음'), { code: 'ENOENT' }), + ); + await run(); + expect(repository.rows.get('gone')?.bytesDeletedAt).not.toBeNull(); + expect(scanned).toEqual([]); + }); + + it('그사이 지운 파일에는 판정을 적지 않는다', async () => { + repository.rows.set('d', file('d')); + blobs.openRead.mockImplementation((id: string) => { + const row = repository.rows.get(id); + if (row) row.state = 'removed'; + return Promise.resolve(Readable.from([Buffer.from(id)])); + }); + verdicts.set('d', { kind: 'infected', signature: 'X' }); + const events: FileScanVerdictEvent[] = []; + service.onVerdict((event) => events.push(event)); + await run(); + expect(repository.rows.get('d')?.scanStatus).toBe('pending'); + expect(events).toEqual([]); + }); + + it('검사기를 쓰지 않으면(CLAMAV_HOST 비움) 아무것도 하지 않는다', async () => { + build(null); + repository.rows.set('a', file('a')); + service.onModuleInit(); + await run(); + expect(scanned).toEqual([]); + }); +}); diff --git a/backend/src/modules/file-storage/file-scan.service.ts b/backend/src/modules/file-storage/file-scan.service.ts new file mode 100644 index 0000000..229dce3 --- /dev/null +++ b/backend/src/modules/file-storage/file-scan.service.ts @@ -0,0 +1,296 @@ +import { + Inject, + Injectable, + Logger, + type OnApplicationShutdown, + type OnModuleInit, +} from '@nestjs/common'; +import type { Readable } from 'stream'; + +import { maskEmail } from '@/shared/utils/mask'; +import { ClamavUnavailableError, type ClamavVerdict } from './clamav-client'; +import { + FILE_BLOB_STORE, + isMissingFile, + type FileBlobStore, +} from './file-blob-store'; +import { fileExpiresAt } from './file-retention'; +import { + FILE_SCAN_CONCURRENCY, + FILE_SCAN_ERROR_AFTER_ATTEMPTS, + FILE_SCAN_POLL_MS, + FILE_SCAN_RETRY_BASE_MS, + FILE_SCAN_RETRY_MAX_MS, +} from './file-storage.constants'; +import { FileStorageRepository } from './file-storage.repository'; +import { + FILE_STORAGE_SETTINGS, + type FileStorageSettings, +} from './file-storage.settings'; +import type { FileScanVerdictEvent } from './file-storage.types'; +import type { StoredFileEntity } from './stored-file.entity'; + +/** 내용을 검사기에 보내 판정을 받는 함수 — 검사기를 쓰지 않으면(CLAMAV_HOST 비움) null. 테스트는 대역을 넣는다 */ +export const FILE_SCANNER = Symbol('FILE_SCANNER'); +export type FileScanner = ( + source: Readable, + signal: AbortSignal, +) => Promise; + +/** 위험 판정 소식을 받는 쪽 (메일 모듈의 실시간 알림) — 던지지 않아야 한다 */ +export type FileScanListener = (event: FileScanVerdictEvent) => void; + +interface ActiveScan { + readonly controller: AbortController; + readonly done: Promise; +} + +/** + * 악성 검사 차례 (계약 4절 "악성 검사") — 다 올린 파일을 서버 전체에서 한 번에 FILE_SCAN_CONCURRENCY 개, 먼저 끝낸 것부터 검사한다. + * 차례는 DB 다(`scan_status = 'pending'` · `scan_next_at`) — 서버를 다시 띄워도 이어진다. + * + * - 깨끗함 → clean, 검사하지 못함(암호 압축 · 압축 안의 검사 한도) → unscannable(받을 수 있음 — 글자만) + * - 위험 → infected: 바이트를 곧 지우고(보관량에서 바로 빠진다) 기록(이름 · 판정 이름)은 남긴다, warn(판정 이름 · 파일 id), 주인에게 알린다(실시간 알림) + * - 검사기에 닿지 못함 · 시간 초과 · 판정 전에 끊김 · 그 밖의 오류 → pending 그대로 두고 1분부터 두 배씩 1시간까지 미뤄 다시 한다. + * FILE_SCAN_ERROR_AFTER_ATTEMPTS 번째부터 error(운영 알림). 흐름 크기 한도(clamd 의 StreamMaxLength — 설정 오류)는 처음부터 error 다. + * **판정 없이 받을 수 있게 되는 일은 없다**(닫힌 쪽) + * + * ⚠️ 한 인스턴스 전제 — 인스턴스를 늘리면 두 인스턴스가 같은 파일을 함께 검사할 수 있다(결과는 같고 낭비) → 그때 행 잠금으로 (nestjs 스킬 "확장 때 옮길 것") + */ +@Injectable() +export class FileScanService implements OnModuleInit, OnApplicationShutdown { + private readonly logger = new Logger(FileScanService.name); + private readonly active = new Map(); + private readonly listeners = new Set(); + private timer: NodeJS.Timeout | null = null; + private filling = false; + private kickAgain = false; + private stopped = false; + + constructor( + private readonly repository: FileStorageRepository, + @Inject(FILE_BLOB_STORE) private readonly blobs: FileBlobStore, + @Inject(FILE_STORAGE_SETTINGS) + private readonly settings: FileStorageSettings, + @Inject(FILE_SCANNER) private readonly scanner: FileScanner | null, + ) {} + + onModuleInit(): void { + if (this.scanner === null) return; + this.timer = setInterval(() => this.kick(), FILE_SCAN_POLL_MS); + this.timer.unref(); + this.kick(); + } + + /** 서버 종료 — 진행 중인 검사를 멈춘다(그 파일은 검사 중 그대로 — 다음 기동에 다시 한다) */ + onApplicationShutdown(): void { + this.stopped = true; + if (this.timer) clearInterval(this.timer); + for (const scan of this.active.values()) scan.controller.abort(); + } + + /** 위험 판정 소식을 받는다 — 돌려준 함수로 그만 받는다 */ + onVerdict(listener: FileScanListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** 검사 차례를 깨운다 — 다 올린 직후 · 주기마다 · 검사 하나가 끝날 때 */ + kick(): void { + if (this.scanner === null || this.stopped) return; + if (this.filling) { + this.kickAgain = true; + return; + } + this.filling = true; + void this.fillSlots() + .catch((error: unknown) => { + this.logger.error( + `[SYS_001] 악성 검사 차례를 읽지 못했습니다: ${error instanceof Error ? error.message : String(error)}`, + ); + }) + .finally(() => { + this.filling = false; + if (this.kickAgain) { + this.kickAgain = false; + this.kick(); + } + }); + } + + /** 테스트 · 종료용 — 지금 진행 중인 검사가 모두 끝날 때까지 (새로 깨운 것까지) */ + async settle(): Promise { + for (;;) { + const pending = [...this.active.values()].map((scan) => scan.done); + if (pending.length === 0 && !this.filling) return; + await Promise.all(pending); + await new Promise((resolve) => setImmediate(resolve)); + } + } + + private async fillSlots(): Promise { + while ( + this.scanner !== null && + !this.stopped && + this.active.size < FILE_SCAN_CONCURRENCY + ) { + const [next] = await this.repository.findScanCandidates( + new Date(), + [...this.active.keys()], + 1, + ); + if (!next) return; + const controller = new AbortController(); + const done = this.scanOne(next, this.scanner, controller.signal) + .catch((error: unknown) => { + this.logger.error( + `[SYS_001] 악성 검사 결과를 적지 못했습니다: id=${next.id}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + }) + .finally(() => { + this.active.delete(next.id); + this.kick(); + }); + this.active.set(next.id, { controller, done }); + } + } + + private async scanOne( + file: StoredFileEntity, + scanner: FileScanner, + signal: AbortSignal, + ): Promise { + let source: Readable; + try { + source = await this.blobs.openRead(file.id); + } catch (error) { + if (isMissingFile(error)) { + // 바이트가 없다(저장소 복원 등) — 받을 수도 없으니 검사하지 않는다 (내려받기는 "찾을 수 없음") + await this.repository.update(file.id, { + bytesDeletedAt: new Date(), + scanNextAt: null, + }); + this.logger.warn( + `[SYS_001] 검사할 파일의 바이트가 저장소에 없습니다: id=${file.id}`, + ); + return; + } + await this.defer(file, error); + return; + } + + let verdict: ClamavVerdict; + try { + verdict = await scanner(source, signal); + } catch (error) { + if (!source.destroyed) source.destroy(); + // 서버 종료로 멈춘 검사는 검사 중 그대로 둔다 — 다음 기동에 다시 한다 + if (signal.aborted) return; + await this.defer(file, error); + return; + } + await this.applyVerdict(file, verdict); + } + + /** 판정을 적는다 — 그사이 지운 파일이면 적지 않는다(다 올림 상태일 때만) */ + private async applyVerdict( + file: StoredFileEntity, + verdict: ClamavVerdict, + ): Promise { + const now = new Date(); + if (verdict.kind === 'clean') { + await this.repository.transition(file.id, ['ready'], { + scanStatus: 'clean', + scanSignature: null, + scannedAt: now, + scanNextAt: null, + scanAttempts: 0, + }); + return; + } + if (verdict.kind === 'unscannable') { + await this.repository.transition(file.id, ['ready'], { + scanStatus: 'unscannable', + scanSignature: verdict.signature, + scannedAt: now, + scanNextAt: null, + scanAttempts: 0, + }); + return; + } + + // 위험 — 판정 뒤 기록만 남고(30일 또는 링크가 끝날 때까지) 보관량에서 바로 빠진다 + const current = (await this.repository.findById(file.id)) ?? file; + const infected = { + ...current, + scanStatus: 'infected' as const, + scannedAt: now, + }; + const changed = await this.repository.transition(file.id, ['ready'], { + scanStatus: 'infected', + scanSignature: verdict.signature, + scannedAt: now, + scanNextAt: null, + scanAttempts: 0, + expiresAt: fileExpiresAt(infected, this.settings.retentionDays, now), + }); + this.logger.warn( + `[FILE_007] 대용량 첨부에서 위험한 내용을 찾아 막았습니다: id=${file.id}, 판정=${verdict.signature}, user=${maskEmail(file.ownerEmail)}`, + ); + try { + await this.blobs.remove(file.id); + await this.repository.update(file.id, { bytesDeletedAt: new Date() }); + } catch (error) { + // 정리 작업이 다시 지운다 (위험 판정 파일의 바이트) + this.logger.error( + `[SYS_001] 위험 판정 파일의 바이트를 지우지 못했습니다 — 정리 작업이 다시 지웁니다: id=${file.id}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!changed) return; + const event: FileScanVerdictEvent = { + ownerEmail: file.ownerEmail, + fileId: file.id, + name: file.name, + scanStatus: 'infected', + }; + for (const listener of this.listeners) { + try { + listener(event); + } catch (error) { + this.logger.error( + `[SYS_001] 위험 판정 소식을 전하지 못했습니다: id=${file.id}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + /** + * 판정을 받지 못했다 — 검사 중 그대로 두고 간격을 늘려 다시 한다. + * 흐름 크기 한도(misconfigured — clamd 의 StreamMaxLength 가 한 파일 한도보다 작다)는 다시 해도 같으니 처음부터 error 로 남긴다(운영이 설정을 고친다) + */ + private async defer(file: StoredFileEntity, error: unknown): Promise { + const attempts = file.scanAttempts + 1; + const delay = Math.min( + FILE_SCAN_RETRY_BASE_MS * 2 ** (attempts - 1), + FILE_SCAN_RETRY_MAX_MS, + ); + const next = new Date(Date.now() + delay); + await this.repository.update(file.id, { + scanAttempts: attempts, + scanNextAt: next, + }); + const reason = error instanceof Error ? error.message : String(error); + if ( + error instanceof ClamavUnavailableError && + error.failure === 'misconfigured' + ) { + this.logger.error( + `[SYS_001] 악성 검사기의 흐름 크기 한도(StreamMaxLength)가 이 파일보다 작아 검사하지 못했습니다 — 받기는 검사 중으로 막아 두고 다시 합니다. clamd 의 StreamMaxLength · MaxFileSize · MaxScanSize 를 한 파일 한도 이상으로 두세요: id=${file.id}, 크기=${file.size}, 이어서 ${attempts}번째, 다음=${next.toISOString()}`, + ); + return; + } + const message = `[SYS_001] 악성 검사기에서 판정을 받지 못해 미룹니다 — 받기는 검사 중으로 막아 둡니다: id=${file.id}, 이어서 ${attempts}번째, 다음=${next.toISOString()}, reason=${reason}`; + if (attempts >= FILE_SCAN_ERROR_AFTER_ATTEMPTS) this.logger.error(message); + else this.logger.warn(message); + } +} diff --git a/backend/src/modules/file-storage/file-storage.cleanup.spec.ts b/backend/src/modules/file-storage/file-storage.cleanup.spec.ts new file mode 100644 index 0000000..9a187ab --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.cleanup.spec.ts @@ -0,0 +1,308 @@ +import { Logger } from '@nestjs/common'; + +import type { FileBlobStore, StoredBlob } from './file-blob-store'; +import { FileStorageCleanup } from './file-storage.cleanup'; +import type { FileStorageRepository } from './file-storage.repository'; +import type { FileStorageSettings } from './file-storage.settings'; +import type { StoredFileEntity, StoredFileState } from './stored-file.entity'; + +const NOW = new Date('2026-11-10T00:00:00.000Z'); +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const row = (id: string, overrides: Partial = {}) => + ({ + id, + ownerEmail: 'ara.kim@arasystem.test', + state: 'ready', + size: 10, + scanStatus: 'clean', + createdAt: new Date(NOW.getTime() - 3 * DAY), + lastChunkAt: new Date(NOW.getTime() - 3 * DAY), + completedAt: new Date(NOW.getTime() - 3 * DAY), + completionStartedAt: null, + draftAttachedAt: null, + linkedAt: null, + linkedUntil: null, + scannedAt: null, + expiresAt: new Date(NOW.getTime() + DAY), + removedAt: null, + bytesDeletedAt: null, + ...overrides, + }) as StoredFileEntity; + +class FakeRepository { + readonly rows = new Map(); + + findExpiredAlive(now: Date) { + return Promise.resolve( + [...this.rows.values()].filter( + (file) => file.state !== 'removed' && file.expiresAt <= now, + ), + ); + } + + expireIfDue(id: string, now: Date) { + const file = this.rows.get(id); + if (!file || file.state === 'removed' || file.expiresAt > now) { + return Promise.resolve(false); + } + Object.assign(file, { + state: 'removed', + removedReason: 'expired', + removedAt: now, + }); + return Promise.resolve(true); + } + + findBytesToDelete() { + return Promise.resolve( + [...this.rows.values()].filter( + (file) => + file.bytesDeletedAt === null && + (file.state === 'removed' || file.scanStatus === 'infected'), + ), + ); + } + + findStaleCompletions(before: Date) { + return Promise.resolve( + [...this.rows.values()].filter( + (file) => + file.state === 'completing' && + (file.completionStartedAt ?? file.lastChunkAt ?? file.createdAt) < + before, + ), + ); + } + + readonly chunkRowsDeleted: string[] = []; + + deleteChunks(fileId: string) { + this.chunkRowsDeleted.push(fileId); + return Promise.resolve(); + } + + deleteRemovedBefore(before: Date) { + let count = 0; + for (const [id, file] of this.rows) { + if ( + file.state === 'removed' && + file.removedAt !== null && + file.removedAt < before && + file.bytesDeletedAt !== null + ) { + this.rows.delete(id); + count += 1; + } + } + return Promise.resolve(count); + } + + idsWithBytes() { + return Promise.resolve( + new Set( + [...this.rows.values()] + .filter((file) => file.bytesDeletedAt === null) + .map((file) => file.id), + ), + ); + } + + transition( + id: string, + from: readonly StoredFileState[], + changes: Partial, + ) { + const file = this.rows.get(id); + if (!file || !from.includes(file.state)) return Promise.resolve(false); + Object.assign(file, changes); + return Promise.resolve(true); + } + + update(id: string, changes: Partial) { + const file = this.rows.get(id); + if (file) Object.assign(file, changes); + return Promise.resolve(); + } +} + +describe('FileStorageCleanup — 대용량 첨부 정리 (11단계)', () => { + let repository: FakeRepository; + let blobs: { + remove: jest.Mock; + list: jest.Mock>; + isStored: jest.Mock; + }; + let cleanup: FileStorageCleanup; + + beforeEach(() => { + repository = new FakeRepository(); + blobs = { + remove: jest.fn().mockResolvedValue(undefined), + list: jest.fn, []>().mockResolvedValue([]), + isStored: jest.fn().mockResolvedValue(false), + }; + cleanup = new FileStorageCleanup( + repository as unknown as FileStorageRepository, + blobs as unknown as FileBlobStore, + { retentionDays: 30, clamav: null } as FileStorageSettings, + ); + jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('기간이 지난 파일을 지움(기간 지남)으로 바꾸고 바이트를 지운다 — 기간 안의 파일은 그대로', async () => { + repository.rows.set( + 'old', + row('old', { expiresAt: new Date(NOW.getTime() - 1) }), + ); + repository.rows.set( + 'upload', + row('upload', { + state: 'uploading', + completedAt: null, + expiresAt: new Date(NOW.getTime() - HOUR), + }), + ); + repository.rows.set('fresh', row('fresh')); + + const result = await cleanup.run(NOW); + expect(result).toMatchObject({ expired: 2 }); + expect(repository.rows.get('old')).toMatchObject({ + state: 'removed', + removedReason: 'expired', + }); + expect(repository.rows.get('old')?.bytesDeletedAt).not.toBeNull(); + expect(repository.rows.get('fresh')?.state).toBe('ready'); + expect(blobs.remove.mock.calls.map(([id]: [string]) => id).sort()).toEqual([ + 'old', + 'upload', + ]); + }); + + it('지웠거나 위험 판정인데 바이트가 남은 파일의 바이트를 지운다 · 30일 지난 기록을 지운다', async () => { + repository.rows.set( + 'deleted', + row('deleted', { + state: 'removed', + removedAt: new Date(NOW.getTime() - HOUR), + expiresAt: new Date(NOW.getTime() - HOUR), + }), + ); + repository.rows.set( + 'infected', + row('infected', { scanStatus: 'infected' }), + ); + repository.rows.set( + 'ancient', + row('ancient', { + state: 'removed', + removedAt: new Date(NOW.getTime() - 31 * DAY), + expiresAt: new Date(NOW.getTime() - 31 * DAY), + bytesDeletedAt: new Date(NOW.getTime() - 31 * DAY), + }), + ); + + const result = await cleanup.run(NOW); + expect(result).toMatchObject({ bytesDeleted: 2, purged: 1 }); + expect(repository.rows.has('ancient')).toBe(false); + expect(repository.rows.get('infected')?.state).toBe('ready'); + expect(repository.rows.get('infected')?.bytesDeletedAt).not.toBeNull(); + }); + + it('끝내기를 시작한 지 오래된 파일 — 다 옮겼으면 다 올림(조각 기록도 지운다), 아니면 올리는 중으로 되살린다', async () => { + const stale = new Date(NOW.getTime() - HOUR); + repository.rows.set( + 'moved', + row('moved', { state: 'completing', completionStartedAt: stale }), + ); + repository.rows.set( + 'notyet', + row('notyet', { state: 'completing', completionStartedAt: stale }), + ); + blobs.isStored.mockImplementation((id: string) => + Promise.resolve(id === 'moved'), + ); + + const result = await cleanup.run(NOW); + expect(result.recovered).toBe(2); + expect(repository.rows.get('moved')).toMatchObject({ + state: 'ready', + scanStatus: 'unscannable', + completionStartedAt: null, + }); + expect(repository.rows.get('notyet')).toMatchObject({ + state: 'uploading', + completionStartedAt: null, + }); + expect(repository.chunkRowsDeleted).toEqual(['moved']); + }); + + it('방금 끝내기를 시작한 파일은 마지막 조각이 오래됐어도 건드리지 않는다 — 시작한 때로 본다 (2026-10-30 리뷰 L4)', async () => { + repository.rows.set( + 'busy', + row('busy', { + state: 'completing', + lastChunkAt: new Date(NOW.getTime() - 20 * HOUR), + completionStartedAt: new Date(NOW.getTime() - 60_000), + }), + ); + // 이 칸을 더하기 전에 시작한 끝내기(시작한 때 없음)는 마지막 조각의 때로 본다 + repository.rows.set( + 'legacy', + row('legacy', { + state: 'completing', + lastChunkAt: new Date(NOW.getTime() - HOUR), + }), + ); + + const result = await cleanup.run(NOW); + expect(result.recovered).toBe(1); + expect(repository.rows.get('busy')?.state).toBe('completing'); + expect(repository.rows.get('legacy')?.state).toBe('uploading'); + expect(blobs.isStored).not.toHaveBeenCalledWith('busy'); + }); + + it('표에 없는 저장소 파일은 하루 지난 것만 지운다', async () => { + repository.rows.set('known', row('known')); + blobs.list.mockResolvedValue([ + { + key: 'known', + stage: 'stored', + modifiedAt: new Date(NOW.getTime() - 3 * DAY), + }, + { + key: 'orphan-old', + stage: 'incoming', + modifiedAt: new Date(NOW.getTime() - 2 * DAY), + }, + { + key: 'orphan-new', + stage: 'incoming', + modifiedAt: new Date(NOW.getTime() - HOUR), + }, + ]); + const result = await cleanup.run(NOW); + expect(result.orphans).toBe(1); + expect(blobs.remove).toHaveBeenCalledWith('orphan-old'); + expect(blobs.remove).not.toHaveBeenCalledWith('orphan-new'); + expect(blobs.remove).not.toHaveBeenCalledWith('known'); + }); + + it('실패해도 앱은 계속 돈다 — error 로그 뒤 0', async () => { + const error = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + blobs.list.mockRejectedValue(new Error('disk gone')); + await expect(cleanup.run(NOW)).resolves.toEqual({ + expired: 0, + bytesDeleted: 0, + recovered: 0, + purged: 0, + orphans: 0, + }); + expect(error).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/file-storage/file-storage.cleanup.ts b/backend/src/modules/file-storage/file-storage.cleanup.ts new file mode 100644 index 0000000..80fc98f --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.cleanup.ts @@ -0,0 +1,176 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { FILE_BLOB_STORE, type FileBlobStore } from './file-blob-store'; +import { fileExpiresAt } from './file-retention'; +import { + ORPHAN_BLOB_AGE_MS, + REMOVED_FILE_RECORD_RETENTION_MS, + STALE_COMPLETION_MS, +} from './file-storage.constants'; +import { FileStorageRepository } from './file-storage.repository'; +import { + FILE_STORAGE_SETTINGS, + type FileStorageSettings, +} from './file-storage.settings'; +import type { StoredFileEntity } from './stored-file.entity'; + +/** 한 번에 다루는 파일 수 — 남으면 다음 주기에 */ +const CLEANUP_BATCH = 500; + +/** 정리 결과 (로그 · 테스트) */ +export interface FileCleanupResult { + /** 기간이 지나 지운 파일 */ + readonly expired: number; + /** 바이트를 지운 파일 (지움 · 기간 지남 · 위험 판정) */ + readonly bytesDeleted: number; + /** 끊긴 끝내기를 되살린 파일 */ + readonly recovered: number; + /** 지운 기록 */ + readonly purged: number; + /** 표에 없어 지운 저장소 파일 */ + readonly orphans: number; +} + +/** + * 대용량 첨부 정리 — 매시간 (계약 4절 "기간 · 보관 · 정리"). 기록을 먼저 바꾸고 실제로 바꾼 것만 지운다 (서명 그림 정리와 같은 결). + * 1) 기간이 지난 파일(끊긴 올리기 24시간 · 보내지 않은 파일 · 끝난 링크의 파일)을 지움(expired)으로 → 바이트를 지운다 + * 2) 지웠거나 위험 판정인데 바이트가 남은 파일의 바이트를 지운다 (요청 중 지우기에 실패한 것) + * 3) 끝내는 중에 멈춘 파일(서버가 그 사이 꺼짐)을 되살린다 — 다 옮겼으면 다 올림, 아니면 올리는 중 + * 4) 지운 지 30일이 지난 기록을 지운다 — 그 뒤 임시 저장본은 그 id 를 버린다 + * 5) 표에 없는 저장소 파일 가운데 하루 지난 것을 지운다 + * + * 보관량은 이 작업을 기다리지 않는다 — 요청 때 상태(expires_at)로 계산한다. + * ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 일을 한다 (결과는 같지만 낭비). + */ +@Injectable() +export class FileStorageCleanup { + private readonly logger = new Logger(FileStorageCleanup.name); + + constructor( + private readonly repository: FileStorageRepository, + @Inject(FILE_BLOB_STORE) private readonly blobs: FileBlobStore, + @Inject(FILE_STORAGE_SETTINGS) + private readonly settings: FileStorageSettings, + ) {} + + @Cron(CronExpression.EVERY_HOUR, { name: 'file-storage-cleanup' }) + async run(now: Date = new Date()): Promise { + try { + const expired = await this.expire(now); + const bytesDeleted = await this.deleteBytes(); + const recovered = await this.recoverStaleCompletions(now); + const purged = await this.repository.deleteRemovedBefore( + new Date(now.getTime() - REMOVED_FILE_RECORD_RETENTION_MS), + ); + const orphans = await this.removeOrphans(now); + const result = { expired, bytesDeleted, recovered, purged, orphans }; + // 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md) + if (Object.values(result).some((count) => count > 0)) { + this.logger.debug( + `대용량 첨부 정리: 기간 지남 ${expired} · 바이트 ${bytesDeleted} · 끝내기 되살림 ${recovered} · 기록 ${purged} · 표에 없는 파일 ${orphans}`, + ); + } + return result; + } catch (error) { + // 정리 작업이 실패해도 앱은 계속 돈다 — 다음 주기에 다시 시도한다 + this.logger.error( + `[SYS_001] 대용량 첨부 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`, + ); + return { + expired: 0, + bytesDeleted: 0, + recovered: 0, + purged: 0, + orphans: 0, + }; + } + } + + /** 기간이 지난 파일을 지움으로 — 그사이 기간이 늘었으면(임시 저장 · 링크) 바꾸지 않는다 */ + private async expire(now: Date): Promise { + let count = 0; + for (const file of await this.repository.findExpiredAlive( + now, + CLEANUP_BATCH, + )) { + if (!(await this.repository.expireIfDue(file.id, now))) continue; + count += 1; + await this.removeBytes(file); + } + return count; + } + + private async deleteBytes(): Promise { + let count = 0; + for (const file of await this.repository.findBytesToDelete(CLEANUP_BATCH)) { + if (await this.removeBytes(file)) count += 1; + } + return count; + } + + private async removeBytes(file: StoredFileEntity): Promise { + try { + await this.blobs.remove(file.id); + await this.repository.update(file.id, { bytesDeletedAt: new Date() }); + return true; + } catch (error) { + this.logger.warn( + `[SYS_001] 파일 바이트를 지우지 못했습니다 — 다음 주기에 다시 지웁니다: id=${file.id}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + } + + /** 끝내기를 시작한 지 오래된(STALE_COMPLETION_MS) 끝내기를 되살린다 — 다 옮겼으면 다 올림, 아니면 올리는 중. 바꾼 것만 센다 */ + private async recoverStaleCompletions(now: Date): Promise { + let count = 0; + for (const file of await this.repository.findStaleCompletions( + new Date(now.getTime() - STALE_COMPLETION_MS), + )) { + if (await this.blobs.isStored(file.id)) { + const scanStatus = this.settings.clamav ? 'pending' : 'unscannable'; + const ready: StoredFileEntity = { + ...file, + state: 'ready', + completedAt: now, + scanStatus, + scanNextAt: scanStatus === 'pending' ? now : null, + }; + if ( + await this.repository.transition(file.id, ['completing'], { + state: 'ready', + completedAt: now, + completionStartedAt: null, + scanStatus, + scanNextAt: ready.scanNextAt, + expiresAt: fileExpiresAt(ready, this.settings.retentionDays, now), + }) + ) { + count += 1; + await this.repository.deleteChunks(file.id); + } + } else if ( + await this.repository.transition(file.id, ['completing'], { + state: 'uploading', + completionStartedAt: null, + }) + ) { + count += 1; + } + } + return count; + } + + private async removeOrphans(now: Date): Promise { + const known = await this.repository.idsWithBytes(); + const before = now.getTime() - ORPHAN_BLOB_AGE_MS; + let count = 0; + for (const blob of await this.blobs.list()) { + if (known.has(blob.key) || blob.modifiedAt.getTime() >= before) continue; + await this.blobs.remove(blob.key); + count += 1; + } + return count; + } +} diff --git a/backend/src/modules/file-storage/file-storage.constants.spec.ts b/backend/src/modules/file-storage/file-storage.constants.spec.ts new file mode 100644 index 0000000..7a95f0f --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.constants.spec.ts @@ -0,0 +1,57 @@ +import { + CLAMAV_IDLE_TIMEOUT_MS, + CLAMAV_KEEPALIVE_INITIAL_DELAY_MS, + CLAMAV_VERDICT_TIMEOUT_BASE_MS, + CLAMAV_VERDICT_TIMEOUT_PER_MB_MS, + clamavVerdictTimeoutMs, +} from './file-storage.constants'; +import { + LARGE_FILE_MAX_SIZE_MB_LIMIT, + MEGABYTE, +} from './file-storage.settings'; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +/** 2026-10-30 실측 (ClamAV 1.4.6, 개발 PC 코어 하나) — 2,047MB 무작위 파일을 다 보낸 뒤 판정까지 296초 */ +const MEASURED_SECONDS_PER_MB = 296 / 2047; +/** clamd 의 MaxScanSize (MB) — 7z · tar.gz 는 MaxScanTime 없이 여기까지 푼다 (운영 설정 — 계약 4절) */ +const MAX_SCAN_SIZE_MB = 4094; + +describe('clamavVerdictTimeoutMs (다 보낸 뒤 판정을 기다리는 시간)', () => { + it('30분 + 보낸 1MB(올림)마다 1초', () => { + expect(CLAMAV_VERDICT_TIMEOUT_BASE_MS).toBe(30 * MINUTE); + expect(CLAMAV_VERDICT_TIMEOUT_PER_MB_MS).toBe(SECOND); + expect(clamavVerdictTimeoutMs(0)).toBe(30 * MINUTE); + expect(clamavVerdictTimeoutMs(1)).toBe(30 * MINUTE + SECOND); + expect(clamavVerdictTimeoutMs(MEGABYTE)).toBe(30 * MINUTE + SECOND); + expect(clamavVerdictTimeoutMs(MEGABYTE + 1)).toBe(30 * MINUTE + 2 * SECOND); + expect(clamavVerdictTimeoutMs(100 * MEGABYTE)).toBe( + 30 * MINUTE + 100 * SECOND, + ); + // 음수는 오지 않지만 0 처럼 + expect(clamavVerdictTimeoutMs(-1)).toBe(30 * MINUTE); + }); + + it('한 파일 한도의 상한(2,047MB)이면 3,847초(약 64분)', () => { + expect( + clamavVerdictTimeoutMs(LARGE_FILE_MAX_SIZE_MB_LIMIT * MEGABYTE), + ).toBe(3_847_000); + }); + + it('파일 크기와 상관없는 몫(30분)은 작은 압축 파일이 MaxScanSize(4,094MB)까지 풀리는 것을 실측 빠르기의 3배 느린 CPU 로 검사하는 시간보다 길다', () => { + const slowestScanMs = + MAX_SCAN_SIZE_MB * MEASURED_SECONDS_PER_MB * 3 * SECOND; + expect(slowestScanMs).toBeGreaterThan(25 * MINUTE); + expect(clamavVerdictTimeoutMs(1)).toBeGreaterThan(slowestScanMs); + // 흘려보내는 동안의 무소식 상한(10분)보다도 길다 — 판정 대기만 늘렸다 + expect(CLAMAV_IDLE_TIMEOUT_MS).toBe(10 * MINUTE); + expect(clamavVerdictTimeoutMs(1)).toBeGreaterThan(CLAMAV_IDLE_TIMEOUT_MS); + }); + + it('keepalive 는 60초 뒤부터 묻는다 — 사라진 상대(1초 간격 10번)를 판정 대기보다 훨씬 일찍 알아차린다', () => { + expect(CLAMAV_KEEPALIVE_INITIAL_DELAY_MS).toBe(60 * SECOND); + const detectionMs = CLAMAV_KEEPALIVE_INITIAL_DELAY_MS + 10 * SECOND; + expect(detectionMs * 10).toBeLessThan(CLAMAV_VERDICT_TIMEOUT_BASE_MS); + }); +}); diff --git a/backend/src/modules/file-storage/file-storage.constants.ts b/backend/src/modules/file-storage/file-storage.constants.ts new file mode 100644 index 0000000..fc0d75b --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.constants.ts @@ -0,0 +1,113 @@ +/** + * 파일 저장 · 올리기 상수 (11단계 대용량 첨부 — 계약 4절 "한도" · "올리기" · "기간 · 보관 · 정리"). + * env 로 바꾸는 한도는 file-storage.settings.ts, 여기는 계약 상수다. + */ + +/** 경로 — `/api/file-uploads` · `/api/file-quota` (`/api/files` 는 템플릿 업로드 예제가 쓴다) */ +export const FILE_UPLOADS_ROUTE = 'file-uploads'; +export const FILE_QUOTA_ROUTE = 'file-quota'; + +/** + * 조각 크기 — 16 MiB 고정(계약 상수). 앞단 nginx 의 이 경로 본문 한도 17m 과 짝이다 (release-ops — frontend/nginx.conf). + * 바꾸면 계약 · nginx · 화면을 함께 바꾼다. 응답의 chunkSize 로 클라이언트가 읽는다 + */ +export const FILE_CHUNK_SIZE = 16 * 1024 * 1024; + +/** 쓰임 — 11단계는 메일만 (2부 파일 저장소가 'drive' 를 더한다) */ +export const FILE_PURPOSES = ['mail'] as const; +export type FilePurpose = (typeof FILE_PURPOSES)[number]; + +/** 직원당 보내지 않은 파일 수 (올리는 중 포함, 위험 판정 파일은 세지 않는다) — 넘으면 409 FILE_005 */ +export const MAX_UNSENT_FILES_PER_USER = 50; + +/** 이름 최대 길이 — JS 문자열 길이 (계약 4절 FileUploadCreate) */ +export const MAX_UPLOAD_NAME_LENGTH = 255; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +/** 올리는 중 — 마지막 조각(없으면 만든 때) 뒤 이만큼 지나면 지운다 */ +export const UPLOAD_IDLE_RETENTION_MS = DAY_MS; + +/** 다 올렸지만 어디에도(임시 저장 · 발송) 실리지 않은 파일 — 다 올린 뒤 이만큼 */ +export const UNATTACHED_FILE_RETENTION_MS = DAY_MS; + +/** 지운 · 기간이 지난 파일의 기록(이름 · 크기)을 남기는 기간 — 임시 저장본이 "지운 파일" · "기간 지남"으로 보이는 동안 */ +export const REMOVED_FILE_RECORD_RETENTION_MS = 30 * DAY_MS; + +/** 위험 판정 파일의 기록 — 판정 뒤 이만큼 (바이트는 판정 즉시 지운다) */ +export const INFECTED_FILE_RECORD_RETENTION_MS = 30 * DAY_MS; + +/** 끊긴 끝내기(completing 그대로 남은 것)를 정리 작업이 되살리는 나이 */ +export const STALE_COMPLETION_MS = 10 * 60 * 1000; + +/** 표에 없는 저장소 파일을 지우는 나이 — 진행 중인 올리기 · 끝내기와 겹치지 않게 넉넉히 */ +export const ORPHAN_BLOB_AGE_MS = DAY_MS; + +/** + * 조각 PUT 의 요청 수 한도 — IP 마다 1분 1,200회 (이 경로만 `@Throttle` — 두 번째 limiter 가 아니다). + * 16MiB 조각은 한 사람이 1분에 수십 번 보내고 사무실은 IP 하나로 나간다 (계약 4절 "요청 수 · 동시 수") + */ +export const FILE_CHUNK_THROTTLE_TTL_MS = 60_000; +export const FILE_CHUNK_THROTTLE_LIMIT = 1_200; + +/** 동시에 받는 조각 — 직원마다 4개 · 서버 전체 32개. 넘으면 기다리지 않고 429 REQ_001 (한 인스턴스 전제 — 메모리) */ +export const FILE_CHUNK_CONCURRENCY_PER_USER = 4; +export const FILE_CHUNK_CONCURRENCY = 32; + +/** + * 악성 검사 (계약 4절 "악성 검사") — 서버 전체에서 한 번에 2개, 먼저 끝낸 것부터. + * 2GB 는 분 단위가 걸려 1개면 작은 파일이 큰 파일 뒤에서 오래 기다린다 + */ +export const FILE_SCAN_CONCURRENCY = 2; +/** 검사를 기다리는 파일을 찾는 간격 — 끝내기 직후에는 바로 깨운다 */ +export const FILE_SCAN_POLL_MS = 30_000; +/** 검사기에 닿지 못한 뒤 다시 하는 간격 — 1분부터 두 배씩, 1시간까지 */ +export const FILE_SCAN_RETRY_BASE_MS = 60_000; +export const FILE_SCAN_RETRY_MAX_MS = HOUR_MS; +/** 이 횟수부터 검사기 장애를 error 로 남긴다 (운영 알림) — 그 전은 warn */ +export const FILE_SCAN_ERROR_AFTER_ATTEMPTS = 3; + +/** clamd 에 닿기까지 */ +export const CLAMAV_CONNECT_TIMEOUT_MS = 10_000; +/** + * 흘려보내는 동안 아무것도 오가지 않는 시간의 상한 — clamd 는 INSTREAM 을 받는 대로 임시 파일에 적고 다 받은 뒤에야 검사하므로, + * 이만큼 받지 않으면 이상이다(다 보낸 뒤의 기다림은 아래 clamavVerdictTimeoutMs) + */ +export const CLAMAV_IDLE_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * 다 보낸 뒤 판정을 기다리는 시간 = 30분 + 보낸 1MB(1,048,576바이트 — 한 파일 한도와 같은 단위, 올림)마다 1초 — clamavVerdictTimeoutMs. + * 2,047MB(한 파일 한도의 상한)면 3,847초 ≈ 64분. + * + * 근거 — 2026-10-30 실측(ClamAV 1.4.6, 개발 PC 코어 하나): 2,047MB 무작위 파일을 다 보낸 뒤 판정까지 296초(약 7MB/s). + * - 30분은 파일 크기와 상관없는 몫이다: 7z · tar.gz 등은 MaxScanTime 없이(1.4 에서는 ZIP 에만 걸린다) MaxScanSize(4,094MB — 한 파일 한도의 2배)까지 + * 풀어 검사한다 — 작은 압축 파일도 그렇다. 4,094MB 를 7MB/s 로 약 10분, 운영 CPU 가 2~3배 느리면 20~30분이다 + * - 1MB 마다 1초는 파일 자체의 몫이다 — 실측(1MB 에 약 0.145초)의 약 7배 + * 넘으면 판정을 받지 못한 것으로 보고 미뤄서 다시 한다 — clamd 는 처음부터 다시 검사하므로, 판정이 끝내 오지 않아 같은 파일을 끝없이 다시 검사하는 것보다 + * 한 번 오래 기다리는 편이 낫다. 길게 잡은 값은 clamd 가 살아 있는 채 멈췄을 때 검사 자리(FILE_SCAN_CONCURRENCY)를 그만큼 붙잡는 값이기도 하다 — + * 상대가 컨테이너째 사라진 경우는 아래 keepalive 가 따로 알아차린다 + */ +export const CLAMAV_VERDICT_TIMEOUT_BASE_MS = 30 * 60 * 1000; +export const CLAMAV_VERDICT_TIMEOUT_PER_MB_MS = 1_000; +/** 위 "1MB" 의 바이트 수 — 한 파일 한도(MAIL_LARGE_FILE_MAX_SIZE_MB)와 같은 단위 (file-storage.settings.ts 의 MEGABYTE) */ +const VERDICT_TIMEOUT_UNIT_BYTES = 1024 * 1024; + +/** 보낸 바이트 수 → 다 보낸 뒤 판정을 기다리는 시간(ms) (순수 함수 — 위 근거) */ +export function clamavVerdictTimeoutMs(streamedBytes: number): number { + const megabytes = Math.ceil( + Math.max(0, streamedBytes) / VERDICT_TIMEOUT_UNIT_BYTES, + ); + return ( + CLAMAV_VERDICT_TIMEOUT_BASE_MS + + megabytes * CLAMAV_VERDICT_TIMEOUT_PER_MB_MS + ); +} + +/** + * clamd 소켓의 TCP keepalive — 마지막으로 오간 뒤 이만큼 지나면 커널이 상대에게 묻기 시작한다. + * Node 24(libuv 1.52)는 그 뒤 1초 간격으로 10번 묻고 답이 없으면 `read ETIMEDOUT` 으로 끊는다 — 컨테이너째 사라져 RST 도 오지 않는 clamd 를 + * 약 70초 안에 알아차린다(2026-10-30 실측: 첫 대기 5초로 두고 상대 네트워크를 내리자 15.4초에 끊김). 끊김은 다른 끊김처럼 미뤄서 다시 한다. + * 살아 있는 clamd 는 검사 중에도 커널이 답하므로 긴 검사를 끊지 않는다 + */ +export const CLAMAV_KEEPALIVE_INITIAL_DELAY_MS = 60_000; diff --git a/backend/src/modules/file-storage/file-storage.module.ts b/backend/src/modules/file-storage/file-storage.module.ts new file mode 100644 index 0000000..65b70f9 --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.module.ts @@ -0,0 +1,90 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { scanWithClamav } from './clamav-client'; +import { FILE_BLOB_STORE } from './file-blob-store'; +import { + FILE_SCANNER, + FileScanService, + type FileScanner, +} from './file-scan.service'; +import { FileStorageCleanup } from './file-storage.cleanup'; +import { + CLAMAV_CONNECT_TIMEOUT_MS, + CLAMAV_IDLE_TIMEOUT_MS, + CLAMAV_KEEPALIVE_INITIAL_DELAY_MS, + clamavVerdictTimeoutMs, +} from './file-storage.constants'; +import { FileStorageRepository } from './file-storage.repository'; +import { FileStorageService } from './file-storage.service'; +import { + FILE_STORAGE_SETTINGS, + type FileStorageSettings, + buildFileStorageSettings, +} from './file-storage.settings'; +import { + FileQuotaController, + FileUploadController, +} from './file-upload.controller'; +import { LocalDiskBlobStore } from './local-disk-blob-store'; +import { StoredFileChunkEntity } from './stored-file-chunk.entity'; +import { StoredFileEntity } from './stored-file.entity'; + +/** 검사기 연결 — CLAMAV_HOST 가 비면(개발) 검사하지 않는다 */ +function createScanner(settings: FileStorageSettings): FileScanner | null { + const { clamav } = settings; + if (clamav === null) return null; + return (source, signal) => + scanWithClamav( + { + host: clamav.host, + port: clamav.port, + connectTimeoutMs: CLAMAV_CONNECT_TIMEOUT_MS, + idleTimeoutMs: CLAMAV_IDLE_TIMEOUT_MS, + verdictTimeoutMs: clamavVerdictTimeoutMs, + keepAliveInitialDelayMs: CLAMAV_KEEPALIVE_INITIAL_DELAY_MS, + }, + source, + signal, + ); +} + +/** + * 파일 저장 모듈 (11단계 대용량 첨부 — 계약 4절) — 파일 · 조각 올리기 · 악성 검사 · 정리. + * 바이트는 저장 포트(FILE_BLOB_STORE — 지금은 로컬 디스크), 정보는 DB. 저장을 바꿀 때는 FILE_BLOB_STORE 의 구현만 바꾼다. + * + * 의존 방향은 메일 → 공유(file-sharing) → 저장(이 모듈) 하나다 — 이 모듈은 메일 · 공유를 가져오지 않는다. + * 2부 파일 저장소가 같은 파일 · 올리기 API 를 쓴다. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([StoredFileEntity, StoredFileChunkEntity]), + ], + controllers: [FileUploadController, FileQuotaController], + providers: [ + { + provide: FILE_STORAGE_SETTINGS, + inject: [ConfigService], + useFactory: (config: ConfigService): FileStorageSettings => + buildFileStorageSettings((key) => config.get(key)), + }, + { + provide: FILE_BLOB_STORE, + inject: [FILE_STORAGE_SETTINGS], + useFactory: (settings: FileStorageSettings) => + new LocalDiskBlobStore(settings.dir), + }, + { + provide: FILE_SCANNER, + inject: [FILE_STORAGE_SETTINGS], + useFactory: createScanner, + }, + FileStorageRepository, + FileScanService, + FileStorageService, + FileStorageCleanup, + ], + exports: [FileStorageService, FileScanService, FILE_STORAGE_SETTINGS], +}) +export class FileStorageModule {} diff --git a/backend/src/modules/file-storage/file-storage.repository.ts b/backend/src/modules/file-storage/file-storage.repository.ts new file mode 100644 index 0000000..bc4d4ec --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.repository.ts @@ -0,0 +1,354 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, type EntityManager, type Repository } from 'typeorm'; + +import { StoredFileChunkEntity } from './stored-file-chunk.entity'; +import { StoredFileEntity, type StoredFileState } from './stored-file.entity'; + +/** + * 파일 만들기를 차례대로 — `pg_advisory_xact_lock(이 값)` (트랜잭션이 끝나면 풀린다). 직원 보관량 · 서버 총량 · 디스크 여유를 본 뒤 넣기까지를 + * 한 번에 하나만 한다 (계약 4절 "한도" — 동시에 둘이 한도를 넘지 않게). 만들기는 드물어 서버 전체 잠금 하나로 충분하다. + * DB 잠금이라 인스턴스를 늘려도 그대로 맞다. 이름표 — 'FIL1' 의 ASCII (서명 저장 잠금 'SIG1' 과 섞이지 않게) + */ +const FILE_CREATE_LOCK = 0x46494c31; + +/** 살아 있는 파일 — 지우지 않았고(상태) 기간 안이다 */ +const ALIVE = `"state" <> 'removed' AND "expires_at" > $1`; +/** 보관량에 드는 파일 — 살아 있고 위험 판정이 아니다 (위험 판정 파일은 바이트를 곧 지운다) */ +const COUNTED = `${ALIVE} AND "scan_status" IS DISTINCT FROM 'infected'`; +/** 살아 있는 링크에 실리지 않은 파일 */ +const UNSENT = `("linked_until" IS NULL OR "linked_until" <= $1)`; + +/** 만들기 전에 보는 셈 — 지금 상태로 계산한다 (정리 작업을 기다리지 않는다) */ +export interface CreationStats { + /** 그 직원의 보관량 (올리는 중은 선언한 크기) */ + readonly userUsedBytes: number; + /** 그 직원의 보내지 않은 파일 수 (올리는 중 포함, 위험 판정 제외) */ + readonly userUnsentCount: number; + /** 모든 직원의 보관량 */ + readonly serverUsedBytes: number; + /** 진행 중인 올리기가 아직 받지 않은 바이트 (디스크 여유에서 뺀다) */ + readonly outstandingUploadBytes: number; +} + +/** 받은 조각 하나 */ +export interface ReceivedChunk { + readonly index: number; + readonly size: number; + readonly sha256: Buffer; +} + +/** 받은 조각을 적을 때 함께 바꾸는 파일 값 */ +export interface ChunkRecord { + readonly fileId: string; + readonly index: number; + readonly size: number; + readonly sha256: Buffer; + readonly receivedAt: Date; + /** 이 조각을 받은 뒤의 보관 끝 (올리는 중 — 마지막 조각 뒤 24시간) */ + readonly expiresAt: Date; +} + +const toNumber = (value: unknown): number => Number(value ?? 0); + +/** + * 파일 · 조각 표 접근 — 판단(한도 · 상태 · 기간)은 FileStorageService 가 한다. + * 주인 조건은 서비스가 함께 본다 (남의 파일 id 로는 아무것도 하지 않는다). + */ +@Injectable() +export class FileStorageRepository { + constructor( + @InjectRepository(StoredFileEntity) + private readonly files: Repository, + @InjectRepository(StoredFileChunkEntity) + private readonly chunks: Repository, + private readonly dataSource: DataSource, + ) {} + + /** + * 만들기 — 잠근 뒤 셈을 보고(`build` 가 한도를 넘으면 던진다) 넣는다. 한 트랜잭션이다 + */ + createLocked( + owner: string, + now: Date, + build: (stats: CreationStats) => Promise, + ): Promise { + return this.dataSource.transaction(async (manager) => { + await manager.query('SELECT pg_advisory_xact_lock($1)', [ + FILE_CREATE_LOCK, + ]); + const row = await build(await this.creationStats(manager, owner, now)); + await manager.insert(StoredFileEntity, row); + return row; + }); + } + + findById(id: string): Promise { + return this.files.findOneBy({ id }); + } + + findByIds(ids: readonly string[]): Promise { + if (ids.length === 0) return Promise.resolve([]); + return this.files.findBy({ id: In([...ids]) }); + } + + /** 받은 조각 — 파일마다 번호 차례 */ + async receivedChunks(fileId: string): Promise { + const rows = await this.chunks.find({ + where: { fileId }, + order: { chunkIndex: 'ASC' }, + }); + return rows.map((row) => ({ + index: row.chunkIndex, + size: row.size, + sha256: row.sha256, + })); + } + + /** 받은 조각 번호 — 여러 파일 (목록용) */ + async chunkIndexes( + fileIds: readonly string[], + ): Promise> { + const result = new Map(); + if (fileIds.length === 0) return result; + const rows = await this.chunks.find({ + select: { fileId: true, chunkIndex: true }, + where: { fileId: In([...fileIds]) }, + order: { chunkIndex: 'ASC' }, + }); + for (const row of rows) { + const list = result.get(row.fileId) ?? []; + list.push(row.chunkIndex); + result.set(row.fileId, list); + } + return result; + } + + /** + * 받은 조각을 적는다 — 파일이 아직 올리는 중일 때만(그사이 지웠으면 적지 않는다). 같은 조각을 이미 적었으면 그대로 둔다. + * @returns 적었으면 true + */ + recordChunk(record: ChunkRecord): Promise { + return this.dataSource.transaction(async (manager) => { + const updated = await manager.query<[unknown[], number]>( + `UPDATE "stored_files" + SET "last_chunk_at" = $2, + "expires_at" = $3, + "first_chunk_sha256" = CASE WHEN $4::int = 0 THEN $5 ELSE "first_chunk_sha256" END + WHERE "id" = $1 AND "state" = 'uploading' + RETURNING "id"`, + [ + record.fileId, + record.receivedAt, + record.expiresAt, + record.index, + record.sha256.toString('base64'), + ], + ); + if (updated[0].length === 0) return false; + await manager + .createQueryBuilder() + .insert() + .into(StoredFileChunkEntity) + .values({ + fileId: record.fileId, + chunkIndex: record.index, + size: record.size, + sha256: record.sha256, + receivedAt: record.receivedAt, + }) + .orIgnore() + .execute(); + return true; + }); + } + + /** + * 상태를 바꾼다 — 지금 상태가 `from` 일 때만 (한 번만 되는 전이 — 끝내기 · 지우기). + * @returns 바꿨으면 true + */ + async transition( + id: string, + from: readonly StoredFileState[], + changes: Partial, + ): Promise { + const result = await this.files + .createQueryBuilder() + .update(StoredFileEntity) + .set(changes) + .where('id = :id', { id }) + .andWhere('state IN (:...from)', { from: [...from] }) + .execute(); + return (result.affected ?? 0) > 0; + } + + /** + * 기간이 지난 파일을 지움(기간 지남)으로 — 그 순간에도 기간이 지났을 때만(그사이 임시 저장 · 링크로 늘었으면 그대로 둔다). + * @returns 바꿨으면 true + */ + async expireIfDue(id: string, now: Date): Promise { + const result = await this.files + .createQueryBuilder() + .update(StoredFileEntity) + .set({ state: 'removed', removedReason: 'expired', removedAt: now }) + .where('id = :id', { id }) + .andWhere(`state <> 'removed'`) + .andWhere('expires_at <= :now', { now }) + .execute(); + return (result.affected ?? 0) > 0; + } + + /** 값을 바꾼다 (상태 조건 없이 — 보관 기간 · 검사 결과 · 바이트를 지운 때) */ + async update(id: string, changes: Partial): Promise { + await this.files.update({ id }, changes); + } + + /** 그 직원의 보관량 — 지금 상태로 */ + async usedBytes(owner: string, now: Date): Promise { + const rows = await this.dataSource.query<{ used: string }[]>( + `SELECT COALESCE(SUM("size"), 0) AS "used" FROM "stored_files" WHERE "owner_email" = $2 AND ${COUNTED}`, + [now, owner], + ); + return toNumber(rows[0]?.used); + } + + /** + * 보내지 않은 파일 — 살아 있고 살아 있는 링크에 실리지 않은 것(올리는 중 포함 · 위험 판정 포함), 만든 때 최근부터 + */ + async listUnsent( + owner: string, + now: Date, + offset: number, + limit: number, + ): Promise<{ rows: StoredFileEntity[]; total: number }> { + const where = `"owner_email" = :owner AND "state" <> 'removed' AND "expires_at" > :now AND ("linked_until" IS NULL OR "linked_until" <= :now)`; + const [rows, total] = await this.files + .createQueryBuilder('file') + .where(where, { owner, now }) + .orderBy('file.createdAt', 'DESC') + .addOrderBy('file.id', 'DESC') + .offset(offset) + .limit(limit) + .getManyAndCount(); + return { rows, total }; + } + + /** 검사를 기다리는 파일 — 다 올린 차례(먼저 끝낸 것부터) */ + findScanCandidates( + now: Date, + exclude: readonly string[], + limit: number, + ): Promise { + const query = this.files + .createQueryBuilder('file') + .where(`file.state = 'ready'`) + .andWhere(`file.scan_status = 'pending'`) + .andWhere('file.bytes_deleted_at IS NULL') + .andWhere('file.scan_next_at <= :now', { now }) + .orderBy('file.completedAt', 'ASC') + .limit(limit); + if (exclude.length > 0) { + query.andWhere('file.id NOT IN (:...exclude)', { exclude: [...exclude] }); + } + return query.getMany(); + } + + /** 기간이 지났는데 아직 지우지 않은 파일 (정리 작업) */ + findExpiredAlive(now: Date, limit: number): Promise { + return this.files + .createQueryBuilder('file') + .where(`file.state <> 'removed'`) + .andWhere('file.expires_at <= :now', { now }) + .orderBy('file.expiresAt', 'ASC') + .limit(limit) + .getMany(); + } + + /** 바이트를 지워야 하는데 아직 남은 파일 — 지웠거나 위험 판정 (정리 작업) */ + findBytesToDelete(limit: number): Promise { + return this.files + .createQueryBuilder('file') + .where('file.bytes_deleted_at IS NULL') + .andWhere(`(file.state = 'removed' OR file.scan_status = 'infected')`) + .limit(limit) + .getMany(); + } + + /** + * 끝내는 중에 멈춘 파일 (정리 작업 — 서버가 끝내기 도중에 꺼진 경우) — 끝내기를 시작한 지 오래된 것만. + * 시작한 때가 없는 행(이 칸을 더하기 전에 끝내기를 시작한 것)은 마지막 조각 · 만든 때로 본다 + */ + findStaleCompletions(before: Date): Promise { + return this.files + .createQueryBuilder('file') + .where(`file.state = 'completing'`) + .andWhere( + 'COALESCE(file.completion_started_at, file.last_chunk_at, file.created_at) < :before', + { before }, + ) + .getMany(); + } + + /** 다 올린 파일의 조각 기록을 지운다 — 다 올린 뒤에는 쓰지 않는다 (조각 표가 쌓이지 않게) */ + async deleteChunks(fileId: string): Promise { + await this.chunks.delete({ fileId }); + } + + /** + * 지운 지 오래된 기록을 지운다 (정리 작업 — 조각은 함께 지워진다). + * @returns 지운 수 + */ + async deleteRemovedBefore(before: Date): Promise { + const result = await this.files + .createQueryBuilder() + .delete() + .from(StoredFileEntity) + .where(`state = 'removed'`) + .andWhere('removed_at < :before', { before }) + .andWhere('bytes_deleted_at IS NOT NULL') + .execute(); + return result.affected ?? 0; + } + + /** 바이트가 남아 있어야 하는 파일 id (정리 작업이 표에 없는 저장소 파일을 찾는다) */ + async idsWithBytes(): Promise> { + const rows = await this.files + .createQueryBuilder('file') + .select('file.id', 'id') + .where('file.bytes_deleted_at IS NULL') + .getRawMany<{ id: string }>(); + return new Set(rows.map((row) => row.id)); + } + + private async creationStats( + manager: EntityManager, + owner: string, + now: Date, + ): Promise { + const [user] = await manager.query<{ used: string; unsent: string }[]>( + `SELECT COALESCE(SUM("size"), 0) AS "used", + COUNT(*) FILTER (WHERE ${UNSENT}) AS "unsent" + FROM "stored_files" WHERE "owner_email" = $2 AND ${COUNTED}`, + [now, owner], + ); + const [server] = await manager.query<{ used: string }[]>( + `SELECT COALESCE(SUM("size"), 0) AS "used" FROM "stored_files" WHERE ${COUNTED}`, + [now], + ); + const [outstanding] = await manager.query<{ left: string }[]>( + `SELECT COALESCE(SUM(f."size" - COALESCE(c."received", 0)), 0) AS "left" + FROM "stored_files" f + LEFT JOIN LATERAL ( + SELECT SUM(ch."size") AS "received" FROM "stored_file_chunks" ch WHERE ch."file_id" = f."id" + ) c ON TRUE + WHERE f."state" IN ('uploading', 'completing') AND f."expires_at" > $1`, + [now], + ); + return { + userUsedBytes: toNumber(user?.used), + userUnsentCount: toNumber(user?.unsent), + serverUsedBytes: toNumber(server?.used), + outstandingUploadBytes: toNumber(outstanding?.left), + }; + } +} diff --git a/backend/src/modules/file-storage/file-storage.service.spec.ts b/backend/src/modules/file-storage/file-storage.service.spec.ts new file mode 100644 index 0000000..03d2981 --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.service.spec.ts @@ -0,0 +1,688 @@ +import { Logger } from '@nestjs/common'; +import { createHash } from 'crypto'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Readable } from 'stream'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { isOnLiveLink } from './file-retention'; +import type { FileScanService } from './file-scan.service'; +import { + FILE_CHUNK_SIZE, + MAX_UNSENT_FILES_PER_USER, +} from './file-storage.constants'; +import type { + ChunkRecord, + CreationStats, + FileStorageRepository, + ReceivedChunk, +} from './file-storage.repository'; +import { FileStorageService } from './file-storage.service'; +import type { FileStorageSettings } from './file-storage.settings'; +import type { ChunkUpload } from './file-storage.types'; +import { LocalDiskBlobStore } from './local-disk-blob-store'; +import type { StoredFileEntity, StoredFileState } from './stored-file.entity'; + +const ARA = 'ara.kim@arasystem.test'; +const DOYUN = 'doyun.lee@arasystem.test'; +const GB = 1024 * 1024 * 1024; + +/** + * 표 대역 — FileStorageRepository 와 같은 판단(지금 상태로 센 보관량 · 보내지 않은 파일 · 상태 조건)을 메모리에서 한다. + * 실제 SQL(잠금 · 부분 인덱스 · RETURNING)은 e2e 가 본다 + */ +class FakeRepository { + readonly rows = new Map(); + readonly chunks = new Map(); + + private alive(row: StoredFileEntity, now: Date): boolean { + return row.state !== 'removed' && row.expiresAt > now; + } + + stats(owner: string, now: Date): CreationStats { + const counted = [...this.rows.values()].filter( + (row) => this.alive(row, now) && row.scanStatus !== 'infected', + ); + const mine = counted.filter((row) => row.ownerEmail === owner); + return { + userUsedBytes: mine.reduce((sum, row) => sum + row.size, 0), + userUnsentCount: mine.filter((row) => !isOnLiveLink(row, now)).length, + serverUsedBytes: counted.reduce((sum, row) => sum + row.size, 0), + outstandingUploadBytes: counted + .filter((row) => row.state === 'uploading') + .reduce( + (sum, row) => + sum + + row.size - + (this.chunks.get(row.id) ?? []).reduce((s, c) => s + c.size, 0), + 0, + ), + }; + } + + async createLocked( + owner: string, + now: Date, + build: (stats: CreationStats) => Promise, + ): Promise { + const row = await build(this.stats(owner, now)); + this.rows.set(row.id, { ...row }); + return row; + } + + findById(id: string) { + const row = this.rows.get(id); + return Promise.resolve(row ? { ...row } : null); + } + + findByIds(ids: readonly string[]) { + return Promise.resolve( + ids.flatMap((id) => { + const row = this.rows.get(id); + return row ? [{ ...row }] : []; + }), + ); + } + + receivedChunks(fileId: string) { + return Promise.resolve( + [...(this.chunks.get(fileId) ?? [])].sort((a, b) => a.index - b.index), + ); + } + + chunkIndexes(ids: readonly string[]) { + return Promise.resolve( + new Map( + ids.map((id) => [ + id, + (this.chunks.get(id) ?? []).map((chunk) => chunk.index).sort(), + ]), + ), + ); + } + + recordChunk(record: ChunkRecord) { + const row = this.rows.get(record.fileId); + if (!row || row.state !== 'uploading') return Promise.resolve(false); + row.lastChunkAt = record.receivedAt; + row.expiresAt = record.expiresAt; + if (record.index === 0) { + row.firstChunkSha256 = record.sha256.toString('base64'); + } + const list = this.chunks.get(record.fileId) ?? []; + if (!list.some((chunk) => chunk.index === record.index)) { + list.push({ + index: record.index, + size: record.size, + sha256: record.sha256, + }); + } + this.chunks.set(record.fileId, list); + return Promise.resolve(true); + } + + transition( + id: string, + from: readonly StoredFileState[], + changes: Partial, + ) { + const row = this.rows.get(id); + if (!row || !from.includes(row.state)) return Promise.resolve(false); + Object.assign(row, changes); + return Promise.resolve(true); + } + + update(id: string, changes: Partial) { + const row = this.rows.get(id); + if (row) Object.assign(row, changes); + return Promise.resolve(); + } + + deleteChunks(fileId: string) { + this.chunks.delete(fileId); + return Promise.resolve(); + } + + usedBytes(owner: string, now: Date) { + return Promise.resolve(this.stats(owner, now).userUsedBytes); + } + + listUnsent(owner: string, now: Date, offset: number, limit: number) { + const all = [...this.rows.values()] + .filter( + (row) => + row.ownerEmail === owner && + this.alive(row, now) && + !isOnLiveLink(row, now), + ) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + return Promise.resolve({ + rows: all.slice(offset, offset + limit), + total: all.length, + }); + } +} + +/** 디스크 여유를 테스트가 정하는 저장소 — 그 밖은 진짜 로컬 디스크(임시 폴더) */ +class TestBlobStore extends LocalDiskBlobStore { + free: number | null = 100 * GB; + override freeBytes(): Promise { + return Promise.resolve(this.free); + } +} + +const settings = ( + overrides: Partial = {}, +): FileStorageSettings => ({ + dir: '', + maxFileBytes: 2047 * 1024 * 1024, + maxFilesPerMessage: 10, + userQuotaBytes: 10 * GB, + retentionDays: 30, + serverMaxTotalBytes: null, + minFreeBytes: 10 * GB, + clamav: null, + ...overrides, +}); + +const sha = (data: Buffer) => createHash('sha256').update(data).digest(); + +/** 조각 요청 — 웹처럼 바이트 본문 · 길이 · 다이제스트 */ +const chunkUpload = ( + data: Buffer, + overrides: Partial = {}, +): ChunkUpload => ({ + contentType: 'application/octet-stream', + contentEncoding: undefined, + contentLength: String(data.length), + transferEncoding: undefined, + contentDigest: `sha-256=:${sha(data).toString('base64')}:`, + body: Readable.from([data]), + ...overrides, +}); + +async function codeOf(run: () => Promise): Promise { + try { + await run(); + return null; + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } +} + +describe('FileStorageService — 대용량 첨부 올리기 (11단계)', () => { + let root: string; + let repository: FakeRepository; + let blobs: TestBlobStore; + let scanner: { kick: jest.Mock }; + let service: FileStorageService; + let errorSpy: jest.SpyInstance; + + const build = (overrides: Partial = {}) => { + service = new FileStorageService( + repository as unknown as FileStorageRepository, + blobs, + settings(overrides), + scanner as unknown as FileScanService, + ); + return service; + }; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'file-storage-')); + repository = new FakeRepository(); + blobs = new TestBlobStore(root); + scanner = { kick: jest.fn() }; + build(); + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); + }); + + const create = (size: number, name = '도면.dwg', owner = ARA) => + service.create(owner, { name, size, purpose: 'mail', lastModified: 1 }); + + /** 작은 파일을 다 올린다 (조각 하나) */ + async function upload(data: Buffer, owner = ARA) { + const file = await create(data.length, '도면.dwg', owner); + await service.writeChunk(owner, file.id, 0, chunkUpload(data)); + return service.complete(owner, file.id); + } + + describe('만들기', () => { + it('이름을 정리하고 조각 계획을 준다 — 올리는 중 · 받은 조각 없음 · 24시간 뒤 지워짐', async () => { + const before = Date.now(); + const file = await service.create(ARA, { + name: 'C:\\Users\\kim\\현장\u202E영상.mp4', + size: FILE_CHUNK_SIZE * 2 + 5, + purpose: 'mail', + lastModified: 123, + }); + expect(file).toMatchObject({ + name: '현장영상.mp4', + status: 'uploading', + chunkSize: FILE_CHUNK_SIZE, + chunkCount: 3, + receivedChunks: [], + firstChunkSha256: null, + scanStatus: null, + lastModified: 123, + }); + const expires = Date.parse(file.expiresAt); + expect(expires - before).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000); + expect(repository.rows.get(file.id)?.ownerEmail).toBe(ARA); + }); + + it('형식(확장자 허용 목록) → 크기 → 직원 한도 → 서버 공간 차례로 막는다', async () => { + expect(await codeOf(() => create(10, 'setup.exe'))).toBe('FILE_001'); + expect(await codeOf(() => create(10, 'disk.iso'))).toBe('FILE_001'); + expect(await codeOf(() => create(2048 * 1024 * 1024))).toBe('FILE_002'); + + build({ userQuotaBytes: 3 * GB }); + await create(Math.floor(1.9 * GB)); + expect(await codeOf(() => create(Math.floor(1.2 * GB)))).toBe('FILE_005'); + // 다른 직원은 따로 센다 + expect( + await codeOf(() => create(Math.floor(1.2 * GB), '도면.dwg', DOYUN)), + ).toBeNull(); + + build({ userQuotaBytes: 3 * GB, serverMaxTotalBytes: 4 * GB }); + expect(await codeOf(() => create(GB, '도면.dwg', DOYUN))).toBe( + 'FILE_006', + ); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('보내지 않은 파일은 직원마다 50개까지 (올리는 중 포함)', async () => { + for (let index = 0; index < MAX_UNSENT_FILES_PER_USER; index += 1) { + await create(1); + } + expect(await codeOf(() => create(1))).toBe('FILE_005'); + }); + + it('디스크 여유 — 만든 뒤에도 남길 여유 + 진행 중인 올리기가 아직 받지 않은 바이트를 빼고 (error 로그)', async () => { + blobs.free = 13 * GB; + await create(Math.floor(1.5 * GB)); + // 남음 13 − 받을 몫 1.5 − 요청 1.6 = 9.9 < 10 + expect(await codeOf(() => create(Math.floor(1.6 * GB)))).toBe('FILE_006'); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('조각', () => { + it('받은 조각만 적고 0번의 SHA-256 을 준다 · 다시 보낸 같은 조각은 그대로 200, 다른 내용은 409 FILE_003', async () => { + const data = Buffer.from('조각 하나짜리 파일'); + const file = await create(data.length); + const first = await service.writeChunk( + ARA, + file.id, + 0, + chunkUpload(data), + ); + expect(first.receivedChunks).toEqual([0]); + expect(first.firstChunkSha256).toBe(sha(data).toString('base64')); + + await expect( + service.writeChunk(ARA, file.id, 0, chunkUpload(data)), + ).resolves.toMatchObject({ receivedChunks: [0] }); + const other = Buffer.from('다른 내용인 같은 길이!!'); + expect( + await codeOf(() => + service.writeChunk( + ARA, + file.id, + 0, + chunkUpload(other.subarray(0, data.length)), + ), + ), + ).toBe('FILE_003'); + }); + + it('다이제스트가 받은 바이트와 다르면 400 FILE_004 — 그 조각을 받지 않았다', async () => { + const data = Buffer.from('abc'); + const file = await create(3); + expect( + await codeOf(() => + service.writeChunk( + ARA, + file.id, + 0, + chunkUpload(data, { + contentDigest: `sha-256=:${sha(Buffer.from('xyz')).toString('base64')}:`, + }), + ), + ), + ).toBe('FILE_004'); + expect(repository.chunks.get(file.id)).toBeUndefined(); + // 다이제스트 없이도 받는다 (다른 클라이언트) + await expect( + service.writeChunk( + ARA, + file.id, + 0, + chunkUpload(data, { contentDigest: undefined }), + ), + ).resolves.toMatchObject({ receivedChunks: [0] }); + }); + + it('머리글 — 바이트 본문 · 압축 없이 · 길이가 조각 길이와 같아야 · 번호 범위 (400 VAL_001), 남의 올리기 404', async () => { + const data = Buffer.from('abc'); + const file = await create(3); + for (const overrides of [ + { contentType: 'application/json' }, + { contentEncoding: 'gzip' }, + { contentLength: undefined }, + { contentLength: '4' }, + { transferEncoding: 'chunked' }, + { contentDigest: 'sha-256=:bad:' }, + ] as Partial[]) { + expect( + await codeOf(() => + service.writeChunk(ARA, file.id, 0, chunkUpload(data, overrides)), + ), + ).toBe('VAL_001'); + } + expect( + await codeOf(() => + service.writeChunk(ARA, file.id, 1, chunkUpload(data)), + ), + ).toBe('VAL_001'); + expect( + await codeOf(() => + service.writeChunk(DOYUN, file.id, 0, chunkUpload(data)), + ), + ).toBe('RES_001'); + }); + + it('끝낸 올리기에 조각을 보내면 409 FILE_003', async () => { + const done = await upload(Buffer.from('abc')); + expect( + await codeOf(() => + service.writeChunk(ARA, done.id, 0, chunkUpload(Buffer.from('abc'))), + ), + ).toBe('FILE_003'); + }); + }); + + describe('끝내기 · 지우기', () => { + it('빠진 조각이 있으면 409 FILE_003, 모두 있으면 ready + 검사 차례(검사기 없으면 unscannable) · 다시 부르면 그대로', async () => { + const file = await create(FILE_CHUNK_SIZE + 3); + expect(await codeOf(() => service.complete(ARA, file.id))).toBe( + 'FILE_003', + ); + await service.writeChunk( + ARA, + file.id, + 1, + chunkUpload(Buffer.from('xyz')), + ); + const big = Buffer.alloc(FILE_CHUNK_SIZE, 7); + await service.writeChunk(ARA, file.id, 0, chunkUpload(big)); + + const ready = await service.complete(ARA, file.id); + expect(ready).toMatchObject({ + status: 'ready', + receivedChunks: [0, 1], + scanStatus: 'unscannable', + }); + expect(scanner.kick).toHaveBeenCalled(); + await expect(service.complete(ARA, file.id)).resolves.toMatchObject({ + status: 'ready', + }); + // 바이트는 다 올린 자리로 옮겨졌다 — 차례 없이 받은 조각이 제자리에 + const stream = await blobs.openRead(file.id, { + start: FILE_CHUNK_SIZE - 1, + end: FILE_CHUNK_SIZE + 2, + }); + const parts: Buffer[] = []; + for await (const part of stream) parts.push(part as Buffer); + expect(Buffer.concat(parts)).toEqual(Buffer.from([7, 0x78, 0x79, 0x7a])); + }); + + it('끝내기를 시작한 때를 적고, 다 올리면 지운다 · 다 올린 파일의 조각 기록도 지운다(다 올린 뒤에는 쓰지 않는다)', async () => { + const file = await create(3); + await service.writeChunk( + ARA, + file.id, + 0, + chunkUpload(Buffer.from('abc')), + ); + const transition = jest.spyOn(repository, 'transition'); + + await service.complete(ARA, file.id); + const [, from, changes] = transition.mock.calls[0]; + expect(from).toEqual(['uploading']); + expect(changes).toMatchObject({ state: 'completing' }); + expect(changes.completionStartedAt).toBeInstanceOf(Date); + expect(repository.rows.get(file.id)).toMatchObject({ + state: 'ready', + completionStartedAt: null, + }); + expect(repository.chunks.has(file.id)).toBe(false); + // 조각 기록이 없어도 다 올린 파일은 모든 조각을 받은 것으로 보인다 + await expect(service.get(ARA, file.id)).resolves.toMatchObject({ + receivedChunks: [0], + }); + }); + + it('끝내는 사이 상태가 바뀌었으면 — 정리 작업이 먼저 끝냈으면 그 결과를, 되돌렸으면 409 FILE_003 (상태 전이의 결과를 본다)', async () => { + const transitionTo = (state: StoredFileState) => { + const original = repository.transition.bind(repository); + jest + .spyOn(repository, 'transition') + .mockImplementation((id, from, changes) => { + const row = repository.rows.get(id); + // 다 올림으로 바꾸기 바로 앞에 정리 작업이 먼저 바꿨다 + if (changes.state === 'ready' && row) { + Object.assign(row, { + state, + completionStartedAt: null, + ...(state === 'ready' + ? { completedAt: new Date(), scanStatus: 'unscannable' } + : {}), + }); + } + return original(id, from, changes); + }); + }; + + const recovered = await create(3); + await service.writeChunk( + ARA, + recovered.id, + 0, + chunkUpload(Buffer.from('abc')), + ); + transitionTo('ready'); + await expect(service.complete(ARA, recovered.id)).resolves.toMatchObject({ + id: recovered.id, + status: 'ready', + }); + jest.restoreAllMocks(); + + const reverted = await create(3); + await service.writeChunk( + ARA, + reverted.id, + 0, + chunkUpload(Buffer.from('abc')), + ); + transitionTo('uploading'); + expect(await codeOf(() => service.complete(ARA, reverted.id))).toBe( + 'FILE_003', + ); + }); + + it('검사기가 있으면 다 올린 파일은 검사 중(pending)', async () => { + build({ clamav: { host: 'clamav', port: 3310 } }); + await expect(upload(Buffer.from('abc'))).resolves.toMatchObject({ + scanStatus: 'pending', + }); + }); + + it('지우기 — 보관량에서 바로 빠지고 바이트도 지운다 · 살아 있는 링크에 실린 파일은 409 FILE_003', async () => { + const done = await upload(Buffer.from('abc')); + expect(await service.quota(ARA)).toMatchObject({ usedBytes: 3 }); + + const row = repository.rows.get(done.id); + if (row) row.linkedUntil = new Date(Date.now() + 60_000); + expect(await codeOf(() => service.remove(ARA, done.id))).toBe('FILE_003'); + if (row) row.linkedUntil = null; + + await service.remove(ARA, done.id); + expect(await service.quota(ARA)).toMatchObject({ usedBytes: 0 }); + expect(repository.rows.get(done.id)).toMatchObject({ + state: 'removed', + removedReason: 'deleted', + }); + expect(await blobs.isStored(done.id)).toBe(false); + expect(await codeOf(() => service.get(ARA, done.id))).toBe('RES_001'); + }); + }); + + describe('메일 · 공유가 쓰는 것', () => { + it('checkUse — 보낼 때는 다 올림 · 위험 아님, 저장은 올리는 중 · 위험도 된다. 남의 것 · 없음 · 지움 · 기간 지남은 까닭을 준다', async () => { + const ready = await upload(Buffer.from('abc')); + const uploading = await create(10); + const infected = await upload(Buffer.from('def')); + const infectedRow = repository.rows.get(infected.id); + if (infectedRow) infectedRow.scanStatus = 'infected'; + const deleted = await upload(Buffer.from('ghi')); + await service.remove(ARA, deleted.id); + const theirs = await upload(Buffer.from('jkl'), DOYUN); + const missing = '11111111-2222-4333-8444-555555555555'; + + const ids = [ + ready.id, + uploading.id, + infected.id, + deleted.id, + theirs.id, + missing, + ]; + const send = await service.checkUse(ARA, ids, 'send'); + expect(send.usable.map((file) => file.id)).toEqual([ready.id]); + expect(Object.fromEntries(send.problems)).toEqual({ + [uploading.id]: 'uploading', + [infected.id]: 'infected', + [deleted.id]: 'deleted', + [theirs.id]: 'not-found', + [missing]: 'not-found', + }); + const draft = await service.checkUse(ARA, ids, 'draft'); + expect(draft.usable.map((file) => file.id)).toEqual([ + ready.id, + uploading.id, + infected.id, + ]); + }); + + it('임시 저장에 실리면 다 올린 날(한국)부터 30일째 날 끝까지 남는다', async () => { + const done = await upload(Buffer.from('abc')); + const before = Date.parse(done.expiresAt); + await service.attachToDraft(ARA, [done.id]); + const after = repository.rows.get(done.id)?.expiresAt.getTime() ?? 0; + expect(after - before).toBeGreaterThan(28 * 24 * 60 * 60 * 1000); + }); + + it('draftFiles — 그 직원의 것만, 지움 · 기간 지남은 MissingLargeFile, 남의 id · 기록이 없는 id 는 버린다', async () => { + const ready = await upload(Buffer.from('abc')); + const deleted = await upload(Buffer.from('def')); + await service.remove(ARA, deleted.id); + const expired = await upload(Buffer.from('ghi')); + const expiredRow = repository.rows.get(expired.id); + if (expiredRow) expiredRow.expiresAt = new Date(Date.now() - 1); + const theirs = await upload(Buffer.from('jkl'), DOYUN); + + const files = await service.draftFiles(ARA, [ + ready.id, + deleted.id, + expired.id, + theirs.id, + '11111111-2222-4333-8444-555555555555', + ]); + expect(files.map((file) => [file.id, file.status])).toEqual([ + [ready.id, 'ready'], + [deleted.id, 'missing'], + [expired.id, 'missing'], + ]); + expect(files[1]).toMatchObject({ + reason: 'deleted', + name: '도면.dwg', + size: 3, + }); + expect(files[2]).toMatchObject({ reason: 'expired' }); + }); + + it('applyLinkRetention — 링크가 끝나는 때까지 남고, 링크를 끊으면(보낸 뒤 임시 저장에 실리지 않음) 곧 끝난다', async () => { + const done = await upload(Buffer.from('abc')); + const linkEnd = new Date(Date.now() + 40 * 24 * 60 * 60 * 1000); + await service.applyLinkRetention([ + { fileId: done.id, linkedAt: new Date(), linkedUntil: linkEnd }, + ]); + expect(repository.rows.get(done.id)?.expiresAt).toEqual(linkEnd); + // 살아 있는 링크의 파일은 보내지 않은 파일 목록에 없다 + expect((await service.listUnsent(ARA, { limit: 20 })).items).toEqual([]); + + await service.applyLinkRetention([ + { fileId: done.id, linkedAt: new Date(), linkedUntil: null }, + ]); + expect( + repository.rows.get(done.id)?.expiresAt.getTime() ?? Infinity, + ).toBeLessThanOrEqual(Date.now()); + expect(await service.quota(ARA)).toMatchObject({ usedBytes: 0 }); + }); + + it('openContent — 바이트가 없으면 사라진 것으로 적는다', async () => { + const done = await upload(Buffer.from('abc')); + await blobs.remove(done.id); + await expect(service.openContent(done.id)).rejects.toThrow(); + expect(repository.rows.get(done.id)?.bytesDeletedAt).not.toBeNull(); + expect( + (await service.views([done.id])).get(done.id)?.bytesAvailable, + ).toBe(false); + }); + }); + + it('사용량 · 한도 (GET /api/file-quota)', async () => { + await create(5); + expect(await service.quota(ARA)).toEqual({ + usedBytes: 5, + limitBytes: 10 * GB, + maxFileBytes: 2047 * 1024 * 1024, + maxFilesPerMessage: 10, + chunkSize: FILE_CHUNK_SIZE, + retentionDays: 30, + }); + }); + + it('보내지 않은 파일 — 만든 때 최근부터, 커서로 이어 받는다 · 깨진 커서는 400 VAL_001', async () => { + for (let index = 0; index < 3; index += 1) { + const file = await create(1, `파일${index}.pdf`); + const row = repository.rows.get(file.id); + if (row) row.createdAt = new Date(Date.now() + index * 1000); + } + const first = await service.listUnsent(ARA, { limit: 2 }); + expect(first.items.map((file) => file.name)).toEqual([ + '파일2.pdf', + '파일1.pdf', + ]); + expect(first.pageInfo).toMatchObject({ hasNext: true, total: 3 }); + const second = await service.listUnsent(ARA, { + limit: 2, + cursor: first.pageInfo.nextCursor ?? undefined, + }); + expect(second.items.map((file) => file.name)).toEqual(['파일0.pdf']); + expect( + await codeOf(() => service.listUnsent(ARA, { limit: 2, cursor: '!!' })), + ).toBe('VAL_001'); + }); +}); diff --git a/backend/src/modules/file-storage/file-storage.service.ts b/backend/src/modules/file-storage/file-storage.service.ts new file mode 100644 index 0000000..e034b7d --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.service.ts @@ -0,0 +1,865 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import type { Readable } from 'stream'; + +import { + type PaginatedResult, + toPaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { traceTag } from '@/shared/logger/request-context'; +import { sanitizeFileName } from '@/shared/utils/file-name'; +import { maskEmail } from '@/shared/utils/mask'; +import { isStreamAbortError } from '@/shared/utils/stream-abort'; +import { + decodeOffsetCursor, + encodeOffsetCursor, +} from '@/shared/utils/offset-cursor'; +import { + ChunkGate, + chunkLength, + digestStream, + parseContentDigest, +} from './chunk-upload'; +import { + ChunkLengthMismatchError, + FILE_BLOB_STORE, + isDiskFull, + isMissingFile, + type BlobRange, + type FileBlobStore, +} from './file-blob-store'; +import { fileExpiresAt, isOnLiveLink } from './file-retention'; +import { FileScanService } from './file-scan.service'; +import { + FILE_CHUNK_CONCURRENCY, + FILE_CHUNK_CONCURRENCY_PER_USER, + FILE_CHUNK_SIZE, + FILE_PURPOSES, + MAX_UNSENT_FILES_PER_USER, + UPLOAD_IDLE_RETENTION_MS, +} from './file-storage.constants'; +import { FileStorageRepository } from './file-storage.repository'; +import { + FILE_STORAGE_SETTINGS, + type FileStorageSettings, +} from './file-storage.settings'; +import type { + ChunkUpload, + FileLinkRetention, + FileQuota, + FileUploadCreate, + FileUseProblem, + LargeFile, + MissingLargeFile, + StoredFileView, +} from './file-storage.types'; +import { isAllowedLargeFileName } from './large-file.policy'; +import { StoredFileEntity } from './stored-file.entity'; + +/** 보내지 않은 파일 목록의 커서 범위 (shared/utils/offset-cursor.ts) */ +const UNSENT_SCOPE = 'file-uploads'; + +const OCTET_STREAM = 'application/octet-stream'; + +/** 쓰려는 파일을 견주는 때 — 보낼 때는 다 올렸고 위험이 아니어야 한다 · 임시 저장은 올리는 중 · 위험도 된다 */ +export type FileUse = 'send' | 'draft'; + +/** 바이트가 저장소에 없다 (저장소 복원 등) — 내려받기는 "찾을 수 없음" */ +export class FileContentMissingError extends Error {} + +function toView(file: StoredFileEntity, now: Date): StoredFileView { + const state = file.state === 'completing' ? 'uploading' : file.state; + return { + id: file.id, + ownerEmail: file.ownerEmail, + name: file.name, + size: file.size, + state, + removedReason: file.removedReason, + scanStatus: file.scanStatus, + completedAt: file.completedAt, + expiresAt: file.expiresAt, + alive: file.state !== 'removed' && file.expiresAt > now, + bytesAvailable: + file.state === 'ready' && + file.bytesDeletedAt === null && + file.scanStatus !== 'infected', + }; +} + +/** + * 대용량 첨부 파일 · 올리기 (11단계 — 계약 4절 "올리기" · "한도" · "기간 · 보관 · 정리"). 바이트는 저장 포트, 정보는 DB. + * 메일 모듈(발송 · 임시 저장 · 읽기)과 공유 모듈(링크 · 공개 내려받기)이 이 서비스를 쓴다 — 이 모듈은 그 둘을 모른다(메일 → 공유 → 저장 한 방향). + * + * 로그(계약 4절 "로그"): 파일 이름은 남기지 않는다 — 파일 id · 크기만, 직원 주소는 가린다. 다 올림 · 보내지 않은 파일 지우기는 log, + * 조각은 남기지 않는다(반복되는 정상 흐름), 디스크가 모자라면 error. + */ +@Injectable() +export class FileStorageService { + private readonly logger = new Logger(FileStorageService.name); + + /** 동시에 받는 조각 — 서버 32 · 직원 4 · 같은 조각 하나 (프로세스 메모리 — 한 인스턴스 전제) */ + private readonly gate = new ChunkGate( + FILE_CHUNK_CONCURRENCY, + FILE_CHUNK_CONCURRENCY_PER_USER, + ); + + constructor( + private readonly repository: FileStorageRepository, + @Inject(FILE_BLOB_STORE) private readonly blobs: FileBlobStore, + @Inject(FILE_STORAGE_SETTINGS) + private readonly settings: FileStorageSettings, + private readonly scanner: FileScanService, + ) {} + + // ── 올리기 API (/api/file-uploads · /api/file-quota) ───────────────────────── + + /** + * 만들기 — 형식(정리한 이름의 확장자, 400 FILE_001) → 크기(413 FILE_002) → 직원 한도(409 FILE_005) → 서버 총량 · 디스크 여유(507 FILE_006). + * 한도는 선언한 크기로 미리 잡는다 — 조각을 보내는 동안에는 다시 보지 않는다. 만들기는 서버 전체에서 하나씩(DB 잠금) + */ + async create(owner: string, input: FileUploadCreate): Promise { + const name = sanitizeFileName(input.name); + if (!isAllowedLargeFileName(name)) { + throw new BusinessException('FILE_001', { + context: { reason: 'large-file-type-not-allowed' }, + }); + } + if (!(FILE_PURPOSES as readonly string[]).includes(input.purpose)) { + throw new BusinessException('VAL_001', { + context: { reason: 'unknown-file-purpose' }, + }); + } + if (input.size > this.settings.maxFileBytes) { + throw new BusinessException('FILE_002', { + context: { reason: 'large-file-too-big', size: input.size }, + }); + } + + const now = new Date(); + const ownerEmail = owner.toLowerCase(); + const row = await this.repository.createLocked( + ownerEmail, + now, + async (stats) => { + if ( + stats.userUsedBytes + input.size > this.settings.userQuotaBytes || + stats.userUnsentCount >= MAX_UNSENT_FILES_PER_USER + ) { + throw new BusinessException('FILE_005', { + context: { + reason: 'user-file-quota', + used: stats.userUsedBytes, + unsent: stats.userUnsentCount, + size: input.size, + }, + }); + } + const { serverMaxTotalBytes } = this.settings; + if ( + serverMaxTotalBytes !== null && + stats.serverUsedBytes + input.size > serverMaxTotalBytes + ) { + this.logger.error( + `[FILE_006]${traceTag()} 서버 전체 대용량 첨부 보관량 한도에 닿아 올리기를 거절합니다: 지금=${stats.serverUsedBytes}, 한도=${serverMaxTotalBytes}, 요청=${input.size}`, + ); + throw new BusinessException('FILE_006', { + context: { reason: 'server-file-quota' }, + }); + } + const free = await this.blobs.freeBytes(); + if ( + free !== null && + free - stats.outstandingUploadBytes - input.size < + this.settings.minFreeBytes + ) { + this.logger.error( + `[FILE_006]${traceTag()} 파일 저장소의 디스크 여유가 모자라 올리기를 거절합니다: 남음=${free}, 받을 몫=${stats.outstandingUploadBytes}, 요청=${input.size}, 남길 여유=${this.settings.minFreeBytes}`, + ); + throw new BusinessException('FILE_006', { + context: { reason: 'disk-free-space' }, + }); + } + + const row = new StoredFileEntity(); + Object.assign(row, { + id: randomUUID(), + ownerEmail, + purpose: input.purpose, + name, + size: input.size, + chunkSize: FILE_CHUNK_SIZE, + chunkCount: Math.ceil(input.size / FILE_CHUNK_SIZE), + lastModified: input.lastModified, + state: 'uploading', + removedReason: null, + firstChunkSha256: null, + scanStatus: null, + scanSignature: null, + scanAttempts: 0, + scanNextAt: null, + scannedAt: null, + createdAt: now, + lastChunkAt: null, + completedAt: null, + completionStartedAt: null, + draftAttachedAt: null, + linkedAt: null, + linkedUntil: null, + expiresAt: new Date(now.getTime() + UPLOAD_IDLE_RETENTION_MS), + removedAt: null, + bytesDeletedAt: null, + } satisfies Partial); + return row; + }, + ); + + try { + await this.blobs.begin(row.id, row.size); + } catch (error) { + await this.repository.transition(row.id, ['uploading'], { + state: 'removed', + removedReason: 'expired', + removedAt: new Date(), + expiresAt: new Date(), + bytesDeletedAt: new Date(), + }); + if (isDiskFull(error)) throw this.diskFull(row.id); + throw error; + } + return this.toLargeFile(row, []); + } + + /** 상태 — 남의 것 · 지운 것 · 기간이 지난 것 · 없는 것은 모두 404 RES_001 */ + async get(owner: string, id: string): Promise { + const file = await this.findVisible(owner, id, new Date()); + return this.present(file); + } + + /** + * 조각 하나 — 계약 4절 "올리기" 2. 본문은 이 처리기가 요청 스트림을 직접 읽는다(본문 파서를 지나지 않는다). + * 머리글 검사(형식 · 길이 · 압축 · 다이제스트 모양, 400 VAL_001) → 자리(동시 수 429 REQ_001 · 같은 조각 409 FILE_003) → + * 파일(404) → 받은 조각이면 견주기(같으면 200, 다르면 409 FILE_003) · 아니면 쓰고(datasync) 다이제스트를 보고(400 FILE_004) 적는다 + */ + async writeChunk( + owner: string, + id: string, + index: number, + upload: ChunkUpload, + ): Promise { + const declaredLength = this.checkChunkHeaders(upload); + const digest = parseContentDigest(upload.contentDigest); + if (digest === 'invalid') { + throw new BusinessException('VAL_001', { + context: { reason: 'chunk-digest-malformed' }, + }); + } + + const ticket = this.gate.tryEnter(owner.toLowerCase(), id, index); + if (ticket === 'chunk-busy') { + throw new BusinessException('FILE_003', { + context: { reason: 'same-chunk-in-progress', id, index }, + }); + } + if (typeof ticket === 'string') { + throw new BusinessException('REQ_001', { + context: { reason: `chunk-${ticket}`, user: maskEmail(owner) }, + }); + } + + try { + const file = await this.findVisible(owner, id, new Date()); + if (index >= file.chunkCount) { + throw new BusinessException('VAL_001', { + context: { reason: 'chunk-index-out-of-range', index }, + }); + } + const expected = chunkLength(file, index); + if (declaredLength !== expected) { + throw new BusinessException('VAL_001', { + context: { + reason: 'chunk-length-mismatch', + declared: declaredLength, + expected, + }, + }); + } + if (file.state !== 'uploading') { + throw new BusinessException('FILE_003', { + context: { reason: 'upload-not-open', id, state: file.state }, + }); + } + + const received = (await this.repository.receivedChunks(id)).find( + (chunk) => chunk.index === index, + ); + if (received) { + // 이미 받은 조각 — 쓰지 않고 내용을 견준다 (응답을 받지 못한 재시도) + const sha256 = await this.readChunkDigest(upload.body, expected); + if (digest !== null && !digest.equals(sha256)) { + throw this.chunkCorrupted(id, index); + } + if (!sha256.equals(received.sha256)) { + throw new BusinessException('FILE_003', { + context: { reason: 'chunk-content-differs', id, index }, + }); + } + return this.present(file); + } + + const written = await this.storeChunk(file, index, expected, upload.body); + if (digest !== null && !digest.equals(written)) { + throw this.chunkCorrupted(id, index); + } + const now = new Date(); + const recorded = await this.repository.recordChunk({ + fileId: id, + index, + size: expected, + sha256: written, + receivedAt: now, + expiresAt: new Date(now.getTime() + UPLOAD_IDLE_RETENTION_MS), + }); + if (!recorded) { + // 그사이 지웠거나(404) 끝냈다(409) + const current = await this.findVisible(owner, id, new Date()); + throw new BusinessException('FILE_003', { + context: { + reason: 'upload-closed-while-writing', + state: current.state, + }, + }); + } + return this.present(await this.findVisible(owner, id, new Date())); + } finally { + ticket.release(); + } + } + + /** + * 끝내기 — 조각이 모두 있으면 파일로 만들고(`ready`) 곧바로 악성 검사 차례에 넣는다. 이미 끝냈으면 그대로 200(응답을 못 받은 재시도). + * 빠진 조각 · 진행 중(끝내기 · 조각)이면 409 FILE_003 + */ + async complete(owner: string, id: string): Promise { + const file = await this.findVisible(owner, id, new Date()); + if (file.state === 'ready') return this.present(file); + if (file.state === 'completing') { + throw new BusinessException('FILE_003', { + context: { reason: 'completion-in-progress', id }, + }); + } + const chunks = await this.repository.receivedChunks(id); + if (chunks.length < file.chunkCount) { + throw new BusinessException('FILE_003', { + context: { + reason: 'chunks-missing', + id, + received: chunks.length, + total: file.chunkCount, + }, + }); + } + if ( + this.gate.isActive(id) || + !(await this.repository.transition(id, ['uploading'], { + state: 'completing', + completionStartedAt: new Date(), + })) + ) { + throw new BusinessException('FILE_003', { + context: { reason: 'completion-in-progress', id }, + }); + } + + try { + await this.blobs.complete(id); + } catch (error) { + // 되돌린다 — 그사이 정리 작업이 먼저 되돌렸으면(멈춘 끝내기로 보고) 그대로 둔다 + await this.repository.transition(id, ['completing'], { + state: 'uploading', + completionStartedAt: null, + }); + throw error; + } + + const now = new Date(); + const scanStatus = this.settings.clamav ? 'pending' : 'unscannable'; + const ready: StoredFileEntity = { + ...file, + state: 'ready', + completedAt: now, + scanStatus, + scanNextAt: scanStatus === 'pending' ? now : null, + }; + ready.expiresAt = fileExpiresAt(ready, this.settings.retentionDays, now); + const finished = await this.repository.transition(id, ['completing'], { + state: 'ready', + completedAt: now, + completionStartedAt: null, + scanStatus, + scanNextAt: ready.scanNextAt, + expiresAt: ready.expiresAt, + }); + if (!finished) { + // 그사이 상태가 바뀌었다 — 정리 작업이 먼저 끝냈으면(ready) 그것을 주고, 되돌렸거나 지웠으면 409 (다다음 요청이 지금 상태를 본다) + const current = await this.repository.findById(id); + if ( + current?.state === 'ready' && + this.isOwnedAlive(current, owner, now) + ) { + return this.present(current); + } + throw new BusinessException('FILE_003', { + context: { + reason: 'completion-state-changed', + id, + state: current?.state ?? 'missing', + }, + }); + } + // 다 올린 뒤에는 조각 기록을 쓰지 않는다 — 지워도 되고, 실패해도 그대로 둔다(셈은 올리는 중인 파일만 본다) + await this.repository + .deleteChunks(id) + .catch((error: unknown) => + this.logger.warn( + `[SYS_001]${traceTag()} 다 올린 파일의 조각 기록을 지우지 못했습니다: id=${id}, reason=${error instanceof Error ? error.message : String(error)}`, + ), + ); + this.logger.log( + `${traceTag()} 대용량 첨부를 다 올렸습니다: user=${maskEmail(owner)}, id=${id}, 크기=${file.size}, 검사=${scanStatus}`, + ); + this.scanner.kick(); + return this.present(ready); + } + + /** + * 보내지 않은 내 파일 — 살아 있는 링크에 실리지 않은 것(올리는 중 · 다 올림 · 위험 판정), 만든 때 최근부터, total 포함. + * 커서는 위치다 (지우는 사이 한두 개가 겹치거나 빠질 수 있다) + */ + async listUnsent( + owner: string, + query: { readonly cursor?: string; readonly limit: number }, + ): Promise> { + let offset = 0; + if (query.cursor !== undefined) { + const reading = decodeOffsetCursor(query.cursor, UNSENT_SCOPE); + if ('problem' in reading) { + throw new BusinessException('VAL_001', { + context: { reason: `invalid-file-cursor:${reading.problem}` }, + }); + } + offset = reading.offset; + } + const { rows, total } = await this.repository.listUnsent( + owner.toLowerCase(), + new Date(), + offset, + query.limit, + ); + const indexes = await this.repository.chunkIndexes( + rows.filter((row) => row.state !== 'ready').map((row) => row.id), + ); + const next = offset + rows.length; + const hasNext = next < total; + return toPaginatedResult( + rows.map((row) => this.toLargeFile(row, indexes.get(row.id) ?? [])), + { + hasNext, + nextCursor: hasNext ? encodeOffsetCursor(next, UNSENT_SCOPE) : null, + total, + }, + ); + } + + /** + * 그만두기 · 지우기 — 올리는 중이면 그만두고, 다 올렸어도 살아 있는 링크에 실리지 않았으면 지운다(보관량에서 바로 빠진다). + * 살아 있는 링크에 실린 파일 · 끝내는 중이면 409 FILE_003 + */ + async remove(owner: string, id: string): Promise { + const now = new Date(); + const file = await this.findVisible(owner, id, now); + if (isOnLiveLink(file, now) || file.state === 'completing') { + throw new BusinessException('FILE_003', { + context: { + reason: + file.state === 'completing' + ? 'completion-in-progress' + : 'file-on-live-link', + id, + }, + }); + } + const removed = await this.repository.transition( + id, + ['uploading', 'ready'], + { + state: 'removed', + removedReason: 'deleted', + removedAt: now, + expiresAt: now, + }, + ); + if (!removed) { + throw new BusinessException('FILE_003', { + context: { reason: 'file-state-changed', id }, + }); + } + await this.deleteBytes(id); + this.logger.log( + `${traceTag()} 보내지 않은 대용량 첨부를 지웠습니다: user=${maskEmail(owner)}, id=${id}, 크기=${file.size}, 올리는 중=${file.state === 'uploading'}`, + ); + } + + /** 사용량 · 한도 (GET /api/file-quota) — 지금 상태로 계산한다 */ + async quota(owner: string): Promise { + return { + usedBytes: await this.repository.usedBytes( + owner.toLowerCase(), + new Date(), + ), + limitBytes: this.settings.userQuotaBytes, + maxFileBytes: this.settings.maxFileBytes, + maxFilesPerMessage: this.settings.maxFilesPerMessage, + chunkSize: FILE_CHUNK_SIZE, + retentionDays: this.settings.retentionDays, + }; + } + + // ── 다른 모듈(메일 · 공유)이 쓰는 것 ───────────────────────────────────────── + + /** 설정 (메일 한 통의 개수 · 보관 날 수) */ + get limits(): Pick< + FileStorageSettings, + 'maxFilesPerMessage' | 'retentionDays' + > { + return { + maxFilesPerMessage: this.settings.maxFilesPerMessage, + retentionDays: this.settings.retentionDays, + }; + } + + /** 파일 보기 — 없는 id 는 빠진다 (주인은 부르는 쪽이 본다) */ + async views(ids: readonly string[]): Promise> { + const now = new Date(); + const rows = await this.repository.findByIds([...new Set(ids)]); + return new Map(rows.map((row) => [row.id, toView(row, now)])); + } + + /** + * 보내기 · 임시 저장에 쓸 수 있는지 — 내 것이고 살아 있어야 한다(없음 · 남의 것은 not-found, 지움 deleted, 기간 지남 expired). + * 보낼 때는 다 올렸고(uploading) 위험이 아니어야 한다(infected). 검사 중 · 검사하지 못함은 보낸다. + * @returns 쓸 수 있는 파일(요청 차례)과 쓸 수 없는 파일의 까닭 + */ + async checkUse( + owner: string, + ids: readonly string[], + use: FileUse, + ): Promise<{ + readonly usable: StoredFileView[]; + readonly problems: Map; + }> { + const found = await this.views(ids); + const usable: StoredFileView[] = []; + const problems = new Map(); + for (const id of ids) { + const view = found.get(id); + const problem = this.useProblem(owner, view, use); + if (problem !== null) problems.set(id, problem); + else if (view) usable.push(view); + } + return { usable, problems }; + } + + /** + * 임시 저장에 실렸다 — 다 올린 파일의 남는 기간이 24시간에서 다 올린 날부터 보관 날 수로 는다 (올리는 중은 그대로 마지막 조각 뒤 24시간). + * 취소해 되돌린 발송의 파일도 이것으로 임시 저장 기간에 돌아간다. 내 것 · 살아 있는 것만 + */ + async attachToDraft(owner: string, ids: readonly string[]): Promise { + if (ids.length === 0) return; + const now = new Date(); + const rows = await this.repository.findByIds([...new Set(ids)]); + for (const row of rows) { + if (!this.isOwnedAlive(row, owner, now)) continue; + const next = { ...row, draftAttachedAt: now }; + await this.repository.update(row.id, { + draftAttachedAt: now, + expiresAt: fileExpiresAt(next, this.settings.retentionDays, now), + }); + } + } + + /** + * 링크 상태가 바뀌었다(공유 모듈 — 만들기 · 취소 · 끊기) — 파일마다 링크의 때를 적고 남는 기간을 다시 계산한다. + * 지운 파일은 그대로 둔다 + */ + async applyLinkRetention( + entries: readonly FileLinkRetention[], + ): Promise { + if (entries.length === 0) return; + const now = new Date(); + const rows = new Map( + ( + await this.repository.findByIds(entries.map((entry) => entry.fileId)) + ).map((row) => [row.id, row]), + ); + for (const entry of entries) { + const row = rows.get(entry.fileId); + if (!row || row.state === 'removed') continue; + const next = { + ...row, + linkedAt: entry.linkedAt, + linkedUntil: entry.linkedUntil, + }; + await this.repository.update(row.id, { + linkedAt: entry.linkedAt, + linkedUntil: entry.linkedUntil, + expiresAt: fileExpiresAt(next, this.settings.retentionDays, now), + }); + } + } + + /** + * 내려받을 내용을 연다 — 바이트가 저장소에 없으면(복원 등) 사라진 것으로 적고 FileContentMissingError (내려받기는 410 FILE_007 "찾을 수 없음") + */ + async openContent(id: string, range?: BlobRange): Promise { + try { + return await this.blobs.openRead(id, range); + } catch (error) { + if (!isMissingFile(error)) throw error; + await this.repository.update(id, { bytesDeletedAt: new Date() }); + this.logger.warn( + `[FILE_007]${traceTag()} 내려받을 파일의 바이트가 저장소에 없어 "찾을 수 없음"으로 둡니다: id=${id}`, + ); + throw new FileContentMissingError(id); + } + } + + /** + * 임시 저장본의 대용량 파일 (GET /api/drafts/{id} 의 largeFiles) — 그 직원의 것만, 요청 차례. + * 살아 있으면 LargeFile, 지웠거나 기간이 지났으면 MissingLargeFile, 남의 id · 기록까지 지워진 id 는 버린다 + */ + async draftFiles( + owner: string, + ids: readonly string[], + ): Promise<(LargeFile | MissingLargeFile)[]> { + if (ids.length === 0) return []; + const now = new Date(); + const rows = new Map( + (await this.repository.findByIds([...new Set(ids)])).map((row) => [ + row.id, + row, + ]), + ); + const visible = [...rows.values()].filter( + (row) => this.sameOwner(row, owner) && this.isVisible(row, now), + ); + const indexes = await this.repository.chunkIndexes( + visible.filter((row) => row.state !== 'ready').map((row) => row.id), + ); + const result: (LargeFile | MissingLargeFile)[] = []; + for (const id of new Set(ids)) { + const row = rows.get(id); + if (!row || !this.sameOwner(row, owner)) continue; + if (this.isVisible(row, now)) { + result.push(this.toLargeFile(row, indexes.get(row.id) ?? [])); + } else { + result.push({ + id: row.id, + status: 'missing', + reason: row.removedReason === 'deleted' ? 'deleted' : 'expired', + name: row.name, + size: row.size, + }); + } + } + return result; + } + + // ── 내부 ──────────────────────────────────────────────────────────────── + + /** 머리글 — 바이트 본문만(400), 압축 없이(400), 길이가 있어야(400 — 청크 전송 아님). 길이를 돌려준다 */ + private checkChunkHeaders(upload: ChunkUpload): number { + const mediaType = (upload.contentType ?? '') + .split(';')[0] + .trim() + .toLowerCase(); + if (mediaType !== OCTET_STREAM) { + throw new BusinessException('VAL_001', { + context: { reason: 'chunk-content-type' }, + }); + } + const encoding = upload.contentEncoding?.trim().toLowerCase(); + if (encoding && encoding !== 'identity') { + throw new BusinessException('VAL_001', { + context: { reason: 'chunk-content-encoding' }, + }); + } + const length = upload.contentLength?.trim(); + if ( + upload.transferEncoding !== undefined || + length === undefined || + !/^\d{1,10}$/.test(length) + ) { + throw new BusinessException('VAL_001', { + context: { reason: 'chunk-content-length' }, + }); + } + return Number(length); + } + + private async readChunkDigest( + body: Readable, + expected: number, + ): Promise { + try { + return await digestStream(body, expected); + } catch (error) { + throw this.chunkReadFailure(error); + } + } + + /** 조각을 저장소에 쓴다 — 디스크가 차면 507 FILE_006(error), 그사이 지운 올리기 404, 끊긴 본문 400 */ + private async storeChunk( + file: StoredFileEntity, + index: number, + expected: number, + body: Readable, + ): Promise { + try { + const written = await this.blobs.writeChunk( + file.id, + index * file.chunkSize, + expected, + body, + ); + return written.sha256; + } catch (error) { + if (isDiskFull(error)) throw this.diskFull(file.id); + if (isMissingFile(error)) { + throw new BusinessException('RES_001', { + context: { reason: 'upload-removed-while-writing', id: file.id }, + }); + } + throw this.chunkReadFailure(error); + } + } + + /** + * 본문을 다 받지 못했다(길이가 다름 · 클라이언트가 끊음) — 받지 않은 조각이다(400). 그 밖의 오류(디스크 등)는 그대로(500 — error 로그). + * 스트림이 destroyed 인지로 가르지 않는다 — 쓰다 실패해도 `for await` 가 본문을 destroy 한다 (shared/utils/stream-abort.ts) + */ + private chunkReadFailure(error: unknown): unknown { + if ( + error instanceof ChunkLengthMismatchError || + isStreamAbortError(error) + ) { + return new BusinessException('VAL_001', { + context: { reason: 'chunk-incomplete' }, + }); + } + return error; + } + + private chunkCorrupted(id: string, index: number): BusinessException { + return new BusinessException('FILE_004', { + context: { reason: 'chunk-digest-mismatch', id, index }, + }); + } + + private diskFull(id: string): BusinessException { + this.logger.error( + `[FILE_006]${traceTag()} 파일 저장소의 디스크가 찼습니다 (ENOSPC): id=${id}`, + ); + return new BusinessException('FILE_006', { + context: { reason: 'disk-full', id }, + }); + } + + /** 바이트를 지운다 — 실패해도 던지지 않는다(정리 작업이 다시 지운다) */ + private async deleteBytes(id: string): Promise { + try { + await this.blobs.remove(id); + await this.repository.update(id, { bytesDeletedAt: new Date() }); + } catch (error) { + this.logger.warn( + `[SYS_001]${traceTag()} 파일 바이트를 지우지 못했습니다 — 정리 작업이 다시 지웁니다: id=${id}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private sameOwner(file: StoredFileEntity, owner: string): boolean { + return file.ownerEmail.toLowerCase() === owner.toLowerCase(); + } + + /** 보이는 파일 — 지우지 않았고 기간 안 */ + private isVisible(file: StoredFileEntity, now: Date): boolean { + return file.state !== 'removed' && file.expiresAt > now; + } + + private isOwnedAlive(file: StoredFileEntity, owner: string, now: Date) { + return this.sameOwner(file, owner) && this.isVisible(file, now); + } + + /** 그 직원의 보이는 파일 — 아니면 404 RES_001 (남의 것 · 지운 것 · 기간이 지난 것 · 없는 것을 가리지 않는다) */ + private async findVisible( + owner: string, + id: string, + now: Date, + ): Promise { + const file = await this.repository.findById(id); + if (!file || !this.isOwnedAlive(file, owner, now)) { + throw new BusinessException('RES_001', { + context: { reason: 'file-not-found', id }, + }); + } + return file; + } + + private useProblem( + owner: string, + view: StoredFileView | undefined, + use: FileUse, + ): FileUseProblem | null { + if (!view || view.ownerEmail.toLowerCase() !== owner.toLowerCase()) { + return 'not-found'; + } + if (!view.alive) { + return view.removedReason === 'deleted' ? 'deleted' : 'expired'; + } + if (use === 'send') { + if (view.state !== 'ready') return 'uploading'; + if (view.scanStatus === 'infected') return 'infected'; + } + return null; + } + + private async present(file: StoredFileEntity): Promise { + if (file.state === 'ready') return this.toLargeFile(file, []); + const indexes = await this.repository.chunkIndexes([file.id]); + return this.toLargeFile(file, indexes.get(file.id) ?? []); + } + + private toLargeFile( + file: StoredFileEntity, + receivedChunks: number[], + ): LargeFile { + const ready = file.state === 'ready'; + return { + id: file.id, + name: file.name, + size: file.size, + status: ready ? 'ready' : 'uploading', + chunkSize: file.chunkSize, + chunkCount: file.chunkCount, + receivedChunks: ready + ? Array.from({ length: file.chunkCount }, (_, index) => index) + : [...receivedChunks].sort((a, b) => a - b), + firstChunkSha256: file.firstChunkSha256, + scanStatus: ready ? file.scanStatus : null, + createdAt: file.createdAt.toISOString(), + expiresAt: file.expiresAt.toISOString(), + lastModified: file.lastModified, + }; + } +} diff --git a/backend/src/modules/file-storage/file-storage.settings.ts b/backend/src/modules/file-storage/file-storage.settings.ts new file mode 100644 index 0000000..72d9788 --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.settings.ts @@ -0,0 +1,125 @@ +import { parsePositiveInt } from '@/shared/utils/env'; + +/** + * 대용량 첨부 · 파일 저장 설정 — 루트 env 에서 compose 가 넣는다 (계약 4절 "환경 변수"). 검사는 env.validation.ts 가 기동 때 한다. + * 여기는 값이 비었거나(compose 의 빈 문자열) 형식이 틀리면 기본값으로 — 검사를 건너뛴 경우의 방어선이다. + */ +export const FILE_STORAGE_SETTINGS = Symbol('FILE_STORAGE_SETTINGS'); + +export interface ClamavSettings { + readonly host: string; + readonly port: number; +} + +export interface FileStorageSettings { + /** 파일을 두는 곳 — 운영은 named volume `_files` → `/app/files` (compose 가 FILE_STORAGE_DIR 로 고정) */ + readonly dir: string; + /** 한 파일 (바이트) — ClamAV 가 검사할 수 있는 2GiB 아래 */ + readonly maxFileBytes: number; + /** 메일 한 통에 새로 싣는 대용량 파일 수 */ + readonly maxFilesPerMessage: number; + /** 직원당 보관량 (바이트) */ + readonly userQuotaBytes: number; + /** 보낸 날(한국)부터 · 임시 저장에 실린 파일이 남는 날 수 */ + readonly retentionDays: number; + /** 서버 전체 보관량 (바이트) — null 이면 한도 없음(디스크 여유만 본다) */ + readonly serverMaxTotalBytes: number | null; + /** 만든 뒤에도 남길 디스크 여유 (바이트) */ + readonly minFreeBytes: number; + /** 악성 검사기(clamd) — null 이면 검사하지 않는다(개발 — 다 올리자마자 unscannable). 운영은 env 검증이 필수로 막는다 */ + readonly clamav: ClamavSettings | null; +} + +export const MEGABYTE = 1024 * 1024; +export const GIGABYTE = 1024 * MEGABYTE; + +/** 한 파일 한도의 상한(MB) — ClamAV 가 검사할 수 있는 2GiB 아래 (2,047MB = 2,146,435,072바이트) */ +export const LARGE_FILE_MAX_SIZE_MB_LIMIT = 2047; +/** 메일 한 통의 대용량 파일 수 상한 — 발송 · 임시 저장 본문의 largeFileIds 길이 상한(남용 방지)과 같다 */ +export const LARGE_FILE_MAX_COUNT_LIMIT = 20; +export const LARGE_FILE_USER_QUOTA_GB_LIMIT = 1_000; +export const LARGE_FILE_RETENTION_DAYS_LIMIT = 90; + +export const DEFAULT_LARGE_FILE_MAX_SIZE_MB = LARGE_FILE_MAX_SIZE_MB_LIMIT; +export const DEFAULT_LARGE_FILE_MAX_COUNT = 10; +export const DEFAULT_LARGE_FILE_USER_QUOTA_GB = 10; +export const DEFAULT_LARGE_FILE_RETENTION_DAYS = 30; +export const DEFAULT_FILE_STORAGE_MIN_FREE_GB = 10; +export const DEFAULT_CLAMAV_PORT = 3310; +/** 컨테이너의 작업 폴더(/app) 기준 — 운영은 compose 가 `/app/files` 로 넣는다. 호스트 개발에서는 backend/files (gitignore) */ +export const DEFAULT_FILE_STORAGE_DIR = './files'; + +function readTrimmed( + read: (key: string) => string | undefined, + key: string, +): string | null { + const value = read(key)?.trim(); + return value ? value : null; +} + +/** 양의 정수 env — 비었거나 틀리면 기본값, 상한을 넘으면 상한 */ +function boundedInt( + read: (key: string) => string | undefined, + key: string, + fallback: number, + max: number, +): number { + return Math.min(parsePositiveInt(read(key), fallback), max); +} + +/** env 값을 읽어 설정을 만든다 (순수 함수 — 읽기 함수를 주입받는다) */ +export function buildFileStorageSettings( + read: (key: string) => string | undefined, +): FileStorageSettings { + const totalGb = readTrimmed(read, 'FILE_STORAGE_MAX_TOTAL_GB'); + const parsedTotalGb = totalGb === null ? null : parsePositiveInt(totalGb, 0); + const clamavHost = readTrimmed(read, 'CLAMAV_HOST'); + + return { + dir: readTrimmed(read, 'FILE_STORAGE_DIR') ?? DEFAULT_FILE_STORAGE_DIR, + maxFileBytes: + boundedInt( + read, + 'MAIL_LARGE_FILE_MAX_SIZE_MB', + DEFAULT_LARGE_FILE_MAX_SIZE_MB, + LARGE_FILE_MAX_SIZE_MB_LIMIT, + ) * MEGABYTE, + maxFilesPerMessage: boundedInt( + read, + 'MAIL_LARGE_FILE_MAX_COUNT', + DEFAULT_LARGE_FILE_MAX_COUNT, + LARGE_FILE_MAX_COUNT_LIMIT, + ), + userQuotaBytes: + boundedInt( + read, + 'MAIL_LARGE_FILE_USER_QUOTA_GB', + DEFAULT_LARGE_FILE_USER_QUOTA_GB, + LARGE_FILE_USER_QUOTA_GB_LIMIT, + ) * GIGABYTE, + retentionDays: boundedInt( + read, + 'MAIL_LARGE_FILE_RETENTION_DAYS', + DEFAULT_LARGE_FILE_RETENTION_DAYS, + LARGE_FILE_RETENTION_DAYS_LIMIT, + ), + serverMaxTotalBytes: + parsedTotalGb === null || parsedTotalGb === 0 + ? null + : parsedTotalGb * GIGABYTE, + minFreeBytes: + parsePositiveInt( + read('FILE_STORAGE_MIN_FREE_GB'), + DEFAULT_FILE_STORAGE_MIN_FREE_GB, + ) * GIGABYTE, + clamav: clamavHost + ? { + host: clamavHost, + port: Math.min( + parsePositiveInt(read('CLAMAV_PORT'), DEFAULT_CLAMAV_PORT), + 65_535, + ), + } + : null, + }; +} diff --git a/backend/src/modules/file-storage/file-storage.types.ts b/backend/src/modules/file-storage/file-storage.types.ts new file mode 100644 index 0000000..dd4db3e --- /dev/null +++ b/backend/src/modules/file-storage/file-storage.types.ts @@ -0,0 +1,118 @@ +import type { Readable } from 'stream'; + +/** + * 파일 저장 도메인 타입 — 계약 4절 "타입" 과 같은 모양이다 (11단계 대용량 첨부, 2부 파일 저장소가 같은 파일 · 올리기를 쓴다). + */ + +/** 검사 중 · 깨끗함 · 위험 · 검사하지 못함 */ +export type ScanStatus = 'pending' | 'clean' | 'infected' | 'unscannable'; + +/** 내가 올린 대용량 파일 — 올리기 API 의 응답 · 보내지 않은 파일 목록 · Draft.largeFiles */ +export interface LargeFile { + /** 올리기 id = 다 올린 뒤의 파일 id (바뀌지 않는다) */ + readonly id: string; + /** 서버가 정리한 이름 */ + readonly name: string; + /** 바이트 */ + readonly size: number; + readonly status: 'uploading' | 'ready'; + readonly chunkSize: number; + readonly chunkCount: number; + /** 받은 조각 번호(0 부터, 오름차순) — ready 면 모두 */ + readonly receivedChunks: number[]; + /** 받은 0번 조각의 SHA-256 (base64) — 받기 전이면 null */ + readonly firstChunkSha256: string | null; + /** ready 일 때만 (uploading 은 null) */ + readonly scanStatus: ScanStatus | null; + readonly createdAt: string; + /** 이대로 두면 지워지는 때 */ + readonly expiresAt: string; + /** 만들 때 받은 File.lastModified (ms) */ + readonly lastModified: number | null; +} + +/** 임시 저장본이 가리키는데 이제 없는 파일 */ +export interface MissingLargeFile { + readonly id: string; + readonly status: 'missing'; + /** 지운 파일 · 기간이 지남 */ + readonly reason: 'deleted' | 'expired'; + readonly name: string | null; + readonly size: number | null; +} + +/** GET /api/file-quota */ +export interface FileQuota { + readonly usedBytes: number; + readonly limitBytes: number; + readonly maxFileBytes: number; + readonly maxFilesPerMessage: number; + readonly chunkSize: number; + readonly retentionDays: number; +} + +/** 만들기 요청 (서비스가 받는 값 — DTO 가 형식을 본 뒤) */ +export interface FileUploadCreate { + readonly name: string; + readonly size: number; + readonly purpose: string; + readonly lastModified: number | null; +} + +/** 조각 요청 — 컨트롤러가 요청 머리글과 본문 스트림을 그대로 넘긴다 (본문 파서를 지나지 않는다) */ +export interface ChunkUpload { + readonly contentType: string | undefined; + readonly contentEncoding: string | undefined; + readonly contentLength: string | undefined; + readonly transferEncoding: string | undefined; + readonly contentDigest: string | undefined; + readonly body: Readable; +} + +/** + * 다른 모듈(공유 · 메일)이 보는 파일 하나 — 주인 · 상태 · 검사 결과 (내용 없이). + * `alive` 는 지우지 않았고 기간 안이다(위험 판정 파일은 기록이 남는 동안 alive), `bytesAvailable` 은 내려줄 바이트가 저장소에 있다(위험 · 지움 · 사라짐이 아님) + */ +export interface StoredFileView { + readonly id: string; + readonly ownerEmail: string; + readonly name: string; + readonly size: number; + readonly state: 'uploading' | 'ready' | 'removed'; + readonly removedReason: 'deleted' | 'expired' | null; + readonly scanStatus: ScanStatus | null; + readonly completedAt: Date | null; + readonly expiresAt: Date; + readonly alive: boolean; + readonly bytesAvailable: boolean; +} + +/** 쓰려는 파일이 쓸 수 없는 까닭 — 메일이 MAIL_016 으로 바꾼다 */ +export type FileUseProblem = + | 'not-found' + | 'deleted' + | 'expired' + | 'uploading' + | 'infected'; + +/** 링크 쪽(공유 모듈)이 알려 주는 파일의 링크 상태 — 보관 기간 계산에 쓴다 */ +export interface FileLinkRetention { + readonly fileId: string; + /** 취소하지 않은 링크 가운데 가장 늦게 만든 때 — 없으면 null */ + readonly linkedAt: Date | null; + /** 취소 · 끊지 않은 링크 가운데 가장 늦게 끝나는 때 — 없으면 null */ + readonly linkedUntil: Date | null; +} + +/** 위험 판정 소식 — 메일 모듈이 실시간 알림(`large-file`)으로 보낸다 */ +export interface FileScanVerdictEvent { + readonly ownerEmail: string; + readonly fileId: string; + readonly name: string; + readonly scanStatus: ScanStatus; +} + +/** 내려받을 파일 내용 — 범위가 있으면 그 부분만 */ +export interface FileContent { + readonly stream: Readable; +} diff --git a/backend/src/modules/file-storage/file-upload.controller.ts b/backend/src/modules/file-storage/file-upload.controller.ts new file mode 100644 index 0000000..635850c --- /dev/null +++ b/backend/src/modules/file-storage/file-upload.controller.ts @@ -0,0 +1,271 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiBody, + ApiConsumes, + ApiCookieAuth, + ApiHeader, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import type { Request } from 'express'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { NoStore } from '@/common/decorators/no-store.decorator'; +import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + FileQuotaDto, + FileUploadCreateDto, + LargeFileDto, + LargeFilePageDto, +} from './dto/file-upload.dto'; +import { ChunkIndexPipe, FileIdPipe } from './file-id.pipe'; +import { + FILE_CHUNK_CONCURRENCY, + FILE_CHUNK_CONCURRENCY_PER_USER, + FILE_CHUNK_SIZE, + FILE_CHUNK_THROTTLE_LIMIT, + FILE_CHUNK_THROTTLE_TTL_MS, + FILE_QUOTA_ROUTE, + FILE_UPLOADS_ROUTE, + MAX_UNSENT_FILES_PER_USER, +} from './file-storage.constants'; +import { FileStorageService } from './file-storage.service'; + +/** 요청 머리글 하나 (여럿이면 첫 값) */ +function header(req: Request, name: string): string | undefined { + const value = req.headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +/** + * 대용량 첨부 올리기 (11단계 — 계약 4절 "올리기") — 만들기 → 조각(16MiB, 차례 자유) → 끝내기. 로그인한 직원 자신의 파일만. + * 2부 파일 저장소가 같은 API 를 쓴다(purpose). + */ +@ApiTags('Files') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(FILE_UPLOADS_ROUTE) +export class FileUploadController { + constructor(private readonly files: FileStorageService) {} + + @Post() + @NoStore() + @ApiOperation({ + summary: '대용량 첨부 올리기 만들기', + description: + '형식은 정리한 이름의 확장자로만 본다(대용량 허용 목록 — 일반 첨부 + mp4 · mov · dwg · dxf · psd · ai · 7z, 밖이면 400 FILE_001). 브라우저가 주는 형식(MIME)은 받지 않는다. ' + + `그다음 크기(413 FILE_002) → 직원 한도(보관량 · 보내지 않은 파일 ${MAX_UNSENT_FILES_PER_USER}개 — 409 FILE_005) → 서버 총량 · 디스크 여유(507 FILE_006) 차례로 본다. ` + + '보관량은 선언한 크기로 미리 잡는다.', + }) + @ApiStandardResponse(LargeFileDto, { + status: HttpStatus.CREATED, + description: '만든 올리기 (uploading, receivedChunks 비어 있음)', + }) + @ApiErrorResponse( + 'VAL_001', + '형식 오류 (이름 1~255자 · 크기 1 이상 정수 · purpose)', + ) + @ApiErrorResponse('FILE_001', '대용량 첨부로 받지 않는 형식') + @ApiErrorResponse('FILE_002', '한 파일 한도 초과') + @ApiErrorResponse('FILE_005', '직원 보관 한도 초과 — 만들지 않았다') + @ApiErrorResponse('FILE_006', '서버 저장 공간이 모자란다 — 만들지 않았다') + create( + @CurrentUser() user: AuthenticatedUser, + @Body() dto: FileUploadCreateDto, + ) { + return this.files.create(user.email, { + name: dto.name, + size: dto.size, + purpose: dto.purpose, + lastModified: dto.lastModified ?? null, + }); + } + + @Get() + @NoStore() + @ApiOperation({ + summary: '보내지 않은 내 파일 (만든 때 최근부터, 커서 페이지네이션)', + description: + '살아 있는 링크(보낸 메일)에 실리지 않은 내 파일 — 올리는 중 · 다 올림(검사 상태 포함). 보내기를 취소해 되돌린 파일도 여기 든다. total 포함.', + }) + @ApiStandardResponse(LargeFilePageDto, { description: '보내지 않은 파일' }) + @ApiErrorResponse('VAL_001', '깨진 커서 · limit 범위 밖') + list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: CursorPaginationQueryDto, + ) { + return this.files.listUnsent(user.email, query); + } + + @Get(':id') + @NoStore() + @ApiOperation({ + summary: '올리기 · 파일 상태', + description: + '끊긴 뒤 무엇을 받았는지 본다(receivedChunks). 올리는 중은 마지막 조각 뒤 24시간 지나면 404 다.', + }) + @ApiStandardResponse(LargeFileDto, { description: '파일' }) + @ApiErrorResponse( + 'RES_001', + '없음 · 남의 것 · 지운 것 · 기간이 지난 것 · UUID 모양이 아닌 id (가리지 않는다)', + ) + get( + @CurrentUser() user: AuthenticatedUser, + @Param('id', FileIdPipe) id: string, + ) { + return this.files.get(user.email, id); + } + + @Put(':id/chunks/:index') + @NoStore() + // 이 경로만 넉넉하게 — 16MiB 조각은 한 사람이 1분에 수십 번 보내고 사무실은 IP 하나로 나간다 (두 번째 limiter 가 아니다) + @Throttle({ + default: { + ttl: FILE_CHUNK_THROTTLE_TTL_MS, + limit: FILE_CHUNK_THROTTLE_LIMIT, + }, + }) + @ApiOperation({ + summary: '조각 하나 올리기 (본문 = 그 조각의 바이트)', + description: + `index 는 0 ~ chunkCount − 1. Content-Type: application/octet-stream 만(그 밖은 400 VAL_001), Content-Length 필수 = chunkSize(${FILE_CHUNK_SIZE.toLocaleString('ko-KR')} — 마지막 조각만 나머지, 없거나 다르면 읽기 전에 400), 압축(Content-Encoding) 400. ` + + '선택 Content-Digest: sha-256=:: (RFC 9530) — 받은 바이트와 다르면 400 FILE_004(그 조각을 받지 않았다). ' + + '다 받아 디스크에 쓴 뒤에만 받은 것으로 적는다. 이미 받은 조각은 쓰지 않고 견준다 — 같으면 200, 다르면 409 FILE_003. 끝낸 올리기 · 같은 조각을 동시에도 409 FILE_003. ' + + `요청 수는 이 경로만 IP 마다 1분 ${FILE_CHUNK_THROTTLE_LIMIT}회, 동시에 직원마다 ${FILE_CHUNK_CONCURRENCY_PER_USER}개 · 서버 전체 ${FILE_CHUNK_CONCURRENCY}개 — 넘으면 429 REQ_001(잠시 뒤 같은 조각을 다시). ` + + '디스크가 차면 507 FILE_006 — 그 조각은 받지 않았다.', + }) + @ApiConsumes('application/octet-stream') + @ApiParam({ + name: 'index', + type: Number, + description: + '조각 번호 — 0 부터, 앞자리 0 없는 10진수 (아니면 400 VAL_001)', + }) + @ApiHeader({ + name: 'Content-Digest', + required: false, + description: 'sha-256=:: — 웹은 늘 보낸다', + }) + @ApiBody({ schema: { type: 'string', format: 'binary' } }) + @ApiStandardResponse(LargeFileDto, { + description: '받은 조각을 반영한 올리기', + }) + @ApiErrorResponse( + 'VAL_001', + '형식 · 길이 · 압축 · 번호 범위 · 다이제스트 모양 · 끊긴 본문 (400)', + ) + @ApiErrorResponse('FILE_004', '조각이 보내는 중에 손상됨 — 다시 보낸다') + @ApiErrorResponse('RES_001', '없는 올리기') + @ApiErrorResponse( + 'FILE_003', + '끝낸 올리기 · 같은 조각을 동시에 · 받은 조각과 내용이 다름 (409)', + ) + @ApiErrorResponse('REQ_001', '요청 수 · 동시 수 한도') + @ApiErrorResponse('FILE_006', '디스크가 찼다') + chunk( + @CurrentUser() user: AuthenticatedUser, + @Param('id', FileIdPipe) id: string, + // unknown — number 로 적으면 전역 ValidationPipe 가 이 파이프 앞에서 글을 숫자로 바꿔 `1e1` · `0x1` 을 받아 버린다 (file-id.pipe.ts) + @Param('index', ChunkIndexPipe) index: unknown, + @Req() req: Request, + ) { + // ChunkIndexPipe 가 0 이상 정수로 바꿨다 + return this.files.writeChunk(user.email, id, index as number, { + contentType: header(req, 'content-type'), + contentEncoding: header(req, 'content-encoding'), + contentLength: header(req, 'content-length'), + transferEncoding: header(req, 'transfer-encoding'), + contentDigest: header(req, 'content-digest'), + body: req, + }); + } + + @Post(':id/completion') + @HttpCode(HttpStatus.OK) + @NoStore() + @ApiOperation({ + summary: '올리기 끝내기 — 파일로 만들고 악성 검사 차례에 넣는다', + description: + "조각이 모두 있으면 ready (scanStatus: 'pending' — 검사기를 쓰지 않으면 'unscannable'). 빠진 조각 · 진행 중이면 409 FILE_003(GET 으로 빠진 번호를 본다). 이미 끝냈으면 그대로 200.", + }) + @ApiStandardResponse(LargeFileDto, { description: '다 올린 파일' }) + @ApiErrorResponse('RES_001', '없는 올리기') + @ApiErrorResponse('FILE_003', '빠진 조각 · 끝내기 · 조각 받기가 진행 중') + complete( + @CurrentUser() user: AuthenticatedUser, + @Param('id', FileIdPipe) id: string, + ) { + return this.files.complete(user.email, id); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() + @NoStore() + @ApiOperation({ + summary: '올리기 그만두기 · 보내지 않은 파일 지우기', + description: + '올리는 중이면 그만두고, 다 올렸어도 살아 있는 링크에 실리지 않았으면 지운다 — 보관량에서 바로 빠진다. ' + + "그 파일을 싣고 있던 임시 저장본을 열면 MissingLargeFile(reason: 'deleted')로 온다. 살아 있는 링크에 실린 파일은 409 FILE_003(링크 끊기로).", + }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: '지움 — 본문 없음', + }) + @ApiErrorResponse('RES_001', '없는 파일') + @ApiErrorResponse('FILE_003', '살아 있는 링크에 실린 파일 · 끝내는 중') + async remove( + @CurrentUser() user: AuthenticatedUser, + @Param('id', FileIdPipe) id: string, + ): Promise { + await this.files.remove(user.email, id); + } +} + +/** 대용량 첨부 사용량 · 한도 (계약 4절 FileQuota) — 2부 파일 저장소가 그대로 쓴다(`/api/mail-quota` 에 섞지 않는다) */ +@ApiTags('Files') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(FILE_QUOTA_ROUTE) +export class FileQuotaController { + constructor(private readonly files: FileStorageService) {} + + @Get() + @NoStore() + @ApiOperation({ + summary: '대용량 첨부 사용량 · 한도', + description: + '쓰기 화면이 올리기 전에 막는 값(한 파일 · 메일 한 통의 개수 · 보관량)과 조각 크기 · 보관 날 수. 사용량은 지금 상태로 계산한다.', + }) + @ApiStandardResponse(FileQuotaDto, { description: '사용량 · 한도' }) + get(@CurrentUser() user: AuthenticatedUser) { + return this.files.quota(user.email); + } +} diff --git a/backend/src/modules/file-storage/large-file.policy.ts b/backend/src/modules/file-storage/large-file.policy.ts new file mode 100644 index 0000000..2aa5dfb --- /dev/null +++ b/backend/src/modules/file-storage/large-file.policy.ts @@ -0,0 +1,47 @@ +import { fileExtension } from '@/shared/utils/file-name'; + +/** + * 대용량 첨부로 받는 형식 (계약 4절 "한도"의 형식) — **허용 목록**만 받는다(.claude/rules/security.md "파일 업로드"). + * 일반 첨부의 허용 목록(modules/mail/attachment.policy.ts — 메일 모듈이 짝이 맞는지 테스트로 본다) + 동영상 · 설계 · 디자인 · 7z. + * 실행 형식(exe · msi · bat · cmd · js · vbs · scr …) · 디스크 이미지(iso · img · vhd) · 브라우저가 실행할 수 있는 형식(html · svg …)은 넣지 않는다. + * + * 형식은 정리한 이름의 확장자로만 본다 — 브라우저가 주는 형식(MIME)은 이 경로에서 믿을 근거가 없고(만들기는 JSON 이라 파일이 오지 않는다), + * 내려줄 때 늘 octet-stream + attachment + nosniff + sandbox 이며 내용은 악성 검사가 본다 (보안 규칙 "확장자와 MIME 둘 다"의 예외 — 계약 4절 "정한 것" 10). + * + * ⚠️ 짝: frontend 의 대용량 첨부 허용 형식 상수 — 쓰기 화면이 올리기 전에 같은 목록으로 막는다. 한쪽을 고치면 둘 다 + */ +export const LARGE_FILE_EXTENSIONS: readonly string[] = [ + // 일반 첨부와 같은 형식 + '.pdf', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.ppt', + '.pptx', + '.hwp', + '.hwpx', + '.txt', + '.csv', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.zip', + // 대용량 첨부에서만 — 동영상 · CAD · 디자인 · 압축 + '.mp4', + '.mov', + '.dwg', + '.dxf', + '.psd', + '.ai', + '.7z', +]; + +const ALLOWED = new Set(LARGE_FILE_EXTENSIONS); + +/** 정리한 이름의 확장자가 대용량 첨부 허용 목록에 있는가 */ +export function isAllowedLargeFileName(fileName: string): boolean { + return ALLOWED.has(fileExtension(fileName)); +} diff --git a/backend/src/modules/file-storage/local-disk-blob-store.spec.ts b/backend/src/modules/file-storage/local-disk-blob-store.spec.ts new file mode 100644 index 0000000..a21b8ff --- /dev/null +++ b/backend/src/modules/file-storage/local-disk-blob-store.spec.ts @@ -0,0 +1,143 @@ +import { createHash, randomUUID } from 'crypto'; +import { existsSync, mkdtempSync, rmSync, statSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { Readable } from 'stream'; + +import { ChunkLengthMismatchError, isMissingFile } from './file-blob-store'; +import { LocalDiskBlobStore } from './local-disk-blob-store'; + +async function readAll(stream: Readable): Promise { + const parts: Buffer[] = []; + for await (const part of stream) parts.push(part as Buffer); + return Buffer.concat(parts); +} + +/** 조각을 여러 토막으로 흘려보내는 본문 (요청 스트림처럼) */ +const bodyOf = (data: Buffer, pieceSize = 7) => + Readable.from( + Array.from({ length: Math.ceil(data.length / pieceSize) }, (_, index) => + data.subarray(index * pieceSize, (index + 1) * pieceSize), + ), + ); + +describe('LocalDiskBlobStore (로컬 디스크 저장)', () => { + let root: string; + let store: LocalDiskBlobStore; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'blob-store-')); + store = new LocalDiskBlobStore(root); + }); + + afterEach(() => rmSync(root, { recursive: true, force: true })); + + it('선언한 크기의 자리에 조각을 차례 없이 제자리에 쓰고, 끝내면 읽을 수 있다 — SHA-256 은 받은 바이트로', async () => { + const key = randomUUID(); + const first = Buffer.from('0123456789'); + const second = Buffer.from('abcde'); + await store.begin(key, 15); + + const written = await store.writeChunk(key, 10, 5, bodyOf(second)); + expect(written.bytes).toBe(5); + expect(written.sha256).toEqual( + createHash('sha256').update(second).digest(), + ); + await store.writeChunk(key, 0, 10, bodyOf(first)); + expect(await store.isIncoming(key)).toBe(true); + + await store.complete(key); + expect(await store.isIncoming(key)).toBe(false); + expect(await store.isStored(key)).toBe(true); + expect((await readAll(await store.openRead(key))).toString()).toBe( + '0123456789abcde', + ); + expect( + ( + await readAll(await store.openRead(key, { start: 8, end: 11 })) + ).toString(), + ).toBe('89ab'); + }); + + it('조각이 선언한 길이보다 짧거나 길면 ChunkLengthMismatchError — 받은 것으로 적지 않게', async () => { + const key = randomUUID(); + await store.begin(key, 10); + await expect( + store.writeChunk(key, 0, 10, bodyOf(Buffer.from('short'))), + ).rejects.toBeInstanceOf(ChunkLengthMismatchError); + await expect( + store.writeChunk(key, 0, 3, bodyOf(Buffer.from('too long'))), + ).rejects.toBeInstanceOf(ChunkLengthMismatchError); + }); + + it('본문이 중간에 끊기면 그 오류를 그대로 던진다 (요청이 끊긴 조각)', async () => { + const key = randomUUID(); + await store.begin(key, 10); + const broken = new Readable({ + read() { + this.push(Buffer.from('12345')); + this.destroy(new Error('aborted')); + }, + }); + await expect(store.writeChunk(key, 0, 10, broken)).rejects.toThrow( + 'aborted', + ); + }); + + it('없는 파일을 읽으면 ENOENT — 머리글을 쓰기 전에 알 수 있다', async () => { + const key = randomUUID(); + const error = await store.openRead(key).catch((caught: unknown) => caught); + expect(isMissingFile(error)).toBe(true); + }); + + it('지우기는 두 자리 모두 · 이미 없어도 된다', async () => { + const incoming = randomUUID(); + const stored = randomUUID(); + await store.begin(incoming, 1); + await store.begin(stored, 1); + await store.complete(stored); + + await store.remove(incoming); + await store.remove(stored); + await store.remove(randomUUID()); + expect(await store.list()).toEqual([]); + }); + + it('목록은 서버가 만든 키만 — 단계와 고친 때를 함께', async () => { + const a = randomUUID(); + const b = randomUUID(); + await store.begin(a, 1); + await store.begin(b, 1); + await store.complete(b); + + const listed = await store.list(); + expect( + listed + .map(({ key, stage }) => ({ key, stage })) + .sort((x, y) => x.key.localeCompare(y.key)), + ).toEqual( + [ + { key: a, stage: 'incoming' }, + { key: b, stage: 'stored' }, + ].sort((x, y) => x.key.localeCompare(y.key)), + ); + expect( + listed.every(({ modifiedAt }) => Number.isFinite(modifiedAt.getTime())), + ).toBe(true); + }); + + it('서버가 만든 키가 아니면 경로를 만들지 않는다 — 경로 탈출 방지', async () => { + await expect(store.begin('../../etc/passwd', 1)).rejects.toThrow('키 형식'); + await expect(store.openRead('ABC')).rejects.toThrow('키 형식'); + expect(existsSync(join(root, 'incoming'))).toBe(false); + }); + + it('선언한 크기의 자리는 빈 곳을 쓰지 않는 파일(크기만)이다 · 남은 공간을 알려 준다', async () => { + const key = randomUUID(); + await store.begin(key, 1024 * 1024); + expect(statSync(join(root, 'incoming', key)).size).toBe(1024 * 1024); + const free = await store.freeBytes(); + expect(typeof free).toBe('number'); + expect(free).toBeGreaterThan(0); + }); +}); diff --git a/backend/src/modules/file-storage/local-disk-blob-store.ts b/backend/src/modules/file-storage/local-disk-blob-store.ts new file mode 100644 index 0000000..91a1ab6 --- /dev/null +++ b/backend/src/modules/file-storage/local-disk-blob-store.ts @@ -0,0 +1,210 @@ +import { createHash } from 'crypto'; +import { + mkdir, + open, + readdir, + rename, + stat, + statfs, + unlink, +} from 'fs/promises'; +import { dirname, join } from 'path'; +import type { Readable } from 'stream'; + +import { + ChunkLengthMismatchError, + isMissingFile, + type BlobRange, + type FileBlobStore, + type StoredBlob, + type WrittenChunk, +} from './file-blob-store'; + +/** 서버가 만든 키(파일 id — 소문자 UUID)만 경로로 만든다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */ +const BLOB_KEY = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) — 서명 그림 저장과 같다 */ +const FILE_MODE = 0o640; + +const INCOMING_DIR = 'incoming'; +const STORED_DIR = 'objects'; +/** 다 올린 파일을 나누는 하위 폴더 이름 길이 — 한 폴더에 파일이 너무 많이 모이지 않게 (id 의 앞 두 글자) */ +const SHARD_LENGTH = 2; +const SHARD_NAME = /^[0-9a-f]{2}$/; + +function assertKey(key: string): void { + if (!BLOB_KEY.test(key)) { + // 서버가 만든 키가 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001) + throw new Error('파일 저장소 키 형식이 올바르지 않습니다.'); + } +} + +async function unlinkIfExists(path: string): Promise { + try { + await unlink(path); + } catch (error) { + if (!isMissingFile(error)) throw error; + } +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if (isMissingFile(error)) return false; + throw error; + } +} + +/** + * 로컬 디스크 저장 (FILE_BLOB_STORE 의 지금 구현) — `/incoming/`(올리는 중 — 선언한 크기의 파일에 조각을 제자리에 쓴다) → + * 끝내면 같은 볼륨의 `/objects/<앞 두 글자>/` 로 옮긴다(rename — 크기와 상관없이 바로 끝난다). + * 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 컨트롤러가 형식 · 머리글을 정한다 (.claude/rules/security.md "파일 업로드"). + * 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다. + */ +export class LocalDiskBlobStore implements FileBlobStore { + constructor(private readonly root: string) {} + + async begin(key: string, size: number): Promise { + const path = this.incomingPath(key); + await mkdir(dirname(path), { recursive: true }); + // wx — 같은 이름이 이미 있으면 덮어쓰지 않고 실패한다 (UUID 라 생기지 않지만 지키는 선) + const handle = await open(path, 'wx', FILE_MODE); + try { + // 선언한 크기로 늘려 둔다(빈 곳은 디스크를 쓰지 않는다) — 조각을 차례 없이 제자리에 쓴다 + await handle.truncate(size); + } finally { + await handle.close(); + } + } + + async writeChunk( + key: string, + offset: number, + expectedBytes: number, + source: Readable, + ): Promise { + const handle = await open(this.incomingPath(key), 'r+'); + const hash = createHash('sha256'); + let written = 0; + try { + // 한 조각씩 기다려 쓴다 — 디스크가 느리면 요청 읽기도 멈춘다(메모리에 쌓이지 않는다) + for await (const piece of source) { + const data = Buffer.isBuffer(piece) + ? piece + : Buffer.from(piece as Uint8Array); + if (written + data.length > expectedBytes) { + throw new ChunkLengthMismatchError( + expectedBytes, + written + data.length, + ); + } + let done = 0; + while (done < data.length) { + const { bytesWritten } = await handle.write( + data, + done, + data.length - done, + offset + written + done, + ); + done += bytesWritten; + } + hash.update(data); + written += data.length; + } + if (written !== expectedBytes) { + throw new ChunkLengthMismatchError(expectedBytes, written); + } + // 다 받았다고 적기 전에 디스크에 내려쓴다 — 받은 것으로 적은 조각이 전원이 꺼져도 남게 + await handle.datasync(); + return { bytes: written, sha256: hash.digest() }; + } finally { + await handle.close(); + } + } + + async complete(key: string): Promise { + const target = this.storedPath(key); + await mkdir(dirname(target), { recursive: true }); + await rename(this.incomingPath(key), target); + } + + isStored(key: string): Promise { + return exists(this.storedPath(key)); + } + + isIncoming(key: string): Promise { + return exists(this.incomingPath(key)); + } + + async openRead(key: string, range?: BlobRange): Promise { + const handle = await open(this.storedPath(key), 'r'); + // 스트림이 끝나거나 망가지면 파일도 닫는다 (autoClose 기본값) + return handle.createReadStream( + range ? { start: range.start, end: range.end } : {}, + ); + } + + async remove(key: string): Promise { + await unlinkIfExists(this.incomingPath(key)); + await unlinkIfExists(this.storedPath(key)); + } + + async list(): Promise { + const blobs: StoredBlob[] = []; + const incoming = join(this.root, INCOMING_DIR); + for (const name of await this.readDir(incoming)) { + if (!BLOB_KEY.test(name)) continue; + const modifiedAt = await this.modifiedAt(join(incoming, name)); + if (modifiedAt) blobs.push({ key: name, stage: 'incoming', modifiedAt }); + } + const stored = join(this.root, STORED_DIR); + for (const shard of await this.readDir(stored)) { + if (!SHARD_NAME.test(shard)) continue; + for (const name of await this.readDir(join(stored, shard))) { + if (!BLOB_KEY.test(name) || !name.startsWith(shard)) continue; + const modifiedAt = await this.modifiedAt(join(stored, shard, name)); + if (modifiedAt) blobs.push({ key: name, stage: 'stored', modifiedAt }); + } + } + return blobs; + } + + async freeBytes(): Promise { + await mkdir(this.root, { recursive: true }); + const stats = await statfs(this.root); + return Number(stats.bavail) * Number(stats.bsize); + } + + private incomingPath(key: string): string { + assertKey(key); + return join(this.root, INCOMING_DIR, key); + } + + private storedPath(key: string): string { + assertKey(key); + return join(this.root, STORED_DIR, key.slice(0, SHARD_LENGTH), key); + } + + /** 폴더의 항목 — 폴더가 없으면 빈 목록 */ + private async readDir(path: string): Promise { + try { + return await readdir(path); + } catch (error) { + if (isMissingFile(error)) return []; + throw error; + } + } + + /** 고친 때 — 그사이 지워졌으면 null */ + private async modifiedAt(path: string): Promise { + try { + return (await stat(path)).mtime; + } catch (error) { + if (isMissingFile(error)) return null; + throw error; + } + } +} diff --git a/backend/src/modules/file-storage/stored-file-chunk.entity.ts b/backend/src/modules/file-storage/stored-file-chunk.entity.ts new file mode 100644 index 0000000..c6a3c0f --- /dev/null +++ b/backend/src/modules/file-storage/stored-file-chunk.entity.ts @@ -0,0 +1,31 @@ +import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; + +import { StoredFileEntity } from './stored-file.entity'; + +/** + * 받은 조각 하나 (계약 4절 "올리기" 2) — 다 받아 디스크에 쓰고(datasync) 나서야 적는다. 파일 행을 지우면 함께 지워진다. + * SHA-256 은 서버가 받은 바이트로 계산한 값 — 같은 조각을 다시 보내면 이것과 견준다(같으면 200, 다르면 FILE_003) + */ +@Entity({ name: 'stored_file_chunks' }) +export class StoredFileChunkEntity { + @PrimaryColumn({ name: 'file_id', type: 'uuid' }) + fileId!: string; + + /** 조각 번호 (0 부터) */ + @PrimaryColumn({ name: 'chunk_index', type: 'integer' }) + chunkIndex!: number; + + /** 바이트 */ + @Column({ type: 'integer' }) + size!: number; + + @Column({ type: 'bytea' }) + sha256!: Buffer; + + @Column({ name: 'received_at', type: 'timestamptz' }) + receivedAt!: Date; + + @ManyToOne(() => StoredFileEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'file_id' }) + file?: StoredFileEntity; +} diff --git a/backend/src/modules/file-storage/stored-file.entity.ts b/backend/src/modules/file-storage/stored-file.entity.ts new file mode 100644 index 0000000..3a6e170 --- /dev/null +++ b/backend/src/modules/file-storage/stored-file.entity.ts @@ -0,0 +1,163 @@ +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { BIGINT_NUMBER } from '@/shared/utils/bigint-column'; + +/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */ +const EMAIL_COLUMN_LENGTH = 254; +/** 정리한 파일 이름 — 코드 포인트 255자 (PostgreSQL varchar 는 글자 수로 센다) */ +const NAME_COLUMN_LENGTH = 255; +/** 상태 · 까닭 · 쓰임 같은 짧은 낱말 */ +const WORD_COLUMN_LENGTH = 16; +/** SHA-256 의 base64 (44자) */ +const SHA256_BASE64_LENGTH = 44; +/** 검사기가 준 판정 이름 (예: Win.Test.EICAR_HDB-1) */ +const SIGNATURE_COLUMN_LENGTH = 255; + +/** 파일(올리기)의 상태 — 올리는 중 · 끝내는 중 · 다 올림 · 지움(사용자가 지움 · 기간이 지남) */ +export type StoredFileState = 'uploading' | 'completing' | 'ready' | 'removed'; + +/** + * 대용량 첨부 파일 하나 (계약 4절 — 올리기 id = 다 올린 뒤의 파일 id, 바뀌지 않는다). 바이트는 저장 포트(FILE_BLOB_STORE)에, 여기는 정보만 둔다. + * + * 보관 기간은 `expires_at` 하나로 본다 — 상태 · 다 올린 때 · 임시 저장에 실린 때 · 링크(공유 모듈이 알려 준다)로 계산해 바뀔 때마다 적는다 + * (file-retention.ts). 보관량 · 보내지 않은 파일 · 정리가 모두 이 값으로 "요청 때 상태"를 본다 — 정리 작업이 늦어도 바로 줄어든다. + * - `linked_at` · `linked_until`: 공유 모듈이 적는 링크 상태 (취소하지 않은 링크의 가장 늦게 만든 때 · 끊지 않은 링크의 가장 늦은 끝) + * - 위험 판정 파일은 바이트를 곧 지우고(`bytes_deleted_at`) 기록은 판정 뒤 30일(또는 링크가 끝날 때까지) 남긴다 — 보관량에는 넣지 않는다 + */ +@Entity({ name: 'stored_files' }) +@Index('IDX_stored_files_owner_created', ['ownerEmail', 'createdAt']) +@Index('IDX_stored_files_alive_expires', ['expiresAt'], { + where: `"state" <> 'removed'`, +}) +@Index('IDX_stored_files_scan_pending', ['scanNextAt'], { + where: `"scan_status" = 'pending'`, +}) +@Index('IDX_stored_files_removed', ['removedAt'], { + where: `"state" = 'removed'`, +}) +export class StoredFileEntity { + @PrimaryColumn({ type: 'uuid' }) + id!: string; + + @Column({ name: 'owner_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH }) + ownerEmail!: string; + + /** 쓰임 — 'mail' (2부 파일 저장소가 더한다) */ + @Column({ type: 'varchar', length: WORD_COLUMN_LENGTH }) + purpose!: string; + + @Column({ type: 'varchar', length: NAME_COLUMN_LENGTH }) + name!: string; + + /** 선언한 크기 (바이트) */ + @Column({ type: 'bigint', transformer: BIGINT_NUMBER }) + size!: number; + + @Column({ name: 'chunk_size', type: 'integer' }) + chunkSize!: number; + + @Column({ name: 'chunk_count', type: 'integer' }) + chunkCount!: number; + + /** 만들 때 받은 File.lastModified (ms) */ + @Column({ + name: 'last_modified', + type: 'bigint', + nullable: true, + transformer: BIGINT_NUMBER, + }) + lastModified!: number | null; + + @Column({ type: 'varchar', length: WORD_COLUMN_LENGTH }) + state!: StoredFileState; + + /** 지운 까닭 — 'deleted'(사용자) · 'expired'(기간) */ + @Column({ + name: 'removed_reason', + type: 'varchar', + length: WORD_COLUMN_LENGTH, + nullable: true, + }) + removedReason!: 'deleted' | 'expired' | null; + + /** 받은 0번 조각의 SHA-256 (base64) */ + @Column({ + name: 'first_chunk_sha256', + type: 'varchar', + length: SHA256_BASE64_LENGTH, + nullable: true, + }) + firstChunkSha256!: string | null; + + /** 다 올린 뒤의 검사 결과 — 올리는 중은 null */ + @Column({ + name: 'scan_status', + type: 'varchar', + length: WORD_COLUMN_LENGTH, + nullable: true, + }) + scanStatus!: 'pending' | 'clean' | 'infected' | 'unscannable' | null; + + /** 검사기가 준 판정 이름 (위험 · 검사하지 못함) */ + @Column({ + name: 'scan_signature', + type: 'varchar', + length: SIGNATURE_COLUMN_LENGTH, + nullable: true, + }) + scanSignature!: string | null; + + /** 이어서 실패한 검사 수 (검사기에 닿지 못함 등) */ + @Column({ name: 'scan_attempts', type: 'integer', default: 0 }) + scanAttempts!: number; + + /** 다음 검사 시각 — 검사를 기다리는 동안만 */ + @Column({ name: 'scan_next_at', type: 'timestamptz', nullable: true }) + scanNextAt!: Date | null; + + @Column({ name: 'scanned_at', type: 'timestamptz', nullable: true }) + scannedAt!: Date | null; + + @Column({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; + + @Column({ name: 'last_chunk_at', type: 'timestamptz', nullable: true }) + lastChunkAt!: Date | null; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt!: Date | null; + + /** + * 끝내기를 시작한 때 — 끝내는 중(completing)일 때만 값이 있다. 정리 작업은 이것이 오래된 끝내기만 멈춘 것으로 본다 + * (마지막 조각의 때로 보면 방금 시작한 끝내기를 되돌릴 수 있었다 — 2026-10-30 리뷰 L4) + */ + @Column({ + name: 'completion_started_at', + type: 'timestamptz', + nullable: true, + }) + completionStartedAt!: Date | null; + + /** 임시 저장에 마지막으로 실린 때 */ + @Column({ name: 'draft_attached_at', type: 'timestamptz', nullable: true }) + draftAttachedAt!: Date | null; + + /** 취소하지 않은 링크 가운데 가장 늦게 만든 때 (공유 모듈이 적는다) */ + @Column({ name: 'linked_at', type: 'timestamptz', nullable: true }) + linkedAt!: Date | null; + + /** 취소 · 끊지 않은 링크 가운데 가장 늦게 끝나는 때 (공유 모듈이 적는다) — 지금보다 뒤면 살아 있는 링크에 실린 파일 */ + @Column({ name: 'linked_until', type: 'timestamptz', nullable: true }) + linkedUntil!: Date | null; + + /** 이대로 두면 지워지는 때 (file-retention.ts) */ + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt!: Date; + + @Column({ name: 'removed_at', type: 'timestamptz', nullable: true }) + removedAt!: Date | null; + + /** 저장소의 바이트를 지운 때 — 정리 작업 · 위험 판정 · 지우기 */ + @Column({ name: 'bytes_deleted_at', type: 'timestamptz', nullable: true }) + bytesDeletedAt!: Date | null; +} diff --git a/backend/src/modules/mail/attachment.policy.spec.ts b/backend/src/modules/mail/attachment.policy.spec.ts index 1083eb2..e3e1300 100644 --- a/backend/src/modules/mail/attachment.policy.spec.ts +++ b/backend/src/modules/mail/attachment.policy.spec.ts @@ -138,28 +138,9 @@ describe('메일 첨부 형식 정책', () => { }); }); -describe('sanitizeFileName', () => { - it('경로를 버리고 파일명만 남긴다 (경로 탈출 방지)', () => { +describe('sanitizeFileName (shared/utils/file-name.ts 를 다시 내보낸다)', () => { + it('메일 쪽 이름으로도 같은 정리를 쓴다', () => { expect(sanitizeFileName('../../etc/passwd')).toBe('passwd'); - expect(sanitizeFileName('C:\\Users\\kim\\보고서.pdf')).toBe('보고서.pdf'); - }); - - it('제어 문자와 방향 제어 문자를 지운다 (확장자 뒤집기 위장 방지)', () => { - expect(sanitizeFileName('a\r\nb.txt')).toBe('ab.txt'); - expect(sanitizeFileName('invoice\u202Etxt.exe')).toBe('invoicetxt.exe'); - }); - - it('너무 긴 이름은 확장자를 남기고 자른다', () => { - const long = `${'가'.repeat(300)}.xlsx`; - const result = sanitizeFileName(long); - - expect(Array.from(result)).toHaveLength(255); - expect(result.endsWith('.xlsx')).toBe(true); - }); - - it('남는 것이 없으면 기본 이름을 쓴다', () => { - expect(sanitizeFileName('')).toBe('첨부파일'); - expect(sanitizeFileName('dir/')).toBe('첨부파일'); }); }); diff --git a/backend/src/modules/mail/attachment.policy.ts b/backend/src/modules/mail/attachment.policy.ts index 0fde152..bc8b821 100644 --- a/backend/src/modules/mail/attachment.policy.ts +++ b/backend/src/modules/mail/attachment.policy.ts @@ -1,6 +1,12 @@ import { extname } from 'path'; -import { MAX_FILE_NAME_LENGTH } from './mail.constants'; +import { + isControlOrBidiChar, + sanitizeFileName, +} from '@/shared/utils/file-name'; + +/** 파일명 정리는 shared/utils/file-name.ts 로 옮겼다(11단계 — 대용량 첨부와 함께 쓴다). 메일 쪽 이름을 그대로 쓰게 다시 내보낸다 */ +export { sanitizeFileName }; /** * 메일 첨부 형식·크기 정책 (순수 함수) — `modules/upload/upload.config.ts` 와 같은 원칙을 따른다. @@ -160,9 +166,6 @@ const ATTACHMENT_TYPES: ReadonlyMap = new Map([ ], ]); -/** 정리 후 이름이 비면 쓰는 이름 */ -const FALLBACK_FILE_NAME = '첨부파일'; - /** 'Text/Plain; charset=UTF-8' → 'text/plain' */ export function normalizeMimeType(mimeType: string): string { return mimeType.split(';')[0].trim().toLowerCase(); @@ -211,33 +214,6 @@ export function resolveAttachmentsTotalLimit( return engineKnown ? Math.min(configuredBytes, engineBytes) : configuredBytes; } -/** - * 화면 표시·헤더용으로 파일명을 정리한다. - * - 경로 구분자 앞부분을 버린다 (클라이언트가 보낸 경로를 쓰지 않는다) - * - 제어 문자와 방향 제어 문자(확장자를 거꾸로 보이게 하는 U+202E 등)를 지운다 - * - 길이를 제한하되 확장자는 남긴다 - */ -export function sanitizeFileName(rawName: string): string { - const lastSegment = rawName.split(/[\\/]/).pop() ?? ''; - const cleaned = Array.from(lastSegment) - .filter((char) => !isControlOrBidiChar(char.codePointAt(0) ?? 0)) - .join('') - .trim(); - - const chars = Array.from(cleaned); - if (chars.length === 0) return FALLBACK_FILE_NAME; - if (chars.length <= MAX_FILE_NAME_LENGTH) return cleaned; - - const extensionChars = Array.from(extname(cleaned)); - if (extensionChars.length >= MAX_FILE_NAME_LENGTH) { - return chars.slice(0, MAX_FILE_NAME_LENGTH).join(''); - } - return ( - chars.slice(0, MAX_FILE_NAME_LENGTH - extensionChars.length).join('') + - extensionChars.join('') - ); -} - /** * 원문 파일 이름의 제목 부분 최대 글자 수(코드 포인트). 한글(UTF-8 3바이트)뿐이면 `.eml` 까지 240+4 바이트라 * 파일 시스템의 이름 한도(255바이트) 안에 든다. 4바이트 문자(이모지 등)가 많으면 넘을 수 있는데, @@ -293,15 +269,3 @@ export function messageSourceFileName(subject: string): string { ).join(''); return `${base || SOURCE_FALLBACK_NAME}${SOURCE_EXTENSION}`; } - -/** C0·C1 제어 문자, DEL, 방향 제어 문자 */ -function isControlOrBidiChar(codePoint: number): boolean { - return ( - codePoint <= 0x1f || - (codePoint >= 0x7f && codePoint <= 0x9f) || - codePoint === 0x200e || - codePoint === 0x200f || - (codePoint >= 0x202a && codePoint <= 0x202e) || - (codePoint >= 0x2066 && codePoint <= 0x2069) - ); -} diff --git a/backend/src/modules/mail/dto/mail-response.dto.ts b/backend/src/modules/mail/dto/mail-response.dto.ts index 3fb2656..e2d67c2 100644 --- a/backend/src/modules/mail/dto/mail-response.dto.ts +++ b/backend/src/modules/mail/dto/mail-response.dto.ts @@ -1,6 +1,15 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { PageInfoDto } from '@/common/dto/pagination.dto'; +import type { SharedFileStatus } from '@/modules/file-sharing/file-sharing.types'; +import { + LargeFileDto, + MissingLargeFileDto, +} from '@/modules/file-storage/dto/file-upload.dto'; +import type { + LargeFile, + MissingLargeFile, +} from '@/modules/file-storage/file-storage.types'; import { CONTACT_EMAIL_LABELS, type ContactEmailLabel, @@ -19,6 +28,7 @@ import type { MessageAttachment, MessageDeletionResult, MessageDetail, + MessageLargeFile, MessagesPatchResult, MessageSummary, PendingSend, @@ -388,6 +398,79 @@ export class MessageDetailDto type: [MessageAttachmentDto], }) attachments!: MessageAttachmentDto[]; + + @ApiProperty({ + description: + '우리 서버가 넣은 대용량 첨부 덩이의 파일 (11단계) — 표시 주석 사이의 우리 링크이고 링크 주인이 이 메일의 보낸 사람일 때만, 없으면 빈 배열. ' + + '그때 htmlBody 안 그 덩이의 바깥 요소에 data-ara-large-files 가 붙는다', + type: () => [MessageLargeFileDto], + }) + largeFiles!: MessageLargeFileDto[]; +} + +const SHARED_FILE_STATUSES: SharedFileStatus[] = [ + 'available', + 'unscannable', + 'scanning', + 'infected', + 'limit-reached', + 'revoked', + 'expired', + 'missing', +]; + +export class MessageLargeFileDownloadsDto { + @ApiProperty({ description: '받은 수 (0번째 바이트부터 받기 시작한 수)' }) + count!: number; + + @ApiProperty({ + description: '마지막으로 받은 때', + nullable: true, + type: String, + }) + lastDownloadedAt!: string | null; +} + +/** 메일 속 대용량 첨부 하나 (계약 4절 MessageLargeFile) */ +export class MessageLargeFileDto implements MessageLargeFile { + @ApiProperty({ description: '링크 id' }) + linkId!: string; + + @ApiProperty({ description: '파일 id — 받기 경로에 쓴다' }) + fileId!: string; + + @ApiProperty({ description: '서버 기록의 이름 — 메일 글이 아니다' }) + name!: string; + + @ApiProperty({ description: '바이트' }) + size!: number; + + @ApiProperty({ description: '파일 상태', enum: SHARED_FILE_STATUSES }) + status!: SharedFileStatus; + + @ApiProperty({ description: '링크가 끝나는 때' }) + expiresAt!: string; + + @ApiProperty({ + description: '링크를 끊은 때 (보낸 사람 · 운영) — 끊지 않았으면 null', + nullable: true, + type: String, + example: '2026-10-22T05:12:00.000Z', + }) + revokedAt!: string | null; + + @ApiProperty({ + description: + '공개 화면 주소 /s/<토큰> — 본문 덩이의 그 주소 (같은 링크의 파일은 같은 주소)', + }) + url!: string; + + @ApiProperty({ + description: '보는 사람이 그 링크를 만든 사람일 때만 — 아니면 null', + nullable: true, + type: MessageLargeFileDownloadsDto, + }) + downloads!: MessageLargeFileDownloadsDto | null; } export class MessagePageDto { @@ -520,6 +603,7 @@ export class MessageDeletionResultDto implements MessageDeletionResult { } /** 계약 2절 "임시 저장"의 Draft — 쓰기 화면이 이어 쓴다 */ +@ApiExtraModels(LargeFileDto, MissingLargeFileDto) export class DraftDto implements Draft { @ApiProperty({ description: @@ -580,6 +664,20 @@ export class DraftDto implements Draft { example: '2026-10-27T06:12:00.000Z', }) updatedAt!: string; + + @ApiProperty({ + description: + '대용량 첨부 (11단계) — 초안 머리글의 파일 가운데 그 직원의 것만: 있으면 LargeFile(올리는 중 포함), 지웠거나 기간이 지났으면 MissingLargeFile. ' + + '취소해 되돌린 발송은 그 링크의 파일 (본문의 덩이는 뗐다)', + type: 'array', + items: { + oneOf: [ + { $ref: getSchemaPath(LargeFileDto) }, + { $ref: getSchemaPath(MissingLargeFileDto) }, + ], + }, + }) + largeFiles!: (LargeFile | MissingLargeFile)[]; } /** 계약 2절 "임시 저장"의 DraftSaved */ diff --git a/backend/src/modules/mail/dto/send-message.dto.ts b/backend/src/modules/mail/dto/send-message.dto.ts index 7d25961..878e05d 100644 --- a/backend/src/modules/mail/dto/send-message.dto.ts +++ b/backend/src/modules/mail/dto/send-message.dto.ts @@ -1,3 +1,4 @@ +import { applyDecorators } from '@nestjs/common'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { @@ -5,14 +6,21 @@ import { ArrayMinSize, IsArray, IsEmail, + IsInt, IsNotEmpty, IsOptional, IsString, Matches, + Max, MaxLength, + Min, ValidateNested, } from 'class-validator'; +import { ArrayDistinct } from '@/common/decorators/array-distinct.decorator'; +import { FILE_ID_PATTERN } from '@/modules/file-storage/file-id.pipe'; +import { LARGE_FILE_MAX_COUNT_LIMIT } from '@/modules/file-storage/file-storage.settings'; + import { MAX_ATTACHMENT_LIST_LENGTH, MAX_ATTACHMENTS_PER_MESSAGE, @@ -163,6 +171,40 @@ export class ComposeContentDto { @IsOptional() @Matches(MAIL_ID_PATTERN, { message: '답장할 메일을 다시 선택해 주세요.' }) inReplyTo?: string; + + @ApiPropertyOptional({ + description: + `이 메일에 새로 싣는 내 대용량 파일 — 올리기 id(POST /api/file-uploads 의 id), 겹치지 않게, ${LARGE_FILE_MAX_COUNT_LIMIT}개까지(남용 방지 — 실제 한도는 GET /api/file-quota 의 maxFilesPerMessage, 넘으면 보낼 때 400 VAL_001). ` + + '보낼 때는 다 올렸고 위험이 아니어야 하고(아니면 400 MAIL_016), 임시 저장은 올리는 중이어도 된다(11단계 — 계약 4절 "발송 · 임시 저장과 함께")', + type: [String], + example: ['0f8fad5b-d9cb-469f-a165-70867728950e'], + }) + @IsOptional() + // 형식 → 개수 → 겹침 → 항목 모양 (적은 차례대로 돈다) + @applyDecorators( + IsArray({ message: '대용량 첨부 목록 형식이 올바르지 않습니다.' }), + ArrayMaxSize(LARGE_FILE_MAX_COUNT_LIMIT, { + message: `대용량 첨부는 한 메일에 ${LARGE_FILE_MAX_COUNT_LIMIT}개를 넘을 수 없습니다.`, + }), + ArrayDistinct({ message: '같은 대용량 첨부를 두 번 실었습니다.' }), + Matches(FILE_ID_PATTERN, { + each: true, + message: '대용량 첨부를 다시 선택해 주세요.', + }), + ) + largeFileIds?: string[]; + + @ApiPropertyOptional({ + description: + 'body(텍스트)에서 인용이 시작하는 자리 = 대용량 첨부 덩이를 넣을 자리 — 0 ~ body.length(JS 문자열 위치)이고 줄의 처음(0 이거나 바로 앞이 줄바꿈). ' + + '아니면 400 VAL_001. 없으면 덩이는 맨 끝이다. 서식 본문은 인용 바로 앞의 빈 표시
로 알린다 (11단계)', + example: 1234, + }) + @IsOptional() + @IsInt({ message: '인용 시작 자리 형식이 올바르지 않습니다.' }) + @Min(0, { message: '인용 시작 자리 형식이 올바르지 않습니다.' }) + @Max(MAX_BODY_LENGTH, { message: '인용 시작 자리 형식이 올바르지 않습니다.' }) + bodyQuoteStart?: number; } /** POST /api/messages — 계약 2절 "발송 본문" */ diff --git a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts index bf6f2b7..6e4c94c 100644 --- a/backend/src/modules/mail/html/sanitize-mail-html.spec.ts +++ b/backend/src/modules/mail/html/sanitize-mail-html.spec.ts @@ -459,3 +459,33 @@ describe('sanitizeMailHtml', () => { }); }); }); + +describe('sanitizeMailHtml — 대용량 첨부 덩이 표시 (11단계)', () => { + it('서비스가 넘긴 표시 값의 div 에만 data-ara-large-files 를 붙인다 — 받은 HTML 의 같은 속성 · 다른 값은 지운다', () => { + const html = + '

흉내

' + + '

맞히려는 값

' + + '
덩이
'; + const { html: sanitized } = sanitizeMailHtml(html, new Map(), { + largeFilesMarker: 'secret-marker', + }); + expect(sanitized).toBe( + '

흉내

맞히려는 값

' + + '
덩이
', + ); + }); + + it('표시 값을 주지 않으면 붙이지 않는다', () => { + const { html } = sanitizeMailHtml( + '

글

', + ); + expect(html).toBe('

글

'); + }); + + it('div 의 배경 그림(외부 주소)은 지금처럼 차단으로 알린다', () => { + const result = sanitizeMailHtml( + '
x
', + ); + expect(result.hasBlockedImages).toBe(true); + }); +}); diff --git a/backend/src/modules/mail/html/sanitize-mail-html.ts b/backend/src/modules/mail/html/sanitize-mail-html.ts index d437578..8a152f1 100644 --- a/backend/src/modules/mail/html/sanitize-mail-html.ts +++ b/backend/src/modules/mail/html/sanitize-mail-html.ts @@ -190,8 +190,17 @@ const ALLOWED_ATTRIBUTES: Record = { del: ['datetime'], ins: ['datetime'], details: ['open'], + // 서버가 확인한 대용량 첨부 덩이의 바깥 요소 표시(11단계) — 받은 HTML 의 같은 속성은 transformTags.div 가 늘 지우고, 서비스가 넘긴 표시에만 붙인다 + div: ['data-ara-large-files'], }; +/** + * 대용량 첨부 덩이 표시 (계약 4절 "우리 웹메일에서 읽기") — 서비스가 확인한 덩이를 `
` 로 감싸 넘기면 + * 그 div 에만 `data-ara-large-files` 를 붙인다. 받은 HTML 에 있던 두 속성은 지운다 — 그래서 서버만 붙일 수 있다 + */ +const LARGE_FILES_ATTRIBUTE = 'data-ara-large-files'; +export const LARGE_FILES_MARKER_ATTRIBUTE = 'data-ara-lf'; + const BOX_SIDES = ['', '-top', '-right', '-bottom', '-left']; /** 인라인 style 에서 남기는 표시용 속성 — position·z-index·content 등은 넣지 않는다 */ @@ -366,6 +375,11 @@ export interface SanitizeMailHtmlOptions { * 없으면 지금처럼 외부 이미지를 지운다 */ readonly externalImageSrc?: (url: URL) => string; + /** + * 서버가 확인한 대용량 첨부 덩이의 표시 값 (11단계) — 서비스가 그 덩이를 `
` 로 감싸 넘긴다(요청마다 무작위 — 보낸 사람이 맞힐 수 없다). + * 이 값과 같은 div 에만 `data-ara-large-files` 를 붙인다. 없으면 붙이지 않는다 + */ + readonly largeFilesMarker?: string; } /** @@ -419,6 +433,17 @@ export function sanitizeMailHtml( disallowedTagsMode: 'discard', transformTags: { a: (tagName, attribs) => ({ tagName, attribs: secureLink(attribs) }), + div: (tagName, attribs) => { + if (loadsExternalImage(attribs)) markBlocked(); + const marked = + options.largeFilesMarker !== undefined && + attribs[LARGE_FILES_MARKER_ATTRIBUTE] === options.largeFilesMarker; + const next: sanitizeHtml.Attributes = { ...attribs }; + delete next[LARGE_FILES_MARKER_ATTRIBUTE]; + delete next[LARGE_FILES_ATTRIBUTE]; + if (marked) next[LARGE_FILES_ATTRIBUTE] = ''; + return { tagName, attribs: next }; + }, img: (tagName, attribs) => { const next = resolveImage(attribs, imageContext); // 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지) — 이름을 '' 로 바꾸면 허용 목록에 없어 diff --git a/backend/src/modules/mail/html/sanitize-outgoing-html.ts b/backend/src/modules/mail/html/sanitize-outgoing-html.ts index efca5c3..712bd80 100644 --- a/backend/src/modules/mail/html/sanitize-outgoing-html.ts +++ b/backend/src/modules/mail/html/sanitize-outgoing-html.ts @@ -1,5 +1,6 @@ import sanitizeHtml from 'sanitize-html'; +import { QUOTE_START_ATTRIBUTE } from '@/modules/mail/large-file-block'; import { NON_TEXT_TAGS, SAFE_LINK } from './sanitize-mail-html'; import { parseSignatureImageId, signatureImagePath } from './signature-images'; @@ -91,6 +92,12 @@ const IMAGE_WIDTH = /^[1-9]\d{0,2}$/; export interface OutgoingSanitizeOptions { /** 남겨도 되는 서명 그림 id — 보내는 사람 자신의 그림(소문자 UUID). 없으면 그림을 하나도 남기지 않는다 */ readonly signatureImageIds?: ReadonlySet; + /** + * 쓰기 화면의 인용 경계 표시(`
` — 11단계 대용량 첨부의 덩이 자리)를 남긴다 — 발송 · 임시 저장의 준비만. + * div 에만, 속성 하나짜리 여는 태그(`
`)로 — 그 밖의 data-* 는 지금처럼 지운다. + * 서비스가 첫 표시 자리를 읽고 표시를 모두 지운다 (large-file-block.ts 의 extractQuoteStart — 받는 사람 · 임시 저장본 · 지문에 들지 않는다) + */ + readonly keepQuoteStart?: boolean; } // ── style 값 형식 ─────────────────────────────────────────────────────────── @@ -297,9 +304,12 @@ export function sanitizeOutgoingHtml( options: OutgoingSanitizeOptions = {}, ): string { const signatureImageIds = options.signatureImageIds ?? NO_SIGNATURE_IMAGES; + const keepQuoteStart = options.keepQuoteStart === true; return sanitizeHtml(html, { allowedTags: ALLOWED_TAGS, - allowedAttributes: ALLOWED_ATTRIBUTES, + allowedAttributes: keepQuoteStart + ? { ...ALLOWED_ATTRIBUTES, div: [QUOTE_START_ATTRIBUTE] } + : ALLOWED_ATTRIBUTES, allowedSchemes: [], allowedSchemesByTag: { a: ['http', 'https', 'mailto'] }, allowProtocolRelative: false, @@ -307,6 +317,11 @@ export function sanitizeOutgoingHtml( nonTextTags: NON_TEXT_TAGS, disallowedTagsMode: 'discard', transformTags: { + // 인용 경계 표시는 속성 하나만 남긴다(style 등은 뗀다) — 서비스가 글자 그대로 찾는다 + div: (tagName, attribs) => + keepQuoteStart && QUOTE_START_ATTRIBUTE in attribs + ? { tagName, attribs: { [QUOTE_START_ATTRIBUTE]: '' } } + : { tagName, attribs }, a: (_tagName, attribs) => keepSafeLink(attribs), ol: (tagName, attribs) => ({ tagName, diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 2fdaf23..5efb4f3 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -10,6 +10,7 @@ import { MAX_BODY_VALUE_BYTES, MAX_ENVELOPE_RESUBMITS, } from '@/modules/mail/mail.constants'; +import { SendNotStartedException } from '@/modules/mail/mail.provider'; import type { MailSettings } from '@/modules/mail/mail.settings'; import type { DraftContent, @@ -31,7 +32,11 @@ import { decodeCursor, encodeCursor } from './jmap-cursor'; import { JmapFilterRunner } from './jmap-filter-run'; import { JmapFilterScriptStore } from './jmap-filter-script'; import { WATCH_DEBOUNCE_MS, WATCH_START_TIMEOUT_MS } from './jmap-mail-watch'; -import { JmapMailProvider, buildThreadHeaders } from './jmap-mail.provider'; +import { + JmapMailProvider, + buildThreadHeaders, + parseLargeFileIds, +} from './jmap-mail.provider'; import { MAILBOX_PROPERTIES } from './jmap.mapper'; import type { JmapInvocation } from './jmap.types'; @@ -3142,6 +3147,26 @@ describe('JmapMailProvider', () => { expect(warn).toHaveBeenCalled(); }); + it('보내는 메일에는 대용량 첨부 머리글(X-Ara-Large-Files)을 싣지 않는다 — 덩이(본문)만 간다 (11단계 — 임시 저장 전용 머리글)', async () => { + const { provider, recorded } = fakeClient([prepared, fits, sent, queued]); + const html = + '

보냅니다.

대용량 첨부
'; + + // 임시 저장 본문이 섞여 들어와도(largeFileIds) 보내는 길은 머리글을 만들지 않는다 + await provider.sendMessage(ARA, { + ...outgoing, + htmlBody: html, + largeFileIds: ['0f8fad5b-d9cb-469f-a165-70867728950e'], + } as OutgoingMessage); + + const draft = (argsOf(recorded[2], 0).create as Record) + .draft as Record; + expect( + Object.keys(draft).filter((key) => /x-ara-large-files/i.test(key)), + ).toEqual([]); + expect(draft.bodyValues).toMatchObject({ html: { value: html } }); + }); + /** 계약 2절 "받는 사람별 결과" (6단계 앞 수정) — 엔진이 그 자리에서 거절한 받는 사람 */ describe('받는 사람별 결과', () => { // 시각(Date.now)을 바꾸는 테스트가 있다 — 다음 테스트로 넘기지 않는다 @@ -3186,6 +3211,19 @@ describe('JmapMailProvider', () => { jest.spyOn(Date, 'now').mockImplementation(() => clock.now); }; + it('준비에 시간을 다 써 보내기를 시작하지 않으면 SendNotStartedException(MAIL_001) — 아무것도 만들거나 제출하지 않았다 (11단계 리뷰 L6 — 대용량 첨부 링크를 취소하는 표시)', async () => { + const fake = fakeClient([prepared]); + // 준비 50초 — 남은 5초는 보낼 최소 시간(10초)보다 적다 + elapseOnCalls(fake, [50_000], { now: 1_000_000 }); + + const error = await fake.provider + .sendMessage(ARA, textOnly) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(SendNotStartedException); + expect((error as BusinessException).code).toBe('MAIL_001'); + expect(fake.recorded).toHaveLength(1); + }); + it('제출 다음에 따로 읽는다 — 제출 id 로 deliveryStatus 만, 짧은 제한 시간(5초)으로 (같은 요청 안의 참조는 엔진이 풀지 못한다)', async () => { const { provider, recorded } = fakeClient([prepared, sent, queued]); @@ -3653,6 +3691,8 @@ describe('JmapMailProvider', () => { .sendMessage(ARA, withTypos) .catch((caught: unknown) => caught as BusinessException); expect((error as BusinessException).code).toBe('MAIL_001'); + // 시작하지 않은 실패의 표시는 형식이다 — 대용량 첨부의 링크를 취소한다 (context 는 로그 전용) + expect(error).toBeInstanceOf(SendNotStartedException); expect((error as BusinessException).context).toMatchObject({ reason: 'send-budget-exhausted', resubmits: 0, @@ -5257,6 +5297,74 @@ describe('JmapMailProvider', () => { }); }); + describe('임시 저장의 대용량 첨부 머리글 (X-Ara-Large-Files — 11단계)', () => { + const content: DraftContent = { + to: [], + cc: [], + bcc: [], + subject: '쓰는 중', + body: '본문', + attachments: [], + }; + const prepared: JmapInvocation[] = [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + [ + 'Identity/get', + { list: [{ id: 'b', name: '김아라', email: ARA }] }, + 'i', + ], + ]; + const created: JmapInvocation[] = [ + [ + 'Email/set', + { oldState: 's1', newState: 's9', created: { draft: { id: 'new1' } } }, + 'c', + ], + ]; + const readBack: JmapInvocation[] = [ + [ + 'Email/get', + { list: [{ id: 'new1', receivedAt: '2026-10-30T01:00:00Z' }] }, + 'g', + ], + ]; + const draftOf = (recorded: CallRecord[]) => + (argsOf(recorded[1]).create as Record>) + .draft; + + it('파일 id 를 머리글 하나에 콤마로 쓴다 (올리는 중인 올리기 id 도) — 덩이는 넣지 않는다', async () => { + const { provider, recorded } = fakeClient([prepared, created, readBack]); + + await provider.saveDraft(ARA, { + ...content, + largeFileIds: [ + '0f8fad5b-d9cb-469f-a165-70867728950e', + '7c9e6679-7425-40de-944b-e07fc1f90ae7', + ], + }); + + const draft = draftOf(recorded); + expect(draft['header:X-Ara-Large-Files:asText']).toBe( + '0f8fad5b-d9cb-469f-a165-70867728950e, 7c9e6679-7425-40de-944b-e07fc1f90ae7', + ); + expect(draft.bodyValues).toEqual({ body: { value: '본문' } }); + }); + + it('파일이 없으면 머리글을 쓰지 않는다', async () => { + for (const largeFileIds of [undefined, []]) { + const { provider, recorded } = fakeClient([ + prepared, + created, + readBack, + ]); + await provider.saveDraft(ARA, { ...content, largeFileIds }); + expect(draftOf(recorded)).not.toHaveProperty( + 'header:X-Ara-Large-Files:asText', + ); + } + }); + }); + describe('이어 쓸 임시 저장본 (getDraft)', () => { const draftEmail = (extra: Record = {}) => ({ ...summaryEmail('dr1', '2026-10-27T01:00:00Z', { @@ -5298,6 +5406,7 @@ describe('JmapMailProvider', () => { inReplyTo: null, updatedAt: '2026-10-27T01:00:00.000Z', isCanceledSend: false, + largeFileIds: [], }); expect(argsOf(recorded[0], 1)).toMatchObject({ ids: ['dr1'], @@ -5382,6 +5491,74 @@ describe('JmapMailProvider', () => { }); }); + describe('이어 쓸 임시 저장본의 대용량 첨부 머리글 (11단계)', () => { + const draftEmail = (header: unknown) => ({ + ...summaryEmail('dr1', '2026-10-27T01:00:00Z', { + mailboxIds: { d: true }, + keywords: { $draft: true, $seen: true }, + }), + cc: [], + bcc: [], + replyTo: [], + sentAt: null, + messageId: ['dr1@arasystem.test'], + references: null, + bodyValues: { '1': { value: '본문', isTruncated: false } }, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [], + attachments: [], + 'header:X-Ara-Large-Files:asText': header, + }); + + it('머리글의 파일 id 를 읽는다 — 우리 모양만 · 소문자 · 겹치지 않게, 머리글을 함께 묻는다', async () => { + const { provider, recorded } = fakeClient([ + [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + [ + 'Email/get', + { + list: [ + draftEmail( + ' 0F8FAD5B-D9CB-469F-A165-70867728950E ,7c9e6679-7425-40de-944b-e07fc1f90ae7, not-a-uuid, 0f8fad5b-d9cb-469f-a165-70867728950e', + ), + ], + }, + 'g', + ], + ], + ]); + + await expect(provider.getDraft(ARA, 'dr1')).resolves.toMatchObject({ + largeFileIds: [ + '0f8fad5b-d9cb-469f-a165-70867728950e', + '7c9e6679-7425-40de-944b-e07fc1f90ae7', + ], + }); + expect( + (argsOf(recorded[0], 1) as { properties: string[] }).properties, + ).toContain('header:X-Ara-Large-Files:asText'); + }); + + it('머리글이 없거나 글이 아니면 빈 목록', async () => { + for (const header of [ + null, + undefined, + 42, + ['0f8fad5b-d9cb-469f-a165-70867728950e'], + ]) { + const { provider } = fakeClient([ + [ + ['Mailbox/get', { list: stalwartMailboxes }, 'm'], + ['Email/get', { list: [draftEmail(header)] }, 'g'], + ], + ]); + await expect(provider.getDraft(ARA, 'dr1')).resolves.toMatchObject({ + largeFileIds: [], + }); + } + }); + }); + describe('보낸 초안 지우기 (deleteDraft)', () => { /** 메일함 + 초안 확인 — 그때의 Email 상태 's1' */ const lookup = (list: Record[]): JmapInvocation[] => [ @@ -7090,6 +7267,60 @@ describe('JmapMailProvider', () => { }); }); + describe('메일 제목 (getMessageSubjects — 11단계 보낸 링크 목록)', () => { + it('한 번에 읽는다 — id 와 제목만, 없는 메일은 빠지고 제목이 없으면 빈 글', async () => { + const { provider, recorded } = fakeClient([ + [ + [ + 'Email/get', + { + list: [ + { id: 'm1', subject: '견적서' }, + { id: 'm2', subject: null }, + ], + notFound: ['m3'], + }, + 's', + ], + ], + ]); + + const subjects = await provider.getMessageSubjects(ARA, [ + 'm1', + 'm2', + 'm3', + 'm1', + ]); + expect([...subjects]).toEqual([ + ['m1', '견적서'], + ['m2', ''], + ]); + expect(argsOf(recorded[0])).toEqual({ + accountId: 'd', + ids: ['m1', 'm2', 'm3'], + properties: ['id', 'subject'], + }); + expect(recorded[0].options).toEqual({ invalidRequestCode: 'RES_001' }); + }); + + it('모양이 틀린 id 는 묻지 않는다 — 남는 것이 없으면 엔진에 묻지 않는다', async () => { + const { provider, recorded } = fakeClient([]); + await expect( + provider.getMessageSubjects(ARA, ['a b', '../x', '']), + ).resolves.toEqual(new Map()); + expect(recorded).toHaveLength(0); + }); + + it('엔진을 읽지 못하면 그 실패를 던진다 — 부르는 쪽이 제목 없이 답한다', async () => { + const { provider } = fakeClient([ + new BusinessException('MAIL_001', { context: { reason: 'test' } }), + ]); + expect(await codeOf(provider.getMessageSubjects(ARA, ['m1']))).toBe( + 'MAIL_001', + ); + }); + }); + describe('원문 (openMessageSource)', () => { it('메일의 원본 blob 을 message/rfc822 로 내려받아 흘려보낸다 — 제목을 함께 돌려준다', async () => { const { provider, recorded, client } = fakeClient([ @@ -8146,3 +8377,44 @@ describe('buildThreadHeaders', () => { expect(buildThreadHeaders(undefined)).toBeNull(); }); }); + +describe('parseLargeFileIds (초안 머리글 X-Ara-Large-Files — 11단계)', () => { + const ID = (n: number) => + `0f8fad5b-d9cb-469f-a165-${n.toString(16).padStart(12, '0')}`; + + it('콤마로 나눈 우리 모양(UUID)만 · 소문자 · 겹치지 않게 · 앞뒤 공백 무시', () => { + expect( + parseLargeFileIds( + ' 0F8FAD5B-D9CB-469F-A165-70867728950E , 7c9e6679-7425-40de-944b-e07fc1f90ae7,0f8fad5b-d9cb-469f-a165-70867728950e', + ), + ).toEqual([ + '0f8fad5b-d9cb-469f-a165-70867728950e', + '7c9e6679-7425-40de-944b-e07fc1f90ae7', + ]); + }); + + it('앞에서부터 20개까지만', () => { + const many = Array.from({ length: 25 }, (_, index) => ID(index)).join(','); + expect(parseLargeFileIds(many)).toEqual( + Array.from({ length: 20 }, (_, index) => ID(index)), + ); + }); + + it.each<[string, unknown]>([ + ['글이 아님', 42], + ['null', null], + ['배열', ['0f8fad5b-d9cb-469f-a165-70867728950e']], + ['빈 글', ''], + [ + '다른 구분자(;) — 다른 프로그램이 고친 값', + '0f8fad5b-d9cb-469f-a165-70867728950e;7c9e6679-7425-40de-944b-e07fc1f90ae7', + ], + ['따옴표로 감쌈', '"0f8fad5b-d9cb-469f-a165-70867728950e"'], + ['JSON 배열 글', '["0f8fad5b-d9cb-469f-a165-70867728950e"]'], + ['중괄호 GUID', '{0f8fad5b-d9cb-469f-a165-70867728950e}'], + ['한 글자 모자란 id', '0f8fad5b-d9cb-469f-a165-70867728950'], + ['경로 같은 글', '../../etc/passwd'], + ])('이상한 머리글은 버린다 — %s', (_label, value) => { + expect(parseLargeFileIds(value)).toEqual([]); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index a8d4c23..30519e8 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -11,7 +11,10 @@ import { MAX_BODY_VALUE_BYTES, MAX_ENVELOPE_RESUBMITS, } from '@/modules/mail/mail.constants'; -import type { MailProvider } from '@/modules/mail/mail.provider'; +import { + SendNotStartedException, + type MailProvider, +} from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { AttachmentContent, @@ -192,6 +195,29 @@ const INTERNAL_SEND_KEYWORD = '$ara-sending'; */ const REPLY_TO_ID_PROPERTY = 'header:X-Ara-Reply-To-Id:asText'; +/** + * 임시 저장본에 실린 대용량 첨부 파일 id (11단계 — 계약 4절 "임시 저장") — `X-Ara-Large-Files: , …` (올리는 중인 올리기 id 도 — 같은 id 다). + * 답장 대상 머리글과 같은 길이다 — 초안 자체는 보내지 않으므로 받는 사람에게 가지 않는다(다른 메일 프로그램이 그 초안을 그대로 보내면 머리글만 나간다 — 알려진 한계) + */ +const LARGE_FILES_PROPERTY = 'header:X-Ara-Large-Files:asText'; +/** 머리글에서 읽는 파일 id 모양 (우리가 만든 UUID) · 개수 상한 (발송 · 임시 저장 본문의 largeFileIds 상한과 같다) */ +const LARGE_FILE_ID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const MAX_DRAFT_LARGE_FILES = 20; + +/** + * 초안 머리글의 대용량 파일 id — 콤마로 나눈 조각 가운데 우리 모양(UUID)만, 소문자, 겹치지 않게, 앞에서부터 20개. + * 다른 프로그램이 만들거나 고친 값(다른 구분자 · 따옴표 · 이상한 글)은 버린다 — 서비스가 그 직원의 파일인지 다시 본다 + */ +export function parseLargeFileIds(value: unknown): string[] { + if (typeof value !== 'string') return []; + const ids = value + .split(',') + .map((part) => part.trim().toLowerCase()) + .filter((part) => LARGE_FILE_ID.test(part)); + return [...new Set(ids)].slice(0, MAX_DRAFT_LARGE_FILES); +} + /** 사용자 초안인지 볼 때 읽는 속성 */ const DRAFT_CHECK_PROPERTIES = [ 'id', @@ -209,6 +235,7 @@ const DRAFT_DETAIL_PROPERTIES = [ ...DETAIL_PROPERTIES, ...DRAFT_CHECK_PROPERTIES, REPLY_TO_ID_PROPERTY, + LARGE_FILES_PROPERTY, 'inReplyTo', ]), ]; @@ -2034,7 +2061,7 @@ export class JmapMailProvider implements MailProvider { // 보내다 끊기면 나갔는지 알 수 없다 — 남은 시간이 모자라면 아예 시작하지 않는다 (아무것도 나가지 않았으니 같은 키로 다시 보내면 된다) const sendTimeoutMs = Math.min(JMAP_SEND_TIMEOUT_MS, budgetLeft()); if (sendTimeoutMs < MIN_SEND_WINDOW_MS) { - throw new BusinessException('MAIL_001', { + throw new SendNotStartedException('MAIL_001', { context: { reason: 'send-budget-exhausted', leftMs: sendTimeoutMs }, }); } @@ -2291,7 +2318,7 @@ export class JmapMailProvider implements MailProvider { const timeoutMs = Math.min(JMAP_SEND_TIMEOUT_MS, context.budgetLeft()); if (timeoutMs < MIN_SEND_WINDOW_MS) { await discard(); - throw new BusinessException('MAIL_001', { + throw new SendNotStartedException('MAIL_001', { context: { reason: 'send-budget-exhausted', leftMs: timeoutMs, @@ -2741,6 +2768,32 @@ export class JmapMailProvider implements MailProvider { }; } + /** 메일 제목 — 한 번에 읽는다 (11단계 보낸 대용량 첨부 링크 목록). 없는 메일은 빠진다, id 모양이 틀리면 묻지 않는다 */ + async getMessageSubjects( + user: string, + ids: readonly string[], + ): Promise> { + const valid = [...new Set(ids)].filter((id) => MAIL_ID_PATTERN.test(id)); + if (valid.length === 0) return new Map(); + const responses = await this.jmap.call( + user, + (accountId) => [ + [ + 'Email/get', + { accountId, ids: valid, properties: ['id', 'subject'] }, + 's', + ], + ], + { invalidRequestCode: 'RES_001' }, + ); + const { list = [] } = expectResult>( + responses, + 's', + 'Email/get', + ); + return new Map(list.map((email) => [email.id, email.subject ?? ''])); + } + /** * 받은 그대로의 메일 원문 — 메일의 원본 blob 을 흘려보낸다 (계약 2절 "스팸 · 원문"). * 없는 메일 · 엔진이 해석하지 못하는 id 는 RES_001. 엔진이 원본 blob 을 알려 주지 않으면 MAIL_001 @@ -2944,6 +2997,10 @@ export class JmapMailProvider implements MailProvider { ...(thread ?? {}), // 이어 쓸 때 돌려줄 답장 대상 — 원 메일을 찾았을 때만 (지워진 메일 id 는 남기지 않는다) ...(original ? { [REPLY_TO_ID_PROPERTY]: original.id } : {}), + // 대용량 첨부(11단계) — 파일 id 만 머리글로 (덩이는 보낼 때 만든다) + ...(content.largeFileIds?.length + ? { [LARGE_FILES_PROPERTY]: content.largeFileIds.join(', ') } + : {}), ...bodyProperties(content, []), }; @@ -3044,6 +3101,9 @@ export class JmapMailProvider implements MailProvider { (isCanceledSend ? await this.replyTargetInThread(user, email) : null), updatedAt: detail.receivedAt, isCanceledSend, + largeFileIds: parseLargeFileIds( + (email as unknown as Record)[LARGE_FILES_PROPERTY], + ), }; } diff --git a/backend/src/modules/mail/large-file-block.spec.ts b/backend/src/modules/mail/large-file-block.spec.ts new file mode 100644 index 0000000..caa06a1 --- /dev/null +++ b/backend/src/modules/mail/large-file-block.spec.ts @@ -0,0 +1,191 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { sanitizeMailHtml } from './html/sanitize-mail-html'; +import { sanitizeOutgoingHtml } from './html/sanitize-outgoing-html'; +import { + LARGE_FILES_BLOCK_END, + LARGE_FILES_BLOCK_START, + buildLargeFileBlock, + extractQuoteStart, + findHtmlBlock, + hrefsIn, + insertHtmlBlock, + insertTextBlock, + isValidQuoteStart, + removeHtmlBlock, + removeTextBlock, +} from './large-file-block'; + +const GB = 1024 * 1024 * 1024; +const MB = 1024 * 1024; +const URL_ = `https://files.arasystem.test/s/${'Ab_-9'.repeat(8)}xyz`; +const EXPIRES = new Date('2026-11-29T14:59:59.999Z'); +const FILES = [ + { name: '설계도_v3.dwg', size: 1.2 * GB }, + { name: '현장영상_1029.mp4', size: 612 * MB }, +]; + +const block = buildLargeFileBlock({ + files: FILES, + expiresAt: EXPIRES, + url: URL_, +}); + +/** 앞뒤 표시(주석)를 뗀 HTML 덩이 — 보내는 정화를 거치면 주석이 지워진다 */ +const withoutMarkers = (html: string) => + html.replace(LARGE_FILES_BLOCK_START, '').replace(LARGE_FILES_BLOCK_END, ''); + +describe('buildLargeFileBlock — 덩이의 내용 (디자인 README "11단계 › 본문 덩이")', () => { + it('HTML — 앞뒤 표시 · 개수 · 합계 · 한국 날짜 · 파일마다 이름 · 크기 · 주소가 보이는 링크 · 로그인 안내 · 영어 한 줄', () => { + expect(block.html.startsWith(LARGE_FILES_BLOCK_START)).toBe(true); + expect(block.html.endsWith(LARGE_FILES_BLOCK_END)).toBe(true); + for (const text of [ + '대용량 첨부 2개 (합계 1.8 GB)', + '2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)', + '설계도_v3.dwg', + '1.2 GB', + '현장영상_1029.mp4', + '612 MB', + `${URL_}`, + '내려받을 때 로그인이나 비밀번호를 묻지 않습니다.', + 'Large files: download by Nov 29, 2026 (KST) — no login or password needed.', + ]) { + expect(block.html).toContain(text); + } + // 그림 · 아이콘 없이 테두리 표와 글자 + expect(block.html).not.toMatch(/ { + expect(block.text.split('\n')).toEqual([ + '-'.repeat(40), + '대용량 첨부 2개 (합계 1.8 GB)', + '2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)', + '- 설계도_v3.dwg (1.2 GB)', + '- 현장영상_1029.mp4 (612 MB)', + `내려받기: ${URL_}`, + '내려받을 때 로그인이나 비밀번호를 묻지 않습니다.', + 'Large files: download by Nov 29, 2026 (KST) — no login or password needed.', + '-'.repeat(40), + ]); + }); + + it('파일 이름의 HTML 문자는 글자로 — 덩이 안에 태그가 생기지 않는다', () => { + const tricky = buildLargeFileBlock({ + files: [{ name: '&"\'.zip', size: 10 }], + expiresAt: EXPIRES, + url: URL_, + }); + expect(tricky.html).toContain( + '<img src=x onerror=1>&"'.zip', + ); + expect(tricky.html).not.toContain(' { + expect(sanitizeOutgoingHtml(block.html)).toBe(withoutMarkers(block.html)); + }); + + it('다시 읽을 때(받은 메일 정화)도 표 · 칸 · 글자 모양이 남는다 — 링크는 새 창으로', () => { + const reread = sanitizeMailHtml(block.html).html; + expect(reread).toContain( + '${URL_}`, + ); + expect(reread).not.toContain(LARGE_FILES_BLOCK_START); + }); +}); + +describe('인용 경계 · 덩이 넣기 · 떼기', () => { + it('isValidQuoteStart — 0 ~ 길이이고 줄의 처음', () => { + const body = '내 글\n\n----- 원본 메일 -----'; + expect(isValidQuoteStart(body, 0)).toBe(true); + expect(isValidQuoteStart(body, 5)).toBe(true); + expect(isValidQuoteStart(body, body.length)).toBe(false); + expect(isValidQuoteStart('a\n', 2)).toBe(true); + expect(isValidQuoteStart(body, 2)).toBe(false); + expect(isValidQuoteStart(body, -1)).toBe(false); + expect(isValidQuoteStart(body, body.length + 1)).toBe(false); + }); + + it('extractQuoteStart — 표시를 모두 지우고 첫 자리를 준다 (내용이 있는 표시는 속성만 뗀다)', () => { + const html = + '

내 글

인용

둘째

'; + const { html: stripped, position } = extractQuoteStart(html); + expect(stripped).toBe( + '

내 글

인용

둘째

', + ); + expect(position).toBe('

내 글

'.length); + expect(extractQuoteStart('

없음

')).toEqual({ + html: '

없음

', + position: null, + }); + }); + + it('보내는 정화는 keepQuoteStart 일 때만, div 에만 표시를 남긴다 — 그 밖의 data-* 는 지운다', () => { + const input = + '

글

p

x
'; + expect(sanitizeOutgoingHtml(input, { keepQuoteStart: true })).toBe( + '

글

p

x
', + ); + // 남기지 않을 때는 표시 속성만 빠지고 div 는 지금처럼 (style 도 그대로) + expect(sanitizeOutgoingHtml(input)).toBe( + '

글

p

x
', + ); + }); + + it('HTML 덩이는 그 자리(없으면 맨 끝)에 · 원문에서 표시 사이를 찾아 뗀다', () => { + const html = '

내 글

인용

'; + const at = '

내 글

'.length; + const inserted = insertHtmlBlock(html, at, block.html); + expect(inserted).toBe(`

내 글

${block.html}

인용

`); + expect(insertHtmlBlock(html, null, block.html)).toBe(html + block.html); + + const found = findHtmlBlock(inserted); + expect(found).not.toBeNull(); + if (found) expect(removeHtmlBlock(inserted, found)).toBe(html); + expect(findHtmlBlock(html)).toBeNull(); + expect(findHtmlBlock(`a${LARGE_FILES_BLOCK_START}b`)).toBeNull(); + }); + + it('텍스트 덩이는 앞뒤에 빈 줄 — 넣은 그대로면 뗄 수 있고, 손대면 남는다', () => { + const body = '내 글\n\n-- \n김아라\n\n----- 원본 메일 -----\n> 인용'; + const quoteStart = body.indexOf('-----'); + const inserted = insertTextBlock(body, quoteStart, block.text); + expect(inserted).toBe( + `내 글\n\n-- \n김아라\n\n\n${block.text}\n\n----- 원본 메일 -----\n> 인용`, + ); + expect(removeTextBlock(inserted, block.text)).toBe(body); + // 엔진이 줄바꿈을 CRLF 로 돌려줘도 같다 + expect(removeTextBlock(inserted.replace(/\n/g, '\r\n'), block.text)).toBe( + body, + ); + expect( + removeTextBlock(inserted.replace('1.2 GB', '1.3 GB'), block.text), + ).toBeNull(); + }); + + it('맨 끝에 붙일 때 마지막 줄이 줄바꿈 없이 끝났으면 줄을 바꾼 뒤에', () => { + expect(insertTextBlock('내 글', null, 'B')).toBe('내 글\n\nB\n\n'); + expect(insertTextBlock('내 글\n', null, 'B')).toBe('내 글\n\nB\n\n'); + expect(insertTextBlock('', null, 'B')).toBe('\nB\n\n'); + }); + + it('hrefsIn — 따옴표 둘 다, 겹치지 않게, 한도까지 · 긴 입력에도 길이에 비례', () => { + expect( + hrefsIn( + `xyz`, + 10, + ), + ).toEqual([URL_, 'mailto:a@b']); + expect( + hrefsIn('', 2), + ).toEqual(['1', '2']); + const cpuMs = startCpuTimer(); + hrefsIn(`href${' '.repeat(200_000)}`, 10); + hrefsIn('href="'.repeat(100_000), 10); + expect(cpuMs()).toBeLessThan(2_000); + }); +}); diff --git a/backend/src/modules/mail/large-file-block.ts b/backend/src/modules/mail/large-file-block.ts new file mode 100644 index 0000000..6a98c60 --- /dev/null +++ b/backend/src/modules/mail/large-file-block.ts @@ -0,0 +1,239 @@ +import { formatFileSize } from '@/shared/utils/format'; +import { formatKstDate, formatKstDateEnglish } from '@/shared/utils/kst-date'; + +/** + * 대용량 첨부 덩이 (순수 함수) — 서버가 보내는 메일 본문에 넣는 내려받기 안내 (계약 4절 "덩이 — 서버가 본문에 넣는 안내", 디자인 README "11단계 › 본문 덩이"). + * + * - **자리**: 내 글 · 서명 아래, 인용 위 — 서식 본문은 쓰기 화면이 둔 첫 경계 표시 `
` 자리, 텍스트는 `bodyQuoteStart`. 없으면 맨 끝 + * - **HTML 모양**: 보내는 정화의 허용 목록 안의 태그 · style 값으로만 — 다시 읽을 때 · 답장 · 전달의 인용 · 보내기 취소로 되돌릴 때도 모양이 남게 + * (sanitize-outgoing-html.spec.ts 가 이 덩이를 정화해도 그대로인지 본다). 그림 · 뱃지 · 알약 없이 테두리 표와 글자. 링크에는 style 을 두지 않는다(정화가 지운다) + * - **표시**: 앞뒤 주석 `` · `` — 보내는 정화는 HTML 주석을 지우므로 사용자가 쓴 본문(인용 포함)에는 + * 이 표시가 들어갈 수 없다(서버만 넣는다). 엔진은 저장한 메일의 주석을 그대로 둔다(2026-10-30 개발 엔진으로 확인 — 되돌린 초안의 rawHtmlBody 에 남는다) + * - **텍스트**: 같은 내용을 줄로 — 대시 줄로 가르고 앞뒤에 빈 줄 + * + * ⚠️ 덩이를 고치면 그 전에 보낸 메일의 텍스트 덩이를 보내기 취소로 되돌릴 때 떼지 못한다(글 그대로일 때만 뗀다) — 표시의 판(v1)을 올리고 옛 판도 알아보게 한다 + */ + +/** 덩이의 앞뒤 표시 (HTML 주석) */ +export const LARGE_FILES_BLOCK_START = ''; +export const LARGE_FILES_BLOCK_END = ''; + +/** 쓰기 화면이 인용("원본 메일" 머리) 바로 앞에 두는 빈 표시 — 보내는 정화가 이 속성만 div 에 남긴다(keepQuoteStart) */ +export const QUOTE_START_ATTRIBUTE = 'data-ara-quote-start'; + +/** 정화한 HTML 의 표시 — 정화가 속성 하나짜리 여는 태그로 다시 쓴다(빈 값은 이름만) */ +const QUOTE_START_OPEN = `
`; +const DIV_CLOSE = '
'; + +/** 텍스트 덩이를 가르는 줄 */ +const TEXT_RULE = '-'.repeat(40); + +/** 덩이의 글꼴 — 보내는 정화의 글꼴 목록 안 (쓰기 화면의 기본 글꼴과 같다) */ +const BLOCK_FONT = + "'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif"; + +/** 덩이 하나의 재료 */ +export interface LargeFileBlockInput { + /** 보낸 차례 */ + readonly files: readonly { readonly name: string; readonly size: number }[]; + /** 링크가 끝나는 때 — 한국 날짜로 적는다 */ + readonly expiresAt: Date; + /** 공개 화면 주소 (`/s/<토큰>`) */ + readonly url: string; +} + +export interface LargeFileBlock { + readonly html: string; + readonly text: string; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** 덩이를 만든다 — HTML(앞뒤 표시 포함)과 텍스트에 같은 내용 */ +export function buildLargeFileBlock( + input: LargeFileBlockInput, +): LargeFileBlock { + const total = input.files.reduce((sum, file) => sum + file.size, 0); + const title = `대용량 첨부 ${input.files.length}개 (합계 ${formatFileSize(total)})`; + const until = `${formatKstDate(input.expiresAt)}까지 내려받을 수 있습니다 (한국 시간)`; + const noLogin = '내려받을 때 로그인이나 비밀번호를 묻지 않습니다.'; + const english = `Large files: download by ${formatKstDateEnglish(input.expiresAt)} (KST) — no login or password needed.`; + const url = escapeHtml(input.url); + + const cell = (style: string, content: string) => + `
`; + const fileRows = input.files + .map( + (file) => + `` + + ``, + ) + .join(''); + const html = + LARGE_FILES_BLOCK_START + + `
${content}
${escapeHtml(file.name)}${formatFileSize(file.size)}
` + + cell( + 'padding:12px 16px 2px 16px;font-size:14px;font-weight:bold;color:#101828', + escapeHtml(title), + ) + + cell( + 'padding:0 16px 8px 16px;font-size:13px;color:#344054', + escapeHtml(until), + ) + + fileRows + + cell( + 'padding:8px 16px 2px 16px;font-size:13px;color:#344054', + `내려받기${url}`, + ) + + cell( + 'padding:6px 16px 0 16px;font-size:12px;color:#667085', + escapeHtml(noLogin), + ) + + cell( + 'padding:2px 16px 12px 16px;font-size:12px;color:#667085', + escapeHtml(english), + ) + + '
' + + LARGE_FILES_BLOCK_END; + + const text = [ + TEXT_RULE, + title, + until, + ...input.files.map( + (file) => `- ${file.name} (${formatFileSize(file.size)})`, + ), + `내려받기: ${input.url}`, + noLogin, + english, + TEXT_RULE, + ].join('\n'); + return { html, text }; +} + +/** 텍스트 덩이를 본문에 넣을 때의 모양 — 앞뒤에 빈 줄 (떼기도 이 모양 그대로 찾는다) */ +function textUnit(block: string): string { + return `\n${block}\n\n`; +} + +/** + * `bodyQuoteStart` 가 맞는 자리인가 — 0 ~ body.length(JS 문자열 위치)이고 줄의 처음(0 이거나 바로 앞이 줄바꿈). 아니면 400 VAL_001 + */ +export function isValidQuoteStart(body: string, position: number): boolean { + return ( + Number.isInteger(position) && + position >= 0 && + position <= body.length && + (position === 0 || body[position - 1] === '\n') + ); +} + +/** + * 정화한 서식 본문의 인용 경계 표시를 **모두** 지운다 — 받는 사람에게 가지 않고, 임시 저장본 · 지문에도 들지 않는다. + * 빈 표시는 태그째, 안에 내용이 있는 표시는 속성만 뗀다. 정화가 표시를 속성 하나짜리 여는 태그로 다시 쓰므로 글자 그대로 찾는다 + * (사용자 글의 `<` 는 정화가 `<` 로 바꿔 이 모양이 될 수 없다). + * @returns 지운 HTML 과 첫 표시가 있던 자리(덩이를 넣을 곳) — 없으면 null + */ +export function extractQuoteStart(sanitizedHtml: string): { + readonly html: string; + readonly position: number | null; +} { + let position: number | null = null; + let result = ''; + let from = 0; + for ( + let found = sanitizedHtml.indexOf(QUOTE_START_OPEN); + found >= 0; + found = sanitizedHtml.indexOf(QUOTE_START_OPEN, from) + ) { + result += sanitizedHtml.slice(from, found); + position ??= result.length; + const afterOpen = found + QUOTE_START_OPEN.length; + if (sanitizedHtml.startsWith(DIV_CLOSE, afterOpen)) { + from = afterOpen + DIV_CLOSE.length; + } else { + result += '
'; + from = afterOpen; + } + } + return { html: result + sanitizedHtml.slice(from), position }; +} + +/** 서식 본문의 그 자리(없으면 맨 끝)에 덩이를 넣는다 */ +export function insertHtmlBlock( + html: string, + position: number | null, + block: string, +): string { + const at = position === null ? html.length : Math.min(position, html.length); + return html.slice(0, at) + block + html.slice(at); +} + +/** 텍스트 본문의 그 자리(없으면 맨 끝)에 덩이를 넣는다 — 앞뒤에 빈 줄 */ +export function insertTextBlock( + text: string, + position: number | null, + block: string, +): string { + const at = position === null ? text.length : Math.min(position, text.length); + // 맨 끝에 붙일 때 마지막 줄이 줄바꿈 없이 끝났으면 줄을 바꾼 뒤에 (bodyQuoteStart 는 늘 줄의 처음이다) + const lineBreak = at > 0 && text[at - 1] !== '\n' ? '\n' : ''; + return text.slice(0, at) + lineBreak + textUnit(block) + text.slice(at); +} + +/** 원문 HTML 의 덩이 자리 — 첫 앞 표시부터 그 뒤의 첫 뒤 표시 끝까지. 없으면 null */ +export function findHtmlBlock( + raw: string, +): { readonly start: number; readonly end: number } | null { + const start = raw.indexOf(LARGE_FILES_BLOCK_START); + if (start < 0) return null; + const close = raw.indexOf( + LARGE_FILES_BLOCK_END, + start + LARGE_FILES_BLOCK_START.length, + ); + if (close < 0) return null; + return { start, end: close + LARGE_FILES_BLOCK_END.length }; +} + +/** href 값 — 따옴표 둘 다 (덩이 안의 링크를 찾을 때만 쓴다 — 입력 길이에 비례) */ +const HREF = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi; + +/** HTML 조각의 링크 주소 — 나온 차례, 겹치지 않게, `limit` 개까지 */ +export function hrefsIn(html: string, limit: number): string[] { + const found = new Set(); + for (const match of html.matchAll(HREF)) { + const value = (match[1] ?? match[2] ?? '').replace(/&/g, '&').trim(); + if (value !== '') found.add(value); + if (found.size >= limit) break; + } + return [...found]; +} + +/** 원문 HTML 에서 덩이를 뗀다 (보내기 취소로 되돌린 초안 — 정화 전) */ +export function removeHtmlBlock( + raw: string, + block: { readonly start: number; readonly end: number }, +): string { + return raw.slice(0, block.start) + raw.slice(block.end); +} + +/** + * 텍스트 본문에서 덩이를 뗀다 — 서버가 넣은 글 그대로일 때만(사용자가 손대면 남는다). 줄바꿈은 LF 로 맞춘 뒤 찾는다. + * @returns 뗀 본문, 찾지 못했으면 null + */ +export function removeTextBlock(text: string, block: string): string | null { + const normalized = text.replace(/\r\n?/g, '\n'); + for (const unit of [textUnit(block), block]) { + const at = normalized.indexOf(unit); + if (at >= 0) + return normalized.slice(0, at) + normalized.slice(at + unit.length); + } + return null; +} diff --git a/backend/src/modules/mail/mail-events.service.ts b/backend/src/modules/mail/mail-events.service.ts index 6730fa2..9efbe54 100644 --- a/backend/src/modules/mail/mail-events.service.ts +++ b/backend/src/modules/mail/mail-events.service.ts @@ -37,10 +37,18 @@ const FALLBACK_CONNECTION_LIFETIME_MS = ACCESS_TTL_MAX_SECONDS * 1000; /** 서버가 곧 닫는 까닭 (`end` 이벤트) */ export type MailEventsEndReason = 'token-expiring' | 'server-shutdown'; +/** 대용량 첨부의 위험 판정 (`large-file` 이벤트 — 11단계 계약 4절 "악성 검사") — 파일 이름은 그 직원 자신의 글이다 */ +export interface LargeFileEventData { + readonly fileId: string; + readonly name: string; + readonly scanStatus: 'infected'; +} + /** 실시간 알림의 이벤트 (계약 2절 "실시간 알림") — type 이 SSE 의 event 이름이다 */ export type MailEvent = | { readonly type: 'state'; readonly data: MailLiveState } | { readonly type: 'new-mail'; readonly data: NewMailNotice } + | { readonly type: 'large-file'; readonly data: LargeFileEventData } | { readonly type: 'ping'; readonly data: { readonly upstream: boolean } } | { readonly type: 'end'; @@ -207,6 +215,18 @@ export class MailEventsService implements BeforeApplicationShutdown { }); } + /** + * 한 직원의 모든 연결에 이벤트 하나를 보낸다 — 연결이 없으면 아무것도 하지 않는다(그 직원은 목록 상태로 안다). + * 메일 엔진 밖의 소식(대용량 첨부의 위험 판정 `large-file` — 11단계)이 쓴다. 던지지 않는다 + */ + notify(user: string, event: MailEvent): void { + const key = user.toLowerCase(); + for (const hub of [...this.hubs.values()]) { + if (hub.closed || hub.user.toLowerCase() !== key) continue; + this.broadcast(hub, event); + } + } + /** 서버 종료 — HTTP 서버를 닫기 전에 모든 연결을 end{server-shutdown} 으로 닫고 엔진 연결도 닫는다 */ beforeApplicationShutdown(): void { this.shuttingDown = true; diff --git a/backend/src/modules/mail/mail-large-files.service.spec.ts b/backend/src/modules/mail/mail-large-files.service.spec.ts new file mode 100644 index 0000000..64e9543 --- /dev/null +++ b/backend/src/modules/mail/mail-large-files.service.spec.ts @@ -0,0 +1,596 @@ +import { Logger } from '@nestjs/common'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { BusinessException } from '@/common/errors/business.exception'; +import type { FileSharingService } from '@/modules/file-sharing/file-sharing.service'; +import type { ShareLinkRecord } from '@/modules/file-sharing/file-sharing.types'; +import type { ShareLinkEntity } from '@/modules/file-sharing/share-link.entity'; +import { hashShareToken, shareUrl } from '@/modules/file-sharing/share-token'; +import type { + FileScanListener, + FileScanService, +} from '@/modules/file-storage/file-scan.service'; +import type { FileStorageService } from '@/modules/file-storage/file-storage.service'; +import type { + FileUseProblem, + StoredFileView, +} from '@/modules/file-storage/file-storage.types'; +import { sanitizeMailHtml } from './html/sanitize-mail-html'; +import { + buildLargeFileBlock, + insertHtmlBlock, + insertTextBlock, +} from './large-file-block'; +import { MAX_BODY_LENGTH } from './mail.constants'; +import type { MailEventsService } from './mail-events.service'; +import { + MailLargeFilesService, + isCertainSendFailure, + type LargeFilePlan, +} from './mail-large-files.service'; +import { SendNotStartedException, type MailProvider } from './mail.provider'; +import type { DraftSource, OutgoingMessage, SentMessage } from './mail.types'; + +const ORIGIN = 'https://files.arasystem.test'; +const ARA: AuthenticatedUser = { + email: 'ara.kim@arasystem.test', + name: '김아라', +}; +const DOYUN: AuthenticatedUser = { + email: 'doyun.lee@arasystem.test', + name: '이도윤', +}; +const TOKEN = 'Ab_-9'.repeat(8) + 'xyz'; +const FILE_A = '0f8fad5b-d9cb-469f-a165-70867728950e'; +const FILE_B = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; +const EXPIRES = new Date('2026-11-29T14:59:59.999Z'); +const SENT_AT = new Date('2026-10-30T03:00:00.000Z'); + +const view = (id: string, name: string, size: number): StoredFileView => ({ + id, + ownerEmail: ARA.email, + name, + size, + state: 'ready', + removedReason: null, + scanStatus: 'clean', + completedAt: SENT_AT, + expiresAt: EXPIRES, + alive: true, + bytesAvailable: true, +}); + +const VIEWS = [ + view(FILE_A, '설계도.dwg', 1_000), + view(FILE_B, '영상.mp4', 2_000), +]; + +function record(overrides: Partial = {}): ShareLinkRecord { + const link = { + id: 'link-1', + tokenHash: hashShareToken(TOKEN), + ownerEmail: ARA.email, + senderName: ARA.name, + purpose: 'mail', + messageId: null, + sentEmailId: 'sent-1', + sentAt: SENT_AT, + expiresAt: EXPIRES, + createdAt: SENT_AT, + revokedAt: null, + revokedBy: null, + canceledAt: null, + cancelReason: null, + ...overrides, + }; + const files = VIEWS.map((file, position) => ({ + linkId: link.id, + fileId: file.id, + position, + name: file.name, + size: file.size, + downloadCount: 3, + lastDownloadedAt: new Date('2026-11-01T00:00:00.000Z'), + })); + return { link, files }; +} + +/** 메일에 넣은 덩이 (서버가 보낸 그대로) */ +const sentBlock = buildLargeFileBlock({ + files: VIEWS, + expiresAt: EXPIRES, + url: shareUrl(ORIGIN, TOKEN), +}); + +const message = ( + overrides: Partial = {}, +): OutgoingMessage => ({ + to: [{ name: null, email: 'partner@example.test' }], + cc: [], + bcc: [], + subject: '도면 보내드립니다', + body: '내 글\n\n----- 원본 메일 -----\n> 인용', + htmlBody: '

내 글

인용

', + attachments: [], + ...overrides, +}); + +const PLAN: LargeFilePlan = { + fileIds: [FILE_A, FILE_B], + htmlQuoteStart: '

내 글

'.length, + textQuoteStart: '내 글\n\n'.length, +}; + +const SENT: SentMessage = { + id: 'sent-9', + rejectedRecipients: [], + pendingSend: null, +}; + +function setup() { + let listener: FileScanListener | null = null; + const files = { + limits: { maxFilesPerMessage: 10, retentionDays: 30 }, + checkUse: jest + .fn() + .mockResolvedValue({ usable: VIEWS, problems: new Map() }), + attachToDraft: jest.fn().mockResolvedValue(undefined), + draftFiles: jest.fn().mockResolvedValue([]), + }; + const sharing = { + publicOrigin: ORIGIN, + createMailLink: jest.fn().mockResolvedValue({ + record: record({ sentEmailId: null }), + token: TOKEN, + url: shareUrl(ORIGIN, TOKEN), + }), + markSent: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn().mockResolvedValue(undefined), + attachRetry: jest.fn().mockResolvedValue(undefined), + findBySentEmail: jest.fn().mockResolvedValue(null), + findByTokens: jest.fn().mockResolvedValue(new Map()), + fileStatuses: jest.fn().mockResolvedValue(new Map()), + listOwned: jest.fn(), + revoke: jest.fn(), + serveFile: jest.fn().mockResolvedValue(undefined), + }; + const scans = { + onVerdict: jest.fn((next: FileScanListener) => { + listener = next; + return () => undefined; + }), + }; + const events = { notify: jest.fn() }; + const provider = { + getMessage: jest.fn(), + getMessageSubjects: jest.fn().mockResolvedValue(new Map()), + }; + const service = new MailLargeFilesService( + files as unknown as FileStorageService, + sharing as unknown as FileSharingService, + scans as unknown as FileScanService, + events as unknown as MailEventsService, + provider as unknown as MailProvider, + ); + service.onModuleInit(); + return { + service, + files, + sharing, + events, + provider, + verdict: (event: Parameters[0]) => listener?.(event), + }; +} + +async function codeOf(run: () => Promise): Promise { + try { + await run(); + return null; + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } +} + +describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () => { + beforeEach(() => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + afterEach(() => jest.restoreAllMocks()); + + describe('발송 — 덩이 · 링크', () => { + it('쓸 수 없는 파일이 있으면 400 MAIL_016 — 링크도 메일도 만들지 않는다', async () => { + const { service, files, sharing } = setup(); + files.checkUse.mockResolvedValue({ + usable: [], + problems: new Map([[FILE_A, 'uploading']]), + }); + const deliver = jest.fn(); + expect( + await codeOf(() => + service.deliverWithLink( + ARA, + message(), + PLAN, + { messageId: null, sentAt: SENT_AT }, + deliver, + ), + ), + ).toBe('MAIL_016'); + expect(files.checkUse).toHaveBeenCalledWith( + ARA.email, + PLAN.fileIds, + 'send', + ); + expect(sharing.createMailLink).not.toHaveBeenCalled(); + expect(deliver).not.toHaveBeenCalled(); + }); + + it('덩이를 넣은 본문이 한도를 넘으면 400 MAIL_007 — 링크를 만들기 전이다', async () => { + const { service, sharing } = setup(); + const deliver = jest.fn(); + const long = 'x'.repeat(MAX_BODY_LENGTH - 100); + expect( + await codeOf(() => + service.deliverWithLink( + ARA, + message({ body: long, htmlBody: undefined }), + { ...PLAN, textQuoteStart: null }, + { messageId: null, sentAt: SENT_AT }, + deliver, + ), + ), + ).toBe('MAIL_007'); + expect(sharing.createMailLink).not.toHaveBeenCalled(); + expect(deliver).not.toHaveBeenCalled(); + }); + + it('링크를 만들고(보낸 사람 · 보낸 때 · Message-ID) 덩이를 인용 위에 넣어 보낸다 → 보낸 사본의 id 를 잇는다', async () => { + const { service, sharing } = setup(); + const deliver = jest + .fn, [OutgoingMessage]>() + .mockResolvedValue(SENT); + const result = await service.deliverWithLink( + ARA, + message(), + PLAN, + { + messageId: 'ara.123@arasystem.test', + sentAt: SENT_AT, + }, + deliver, + ); + + expect(result).toBe(SENT); + expect(sharing.createMailLink).toHaveBeenCalledWith({ + ownerEmail: ARA.email, + senderName: ARA.name, + files: VIEWS, + sentAt: SENT_AT, + messageId: 'ara.123@arasystem.test', + }); + const sent = deliver.mock.calls[0][0]; + expect(sent.htmlBody).toBe( + insertHtmlBlock( + message().htmlBody ?? '', + PLAN.htmlQuoteStart, + sentBlock.html, + ), + ); + expect(sent.body).toBe( + insertTextBlock(message().body, PLAN.textQuoteStart, sentBlock.text), + ); + expect(sharing.markSent).toHaveBeenCalledWith('link-1', 'sent-9'); + expect(sharing.cancel).not.toHaveBeenCalled(); + }); + + it('보내지 않은 것이 확실한 실패면 링크를 취소한 링크로, 결과를 모르는 실패면 그대로 둔다', async () => { + for (const [error, cancels] of [ + [new BusinessException('MAIL_010'), true], + [new BusinessException('MAIL_011'), true], + [new BusinessException('VAL_001'), true], + [ + new SendNotStartedException('MAIL_001', { + context: { reason: 'send-budget-exhausted' }, + }), + true, + ], + // context 는 로그 전용 — 같은 까닭 글이어도 형식이 아니면 결과를 모르는 실패다 + [ + new BusinessException('MAIL_001', { + context: { reason: 'send-budget-exhausted' }, + }), + false, + ], + [ + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + false, + ], + [new Error('socket hang up'), false], + ] as const) { + const { service, sharing } = setup(); + const deliver = jest.fn().mockRejectedValue(error); + await expect( + service.deliverWithLink( + ARA, + message(), + PLAN, + { messageId: null, sentAt: SENT_AT }, + deliver, + ), + ).rejects.toBe(error); + expect(sharing.cancel).toHaveBeenCalledTimes(cancels ? 1 : 0); + if (cancels) { + expect(sharing.cancel).toHaveBeenCalledWith( + expect.anything(), + 'send-failed', + ); + } + expect(sharing.markSent).not.toHaveBeenCalled(); + } + }); + + it('isCertainSendFailure — 받는 사람 모두 거절 · 엔진 거절 · 시작하지 않음(SendNotStartedException)만', () => { + expect(isCertainSendFailure(new BusinessException('MAIL_004'))).toBe( + true, + ); + expect( + isCertainSendFailure(new SendNotStartedException('MAIL_001')), + ).toBe(true); + expect(isCertainSendFailure(new BusinessException('MAIL_001'))).toBe( + false, + ); + expect(isCertainSendFailure(new BusinessException('MAIL_005'))).toBe( + false, + ); + expect(isCertainSendFailure('x')).toBe(false); + }); + + it('한 통의 개수 — maxFilesPerMessage 를 넘으면 400 VAL_001', () => { + const { service } = setup(); + expect(() => + service.assertCount(Array.from({ length: 10 }, String)), + ).not.toThrow(); + expect(() => + service.assertCount(Array.from({ length: 11 }, String)), + ).toThrow(BusinessException); + }); + + it('같은 키의 재시도 · 보내기 취소는 링크 쪽에 넘긴다 — 취소는 파일을 임시 저장 기간으로 돌린다', async () => { + const { service, sharing, files } = setup(); + await service.attachRetry(ARA, 'mid', 'sent-1'); + expect(sharing.attachRetry).toHaveBeenCalledWith( + ARA.email, + 'mid', + 'sent-1', + ); + + sharing.findBySentEmail.mockResolvedValue(record()); + await service.onPendingSendCanceled(ARA, 'sent-1'); + expect(sharing.cancel).toHaveBeenCalledWith( + expect.anything(), + 'send-canceled', + ); + expect(files.attachToDraft).toHaveBeenCalledWith(ARA.email, [ + FILE_A, + FILE_B, + ]); + }); + }); + + describe('읽기 — 덩이 알아보기', () => { + const html = `

내 글

${sentBlock.html}

인용

`; + + it('표시가 없으면 DB 를 보지 않는다', async () => { + const { service, sharing } = setup(); + expect( + await service.recognize(ARA, '

그냥 글

', ARA.email), + ).toBeNull(); + expect(sharing.findByTokens).not.toHaveBeenCalled(); + }); + + it('표시 사이의 우리 링크이고 링크 주인이 보낸 사람(From)이면 파일 · 표시를 준다 — 받은 수는 주인만', async () => { + const { service, sharing } = setup(); + sharing.findByTokens.mockResolvedValue(new Map([[TOKEN, record()]])); + sharing.fileStatuses.mockResolvedValue( + new Map([ + [`link-1:${FILE_A}`, 'available'], + [`link-1:${FILE_B}`, 'scanning'], + ]), + ); + + const mine = await service.recognize(ARA, html, 'Ara.Kim@ArASystem.test'); + expect(sharing.findByTokens).toHaveBeenCalledWith([TOKEN]); + expect(mine?.largeFiles).toEqual([ + { + linkId: 'link-1', + fileId: FILE_A, + name: '설계도.dwg', + size: 1_000, + status: 'available', + expiresAt: EXPIRES.toISOString(), + revokedAt: null, + url: shareUrl(ORIGIN, TOKEN), + downloads: { count: 3, lastDownloadedAt: '2026-11-01T00:00:00.000Z' }, + }, + expect.objectContaining({ fileId: FILE_B, status: 'scanning' }), + ]); + // 정화하면 그 덩이의 바깥에만 표시가 붙는다 + const sanitized = sanitizeMailHtml(mine?.html ?? '', new Map(), { + largeFilesMarker: mine?.marker, + }).html; + expect(sanitized.match(/data-ara-large-files/g)).toHaveLength(1); + expect(sanitized).toMatch( + /^

내 글<\/p>

{ + const { service, sharing } = setup(); + sharing.findByTokens.mockResolvedValue(new Map([[TOKEN, record()]])); + expect( + await service.recognize(ARA, html, 'partner@example.test'), + ).toBeNull(); + expect(await service.recognize(ARA, html, null)).toBeNull(); + }); + + it('우리 링크 모양이 아니면(다른 주소 · 표시 밖의 링크) 묻지 않는다', async () => { + const { service, sharing } = setup(); + const lookalike = html.replaceAll(ORIGIN, 'https://files.evil.example'); + expect(await service.recognize(ARA, lookalike, ARA.email)).toBeNull(); + expect(sharing.findByTokens).not.toHaveBeenCalled(); + }); + }); + + describe('임시 저장본', () => { + const draft = (overrides: Partial = {}): DraftSource => ({ + id: 'sent-1', + to: [], + cc: [], + bcc: [], + subject: '제목', + textBody: null, + rawHtmlBody: null, + attachments: [], + inReplyTo: null, + updatedAt: SENT_AT.toISOString(), + isCanceledSend: false, + largeFileIds: [], + ...overrides, + }); + + it('머리글의 파일 — 그 직원의 것만 (파일 모듈이 고른다)', async () => { + const { service, files } = setup(); + await service.draftView(ARA, draft({ largeFileIds: [FILE_A] })); + expect(files.draftFiles).toHaveBeenCalledWith(ARA.email, [FILE_A]); + }); + + it('취소해 되돌린 발송 — 서버가 넣은 덩이를 HTML · 텍스트에서 떼고 그 링크의 파일을 돌려준다 (다시 보내면 덩이 하나)', async () => { + const { service, sharing, files } = setup(); + sharing.findBySentEmail.mockResolvedValue( + record({ canceledAt: SENT_AT }), + ); + const originalHtml = '

내 글

인용

'; + const originalText = '내 글\n\n----- 원본 메일 -----\n> 인용'; + const result = await service.draftView( + ARA, + draft({ + isCanceledSend: true, + rawHtmlBody: insertHtmlBlock( + originalHtml, + '

내 글

'.length, + sentBlock.html, + ), + textBody: insertTextBlock( + originalText, + '내 글\n\n'.length, + sentBlock.text, + ), + }), + ); + expect(result.rawHtmlBody).toBe(originalHtml); + expect(result.textBody).toBe(originalText); + expect(files.draftFiles).toHaveBeenCalledWith(ARA.email, [ + FILE_A, + FILE_B, + ]); + }); + + it('텍스트만 보낸 발송도 주소의 토큰으로 링크를 맞춰 뗀다 · 손댄 덩이는 남긴다', async () => { + const { service, sharing } = setup(); + sharing.findBySentEmail.mockResolvedValue(record()); + const text = insertTextBlock('내 글', null, sentBlock.text); + const stripped = await service.draftView( + ARA, + draft({ isCanceledSend: true, textBody: text }), + ); + expect(stripped.textBody).toBe('내 글\n'); + + const edited = text.replace('설계도.dwg', '설계도_고침.dwg'); + const kept = await service.draftView( + ARA, + draft({ isCanceledSend: true, textBody: edited }), + ); + expect(kept.textBody).toBe(edited); + }); + + it('다른 링크의 덩이(토큰이 다름)는 떼지 않는다', async () => { + const { service, sharing } = setup(); + sharing.findBySentEmail.mockResolvedValue( + record({ tokenHash: hashShareToken('Z'.repeat(43)) }), + ); + const html = `

글

${sentBlock.html}`; + const result = await service.draftView( + ARA, + draft({ isCanceledSend: true, rawHtmlBody: html }), + ); + expect(result.rawHtmlBody).toBe(html); + }); + }); + + describe('보낸 링크 · 위험 판정 알림', () => { + const summary = { + id: 'link-1', + sentEmailId: 'sent-1', + sentAt: SENT_AT.toISOString(), + expiresAt: EXPIRES.toISOString(), + status: 'active' as const, + files: [], + }; + + it('목록은 보낸 메일 제목을 한 번에 읽는다 — 읽지 못하면 제목 없이 그대로', async () => { + const { service, sharing, provider } = setup(); + sharing.listOwned.mockResolvedValue({ + items: [summary, { ...summary, id: 'link-2', sentEmailId: null }], + pageInfo: { hasNext: false, nextCursor: null, total: 2 }, + }); + provider.getMessageSubjects.mockResolvedValue( + new Map([['sent-1', '도면']]), + ); + const page = await service.listShareLinks(ARA, { limit: 20 }); + expect(page.items.map((item) => item.subject)).toEqual(['도면', null]); + expect(provider.getMessageSubjects).toHaveBeenCalledWith(ARA.email, [ + 'sent-1', + ]); + + provider.getMessageSubjects.mockRejectedValue( + new BusinessException('MAIL_001'), + ); + const fallback = await service.listShareLinks(ARA, { limit: 20 }); + expect(fallback.items.map((item) => item.subject)).toEqual([null, null]); + }); + + it('위험 판정이면 그 직원의 연결에 large-file 을 보낸다 (깨끗함 · 검사하지 못함은 보내지 않는다)', () => { + const { events, verdict } = setup(); + verdict({ + ownerEmail: ARA.email, + fileId: FILE_A, + name: '견적서.zip', + scanStatus: 'infected', + }); + verdict({ + ownerEmail: ARA.email, + fileId: FILE_B, + name: '도면.dwg', + scanStatus: 'clean', + }); + expect(events.notify).toHaveBeenCalledTimes(1); + expect(events.notify).toHaveBeenCalledWith(ARA.email, { + type: 'large-file', + data: { fileId: FILE_A, name: '견적서.zip', scanStatus: 'infected' }, + }); + }); + }); +}); diff --git a/backend/src/modules/mail/mail-large-files.service.ts b/backend/src/modules/mail/mail-large-files.service.ts new file mode 100644 index 0000000..b0d59ef --- /dev/null +++ b/backend/src/modules/mail/mail-large-files.service.ts @@ -0,0 +1,640 @@ +import { + Inject, + Injectable, + Logger, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; +import { randomBytes } from 'crypto'; +import type { Request, Response } from 'express'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import type { PaginatedResult } from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { FileSharingService } from '@/modules/file-sharing/file-sharing.service'; +import type { + ShareLinkRecord, + ShareLinkSummary, +} from '@/modules/file-sharing/file-sharing.types'; +import { + hashShareToken, + shareUrl, + tokenFromShareUrl, +} from '@/modules/file-sharing/share-token'; +import { FileScanService } from '@/modules/file-storage/file-scan.service'; +import { FileStorageService } from '@/modules/file-storage/file-storage.service'; +import type { + FileUseProblem, + LargeFile, + MissingLargeFile, + StoredFileView, +} from '@/modules/file-storage/file-storage.types'; +import { traceTag } from '@/shared/logger/request-context'; +import { kstEndOfDayAfter } from '@/shared/utils/kst-date'; +import { maskEmail } from '@/shared/utils/mask'; +import { checkHtmlParseBudget } from './html/html-parse-budget'; +import { LARGE_FILES_MARKER_ATTRIBUTE } from './html/sanitize-mail-html'; +import { + buildLargeFileBlock, + findHtmlBlock, + hrefsIn, + insertHtmlBlock, + insertTextBlock, + removeHtmlBlock, + removeTextBlock, + type LargeFileBlock, +} from './large-file-block'; +import { MAX_BODY_LENGTH, MAX_BODY_VALUE_BYTES } from './mail.constants'; +import { MailEventsService } from './mail-events.service'; +import { + MAIL_PROVIDER, + SendNotStartedException, + type MailProvider, +} from './mail.provider'; +import type { + DraftSource, + MessageLargeFile, + OutgoingMessage, + SentMessage, +} from './mail.types'; + +/** 한 통에서 알아보는 링크 수 (계약 4절 "우리 웹메일에서 읽기" — 한 통에 10개까지 본다) */ +const MAX_RECOGNIZED_LINKS = 10; +/** 덩이 안에서 읽는 링크 주소 수 — 링크마다 주소가 두 번(href) 나오지 않게 넉넉히 */ +const MAX_BLOCK_HREFS = 40; +/** 크기를 미리 재는 덩이의 자리 토큰 — 진짜 토큰과 길이가 같다(43자) */ +const PLACEHOLDER_TOKEN = 'x'.repeat(43); + +/** + * 보내지 않은 것이 확실한 발송 실패 — 그 링크를 취소한 링크로 둔다 (계약 4절 "링크를 만드는 때 · 발송이 실패하면"). + * 받는 사람 모두 거절 · 엔진이 요청을 거절함 · 엔진의 요청 수 한도, 그리고 어댑터가 SendNotStartedException 으로 알린 "시작하지 않음"(시간이 모자람). + * 그 밖(제출 중 시간 초과 · 연결 끊김 MAIL_001 · 알 수 없는 오류)은 나갔을 수 있어 그대로 둔다. context(로그 전용)는 보지 않는다 + */ +const CERTAIN_SEND_FAILURES: ReadonlySet = new Set([ + 'MAIL_010', + 'MAIL_011', + 'VAL_001', + 'MAIL_004', + 'FILE_002', + 'MAIL_003', + 'AUTH_003', + 'REQ_001', +]); +export function isCertainSendFailure(error: unknown): boolean { + if (error instanceof SendNotStartedException) return true; + return ( + error instanceof BusinessException && CERTAIN_SEND_FAILURES.has(error.code) + ); +} + +/** 발송에 실을 대용량 파일과 덩이 자리 — 서비스가 본문을 준비하며 정한다 */ +export interface LargeFilePlan { + /** 이 메일에 새로 싣는 내 파일 (요청 차례, 겹치지 않게, 소문자) */ + readonly fileIds: readonly string[]; + /** 서식 본문의 덩이 자리 (첫 인용 경계 표시 — 없으면 맨 끝) */ + readonly htmlQuoteStart: number | null; + /** 텍스트 본문의 덩이 자리 (bodyQuoteStart — 없으면 맨 끝) */ + readonly textQuoteStart: number | null; +} + +/** 덩이를 넣은 뒤 보낸다 — 메일 서비스의 발송 함수 */ +export type DeliverFunction = ( + message: OutgoingMessage, +) => Promise; + +/** 읽기 화면이 알아본 덩이 — 표시를 붙일 원문과 파일 */ +export interface RecognizedLargeFiles { + readonly largeFiles: MessageLargeFile[]; + /** 덩이를 `
` 로 감싼 원문 HTML (정화 전) */ + readonly html: string; + readonly marker: string; +} + +/** 보낸 사람의 링크 하나 (계약 4절 ShareLink) — 메일 제목을 더한다 */ +export type ShareLinkView = ShareLinkSummary & { + readonly subject: string | null; +}; + +/** 쓸 수 없는 대용량 파일 — 400 MAIL_016 (메일은 나가지 않았다 · 저장하지 않았다) */ +function unusableFiles( + problems: ReadonlyMap, + use: 'send' | 'draft', +): BusinessException { + return new BusinessException('MAIL_016', { + context: { + reason: `large-file-not-${use === 'send' ? 'sendable' : 'savable'}`, + problems: Object.fromEntries(problems), + }, + }); +} + +/** + * 메일과 대용량 첨부(11단계 — 계약 4절 "발송 · 임시 저장과 함께" · "우리 웹메일에서 읽기" · "보낸 사람 관리")를 잇는다. + * 파일은 파일 모듈, 링크는 공유 모듈이 맡고 — 메일 → 공유 → 저장 한 방향 — 이 서비스는 메일 쪽 규칙(덩이 · 자리 · 머리글 · 보낸 사람)만 둔다. + * 위험 판정 소식을 실시간 알림(`large-file`)으로 보낸다. + */ +@Injectable() +export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MailLargeFilesService.name); + private unsubscribe: (() => void) | null = null; + + constructor( + private readonly files: FileStorageService, + private readonly sharing: FileSharingService, + private readonly scans: FileScanService, + private readonly events: MailEventsService, + @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + ) {} + + onModuleInit(): void { + // 위험 판정 → 그 직원의 열린 연결에 `large-file` (연결이 없던 직원은 목록 상태로 안다 — 계약 4절 "정한 것" 2) + this.unsubscribe = this.scans.onVerdict((event) => { + if (event.scanStatus !== 'infected') return; + this.events.notify(event.ownerEmail, { + type: 'large-file', + data: { + fileId: event.fileId, + name: event.name, + scanStatus: 'infected', + }, + }); + }); + } + + onModuleDestroy(): void { + this.unsubscribe?.(); + } + + /** 메일 한 통에 새로 싣는 대용량 파일 수 한도 — 넘으면 400 VAL_001 (보낼 때) */ + assertCount(fileIds: readonly string[]): void { + const { maxFilesPerMessage } = this.files.limits; + if (fileIds.length > maxFilesPerMessage) { + throw new BusinessException('VAL_001', { + context: { + reason: 'too-many-large-files', + count: fileIds.length, + limit: maxFilesPerMessage, + }, + }); + } + } + + // ── 발송 ─────────────────────────────────────────────────────────────── + + /** + * 덩이를 넣어 보낸다 — 계약 4절 "지문 · 같은 키 재시도" · "링크를 만드는 때": 정화 · 서명 그림 · 지문 · 중복 확인을 **마친 뒤** 부른다. + * 1) 파일 확인 — 내 것 · 다 올림 · 위험 아님 (아니면 400 MAIL_016 — 엔진에 묻기 전이다) + * 2) 같은 길이의 자리 토큰으로 덩이를 넣어 본문 한도를 다시 잰다 (넘으면 400 MAIL_007 — 메일도 링크도 만들지 않는다) + * 3) 링크를 만들고(토큰 · 보낸 사람 · 끝나는 때) 덩이를 넣어 보낸다 → 성공하면 보낸 사본의 메일 id 를 잇는다 + * 4) 보내지 않은 것이 확실한 실패면 그 링크를 취소한 링크로, 결과를 모르는 실패면 그대로 둔다 + */ + async deliverWithLink( + user: AuthenticatedUser, + message: OutgoingMessage, + plan: LargeFilePlan, + context: { readonly messageId: string | null; readonly sentAt: Date }, + deliver: DeliverFunction, + ): Promise { + const files = await this.requireUsable(user, plan.fileIds, 'send'); + const expiresAt = kstEndOfDayAfter( + context.sentAt, + this.files.limits.retentionDays, + ); + // 덩이를 넣은 크기를 미리 잰다 — 토큰 길이가 같아 진짜 덩이와 같은 크기다 + this.withBlock( + message, + plan, + buildLargeFileBlock({ + files, + expiresAt, + url: shareUrl(this.sharing.publicOrigin, PLACEHOLDER_TOKEN), + }), + ); + + const created = await this.sharing.createMailLink({ + ownerEmail: user.email, + senderName: user.name || null, + files, + sentAt: context.sentAt, + messageId: context.messageId, + }); + const outgoing = this.withBlock( + message, + plan, + buildLargeFileBlock({ + files, + expiresAt: created.record.link.expiresAt, + url: created.url, + }), + ); + + let result: SentMessage; + try { + result = await deliver(outgoing); + } catch (error) { + if (isCertainSendFailure(error)) { + await this.sharing + .cancel(created.record, 'send-failed') + .catch((cancelError: unknown) => + this.warn( + '보내지 못한 발송의 링크를 취소하지 못했습니다', + user, + cancelError, + ), + ); + } + throw error; + } + await this.sharing + .markSent(created.record.link.id, result.id) + .catch((markError: unknown) => + this.warn('보낸 메일 id 를 링크에 잇지 못했습니다', user, markError), + ); + return result; + } + + /** + * 같은 Idempotency-Key 의 재시도가 처음 메일을 찾았다 — 처음 시도가 만든 링크(보낸 메일을 모르는 것)에 보낸 사본을 잇는다. 링크를 새로 만들지 않는다 + */ + async attachRetry( + user: AuthenticatedUser, + messageId: string, + sentId: string, + ): Promise { + await this.sharing + .attachRetry(user.email, messageId, sentId) + .catch((error: unknown) => + this.warn( + '같은 키 재시도의 링크에 보낸 메일 id 를 잇지 못했습니다', + user, + error, + ), + ); + } + + /** + * 붙잡은 발송을 취소했다(9단계 — 엔진에서 확인된 뒤) — 그 발송이 만든 링크를 취소한 링크로 두고, 파일은 임시 저장 기간으로 돌린다 + * (되돌린 초안이 쓴다 — 보내지 않은 파일 목록에 다시 나온다). 실패해도 취소는 된 것이다 — 경고만 + */ + async onPendingSendCanceled( + user: AuthenticatedUser, + sentId: string, + ): Promise { + try { + const record = await this.sharing.findBySentEmail(user.email, sentId); + if (!record) return; + await this.sharing.cancel(record, 'send-canceled'); + await this.files.attachToDraft( + user.email, + record.files.map((file) => file.fileId), + ); + } catch (error) { + this.warn('취소한 발송의 링크를 취소하지 못했습니다', user, error); + } + } + + // ── 임시 저장 ──────────────────────────────────────────────────────────── + + /** 저장할 파일 확인 — 내 것이면 올리는 중 · 위험 판정이어도 된다. 없음 · 남의 것 · 지움 · 기간 지남은 400 MAIL_016 */ + async requireDraftable( + user: AuthenticatedUser, + fileIds: readonly string[], + ): Promise { + if (fileIds.length === 0) return; + await this.requireUsable(user, fileIds, 'draft'); + } + + /** 저장했다 — 그 파일들을 "임시 저장에 실린 파일"로 (남는 기간이 는다). 실패해도 저장은 된 것이다 — 경고만 */ + async afterDraftSaved( + user: AuthenticatedUser, + fileIds: readonly string[], + ): Promise { + if (fileIds.length === 0) return; + await this.files + .attachToDraft(user.email, fileIds) + .catch((error: unknown) => + this.warn( + '임시 저장에 실린 대용량 파일을 적지 못했습니다', + user, + error, + ), + ); + } + + /** + * 이어 쓸 임시 저장본의 대용량 파일 · 본문 (GET /api/drafts/{id}) — 머리글의 파일 가운데 그 직원의 것만. + * 취소해 되돌린 발송(9단계)이면 그 링크의 파일을 더하고, 서버가 넣은 덩이를 원문 HTML(정화 전) · 텍스트에서 뗀다 — 다시 보내면 새 덩이 하나 + */ + async draftView( + user: AuthenticatedUser, + source: DraftSource, + ): Promise<{ + readonly rawHtmlBody: string | null; + readonly textBody: string | null; + readonly largeFiles: (LargeFile | MissingLargeFile)[]; + }> { + let { rawHtmlBody, textBody } = source; + const ids = [...source.largeFileIds]; + if (source.isCanceledSend) { + const record = await this.sharing.findBySentEmail(user.email, source.id); + if (record) { + const stripped = this.stripBlock(record, rawHtmlBody, textBody); + rawHtmlBody = stripped.rawHtmlBody; + textBody = stripped.textBody; + for (const file of record.files) { + if (!ids.includes(file.fileId)) ids.push(file.fileId); + } + } + } + return { + rawHtmlBody, + textBody, + largeFiles: await this.files.draftFiles(user.email, ids), + }; + } + + // ── 읽기 ─────────────────────────────────────────────────────────────── + + /** + * 원문 HTML 의 덩이를 알아본다 (계약 4절 "우리 웹메일에서 읽기") — 셋이 모두 맞을 때만: + * 표시 주석 사이의 링크이고(표시가 없으면 DB 를 보지 않는다), href 가 `/s/<토큰>` 이고 토큰이 우리 기록에 있으며(취소한 링크는 빼고 · 10개까지), + * 그 링크를 만든 직원이 그 메일의 보낸 사람(From — 대소문자 무시)이다. 아니면 null(본문 글 그대로) + */ + async recognize( + user: AuthenticatedUser, + rawHtmlBody: string, + from: string | null, + ): Promise { + const found = await this.recognizedRecords(rawHtmlBody, from); + if (found === null) return null; + const statuses = await this.sharing.fileStatuses( + found.records.map(({ record }) => record), + ); + const viewer = user.email.toLowerCase(); + const largeFiles = found.records.flatMap(({ record, token }) => + record.files.map( + (file): MessageLargeFile => ({ + linkId: record.link.id, + fileId: file.fileId, + name: file.name, + size: file.size, + status: statuses.get(`${record.link.id}:${file.fileId}`) ?? 'missing', + expiresAt: record.link.expiresAt.toISOString(), + revokedAt: record.link.revokedAt?.toISOString() ?? null, + url: shareUrl(this.sharing.publicOrigin, token), + downloads: + record.link.ownerEmail === viewer + ? { + count: file.downloadCount, + lastDownloadedAt: + file.lastDownloadedAt?.toISOString() ?? null, + } + : null, + }), + ), + ); + // 정화가 이 div 에만 data-ara-large-files 를 붙인다 — 요청마다 무작위라 보낸 사람이 흉내 낼 수 없다 + const marker = randomBytes(16).toString('hex'); + const { start, end } = found.block; + const html = + rawHtmlBody.slice(0, start) + + `
` + + rawHtmlBody.slice(start, end) + + '
' + + rawHtmlBody.slice(end); + return { largeFiles, html, marker }; + } + + /** + * 우리 웹메일에서 받기 (GET · HEAD /api/messages/{id}/large-files/{fileId}) — 그 메일에서 첨부처럼 올린 파일만(아니면 404 RES_001). + * 머리글 · 범위 · 상태별 실패는 공개 받기와 같다. 보는 사람이 링크를 만든 사람이면 세지 않는다(보낸 사람 자신의 확인) + */ + async serveMessageFile( + user: AuthenticatedUser, + messageId: string, + fileId: string, + req: Request, + res: Response, + ): Promise { + const detail = await this.provider.getMessage(user.email, messageId); + const raw = detail.rawHtmlBody; + const found = + raw !== null && checkHtmlParseBudget(raw).ok + ? await this.recognizedRecords(raw, detail.from?.email ?? null) + : null; + const record = found?.records.find(({ record: candidate }) => + candidate.files.some((file) => file.fileId === fileId), + )?.record; + if (!record) { + throw new BusinessException('RES_001', { + context: { reason: 'large-file-not-in-message' }, + }); + } + await this.sharing.serveFile(req, res, record, fileId, { + count: record.link.ownerEmail !== user.email.toLowerCase(), + }); + } + + // ── 보낸 사람 관리 (/api/share-links) ────────────────────────────────────── + + /** 내 링크 — 보낸 메일 제목을 엔진에서 한 번에 읽는다(지웠거나 읽지 못하면 null — 목록은 그대로 준다, warn) */ + async listShareLinks( + user: AuthenticatedUser, + query: { readonly cursor?: string; readonly limit: number }, + ): Promise> { + const page = await this.sharing.listOwned(user.email, query); + const subjects = await this.subjectsOf(user, page.items); + return { + items: page.items.map((item) => ({ + ...item, + subject: + item.sentEmailId === null + ? null + : (subjects.get(item.sentEmailId) ?? null), + })), + pageInfo: page.pageInfo, + }; + } + + /** 링크 끊기 — 그 링크(메일)의 파일 모두. 남의 링크 · 없는 것 404 */ + async revokeShareLink( + user: AuthenticatedUser, + id: string, + ): Promise { + const summary = await this.sharing.revoke(user.email, id); + const subjects = await this.subjectsOf(user, [summary]); + return { + ...summary, + subject: + summary.sentEmailId === null + ? null + : (subjects.get(summary.sentEmailId) ?? null), + }; + } + + // ── 내부 ──────────────────────────────────────────────────────────────── + + private async requireUsable( + user: AuthenticatedUser, + fileIds: readonly string[], + use: 'send' | 'draft', + ): Promise { + const { usable, problems } = await this.files.checkUse( + user.email, + fileIds, + use, + ); + if (problems.size > 0) throw unusableFiles(problems, use); + return usable; + } + + /** 덩이를 넣은 메일 — 넣은 뒤 본문 한도를 다시 잰다 (텍스트 100,000자 · 서식 본문 2,000,000바이트 — 넘으면 400 MAIL_007) */ + private withBlock( + message: OutgoingMessage, + plan: LargeFilePlan, + block: LargeFileBlock, + ): OutgoingMessage { + const body = insertTextBlock(message.body, plan.textQuoteStart, block.text); + const htmlBody = message.htmlBody + ? insertHtmlBlock(message.htmlBody, plan.htmlQuoteStart, block.html) + : undefined; + if (body.length > MAX_BODY_LENGTH) { + throw new BusinessException('MAIL_007', { + context: { + reason: 'body-too-long-with-large-files', + length: body.length, + }, + }); + } + if ( + htmlBody !== undefined && + Buffer.byteLength(htmlBody, 'utf8') > MAX_BODY_VALUE_BYTES + ) { + throw new BusinessException('MAIL_007', { + context: { reason: 'body-html-too-large-with-large-files' }, + }); + } + return { + ...message, + body, + ...(htmlBody !== undefined ? { htmlBody } : {}), + }; + } + + /** 원문 HTML 의 덩이 · 그 안의 우리 링크 가운데 보낸 사람의 것 — 없으면 null */ + private async recognizedRecords( + rawHtmlBody: string, + from: string | null, + ): Promise<{ + readonly block: { readonly start: number; readonly end: number }; + readonly records: { + readonly token: string; + readonly record: ShareLinkRecord; + }[]; + } | null> { + if (from === null) return null; + const block = findHtmlBlock(rawHtmlBody); + if (block === null) return null; + const tokens = [ + ...new Set( + hrefsIn(rawHtmlBody.slice(block.start, block.end), MAX_BLOCK_HREFS) + .map((href) => tokenFromShareUrl(this.sharing.publicOrigin, href)) + .filter((token): token is string => token !== null), + ), + ].slice(0, MAX_RECOGNIZED_LINKS); + if (tokens.length === 0) return null; + const found = await this.sharing.findByTokens(tokens); + const sender = from.trim().toLowerCase(); + const records = tokens.flatMap((token) => { + const record = found.get(token); + return record && record.link.ownerEmail === sender + ? [{ token, record }] + : []; + }); + return records.length === 0 ? null : { block, records }; + } + + /** + * 취소해 되돌린 발송의 덩이를 뗀다 — HTML 은 표시 사이(그 링크의 토큰이 있을 때만), 텍스트는 서버가 넣은 글 그대로일 때만. + * 토큰은 HTML 덩이의 링크에서(없으면 텍스트의 주소에서) 찾아 링크의 해시와 맞춘다 — 덩이를 다시 만들어 글 그대로 찾는다 + */ + private stripBlock( + record: ShareLinkRecord, + rawHtmlBody: string | null, + textBody: string | null, + ): { rawHtmlBody: string | null; textBody: string | null } { + const origin = this.sharing.publicOrigin; + const matches = (token: string | null): token is string => + token !== null && hashShareToken(token).equals(record.link.tokenHash); + + let token: string | null = null; + let html = rawHtmlBody; + const block = html === null ? null : findHtmlBlock(html); + if (html !== null && block !== null) { + token = + hrefsIn(html.slice(block.start, block.end), MAX_BLOCK_HREFS) + .map((href) => tokenFromShareUrl(origin, href)) + .find(matches) ?? null; + if (token !== null) html = removeHtmlBlock(html, block); + } + if (token === null && textBody !== null) { + token = this.tokenInText(textBody, matches); + } + + let text = textBody; + if (token !== null && text !== null) { + const { text: blockText } = buildLargeFileBlock({ + files: record.files, + expiresAt: record.link.expiresAt, + url: shareUrl(origin, token), + }); + text = removeTextBlock(text, blockText) ?? text; + } + return { rawHtmlBody: html, textBody: text }; + } + + /** 텍스트의 공개 화면 주소 가운데 그 링크의 토큰 */ + private tokenInText( + text: string, + matches: (token: string | null) => token is string, + ): string | null { + const prefix = `${this.sharing.publicOrigin}/s/`; + for ( + let at = text.indexOf(prefix); + at >= 0; + at = text.indexOf(prefix, at + prefix.length) + ) { + const candidate = text.slice(at + prefix.length, at + prefix.length + 43); + if (matches(candidate)) return candidate; + } + return null; + } + + /** 링크들의 보낸 메일 제목 — 읽지 못하면 빈 맵(warn) */ + private async subjectsOf( + user: AuthenticatedUser, + links: readonly ShareLinkSummary[], + ): Promise> { + const ids = links + .map((link) => link.sentEmailId) + .filter((id): id is string => id !== null); + if (ids.length === 0) return new Map(); + try { + return await this.provider.getMessageSubjects(user.email, ids); + } catch (error) { + this.warn( + '보낸 링크의 메일 제목을 읽지 못해 제목 없이 답합니다', + user, + error, + ); + return new Map(); + } + } + + private warn(what: string, user: AuthenticatedUser, error: unknown): void { + const code = error instanceof BusinessException ? error.code : 'SYS_001'; + this.logger.warn( + `[${code}]${traceTag()} ${what}: user=${maskEmail(user.email)}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/backend/src/modules/mail/mail.constants.ts b/backend/src/modules/mail/mail.constants.ts index f7208ad..b355b15 100644 --- a/backend/src/modules/mail/mail.constants.ts +++ b/backend/src/modules/mail/mail.constants.ts @@ -212,5 +212,5 @@ export const MAX_DISPLAY_NAME_LENGTH = 200; /** 메일 주소 최대 길이 (RFC 5321 경로 한도) */ export const MAX_EMAIL_LENGTH = 254; -/** 첨부 파일명 최대 길이 */ -export const MAX_FILE_NAME_LENGTH = 255; +/** 첨부 파일명 최대 길이 — 파일 이름 정리(shared/utils/file-name.ts)와 같은 값 하나를 쓴다 (대용량 첨부도 같다) */ +export { MAX_FILE_NAME_LENGTH } from '@/shared/utils/file-name'; diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index e9eac09..b5c243c 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -7,6 +7,7 @@ import { import { BusinessException } from '@/common/errors/business.exception'; import { ContactsModule } from '@/modules/contacts/contacts.module'; +import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module'; import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module'; import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module'; import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module'; @@ -36,6 +37,7 @@ import { MailFilterRunController, } from './mail-filter.controller'; import { MailEventsService } from './mail-events.service'; +import { MailLargeFilesService } from './mail-large-files.service'; import { MailEngineModule } from './mail-engine.module'; import { MailUploadTempCleanup, @@ -56,6 +58,7 @@ import { RecipientSuggestionController } from './recipient-suggestion.controller import { RecipientSuggestionService } from './recipient-suggestions.service'; import { ScheduledMessageController } from './scheduled-message.controller'; import { ScheduledMessagesService } from './scheduled-messages.service'; +import { ShareLinkController } from './share-link.controller'; function readMailSettings(config: ConfigService): MailSettings { return buildMailSettings((key) => config.get(key)); @@ -116,6 +119,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailDirectoryModule, // 보내기 · 읽기 설정 — 발송의 보내기 취소 초 · 메일 읽기의 외부 이미지 (9단계) MailUserSettingsModule, + // 대용량 첨부(11단계) — 공유 링크 · 공개 내려받기(파일 저장 모듈을 함께 내보낸다). 메일 → 공유 → 저장 한 방향 + FileSharingModule, ], controllers: [ MailboxController, @@ -133,6 +138,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailImageController, DraftController, RecipientSuggestionController, + // 보낸 대용량 첨부 링크 — 목록 · 끊기 (11단계, 보낸 메일 제목을 함께) + ShareLinkController, ], providers: [ // 보낸 사람 차단 (9단계) — 저장은 필터 · 부재중과 같은 엔진 규칙(포트) @@ -153,6 +160,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { MailService, // 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown) MailEventsService, + // 대용량 첨부(11단계) — 발송의 덩이 · 링크, 임시 저장의 파일, 읽기의 덩이 알아보기, 위험 판정 알림(large-file) + MailLargeFilesService, RecipientSuggestionService, MailUploadTempCleanup, ], diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index 72258cf..e965522 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -1,3 +1,4 @@ +import { BusinessException } from '@/common/errors/business.exception'; import type { AttachmentContent, BlockedSender, @@ -54,6 +55,13 @@ import type { */ export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER'); +/** + * 엔진에 아무것도 내지 않은 채 끝난 발송 실패 — 보내기를 시작하지 않았다(예: 남은 시간이 모자라 제출하지 않음). 코드 · 응답은 BusinessException 그대로다. + * 어댑터가 이 예외로 던지면 메일 서비스는 "보내지 않은 것이 확실한 실패"로 본다 (11단계 — 대용량 첨부의 링크를 취소한다, 계약 4절 "링크를 만드는 때"). + * BusinessException 의 context 는 로그 전용이라 갈래에 쓰지 않는다 — 이 형식으로 가른다 (2026-10-30 리뷰 L6) + */ +export class SendNotStartedException extends BusinessException {} + export interface MailProvider { /** * 직원 자격 증명을 메일 엔진에 확인한다. 비밀번호는 저장하지 않는다. @@ -294,6 +302,15 @@ export interface MailProvider { /** 받은 그대로의 메일 원문(엔진의 원본)을 흘려보낸다 — 없는 메일은 RES_001 */ openMessageSource(user: string, id: string): Promise; + /** + * 메일 제목 (11단계 — 보낸 대용량 첨부 링크 목록이 보낸 메일 제목을 함께 보인다) — 한 번에 읽는다. + * 없는 메일(지움)은 결과에 들지 않는다. 엔진을 읽지 못하면 그 실패(MAIL_001 등)를 던진다 — 부르는 쪽이 제목 없이 답한다 + */ + getMessageSubjects( + user: string, + ids: readonly string[], + ): Promise>; + /** 그 메일에 실제로 있는 첨부만 내려준다 (스트리밍) */ openAttachment( user: string, diff --git a/backend/src/modules/mail/mail.service.spec.ts b/backend/src/modules/mail/mail.service.spec.ts index 06b44ba..977d59d 100644 --- a/backend/src/modules/mail/mail.service.spec.ts +++ b/backend/src/modules/mail/mail.service.spec.ts @@ -14,6 +14,7 @@ import type { MailFilterRunRequestDto } from './dto/mail-filter-run.dto'; import type { DraftBodyDto, SendMessageDto } from './dto/send-message.dto'; import { MAX_HTML_PARSE_LENGTH } from './html/html-parse-budget'; import { MailImageTokens } from './images/mail-image-token'; +import type { MailLargeFilesService } from './mail-large-files.service'; import { MAX_BODY_HTML_LENGTH, MAX_BODY_LENGTH, @@ -29,6 +30,7 @@ import type { Mailbox, MailFilterRule, MessageDetailSource, + OutgoingMessage, SentMessage, SentMessageRecord, } from './mail.types'; @@ -89,6 +91,7 @@ function fakeProvider(): ProviderMocks { setMailFilters: mockOf<'setMailFilters'>(), runMailFilter: mockOf<'runMailFilter'>(), openMessageSource: mockOf<'openMessageSource'>(), + getMessageSubjects: mockOf<'getMessageSubjects'>(), getUsage: mockOf<'getUsage'>(), getAttachmentsTotalLimit: mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024), @@ -256,6 +259,7 @@ const draftSource = (overrides: Partial = {}): DraftSource => ({ inReplyTo: null, updatedAt: '2026-10-27T01:00:00.000Z', isCanceledSend: false, + largeFileIds: [], ...overrides, }); @@ -292,12 +296,45 @@ function fakeNinthStep(): NinthStepMocks { }; } +/** + * 11단계 — 대용량 첨부의 대역. 기존 테스트는 대용량 파일 없이 본다: 덩이를 알아보지 않고(null), 임시 저장본은 그대로 · 파일 없음. + * 대용량 파일이 있는 경로는 아래 "대용량 첨부" 묶음이 이 대역의 호출을 본다 (규칙 자체는 mail-large-files.service.spec.ts) + */ +interface LargeFileMocks { + assertCount: jest.Mock; + deliverWithLink: jest.Mock; + attachRetry: jest.Mock; + requireDraftable: jest.Mock; + afterDraftSaved: jest.Mock; + draftView: jest.Mock; + recognize: jest.Mock; +} + +function fakeLargeFiles(): LargeFileMocks { + return { + assertCount: jest.fn(), + deliverWithLink: jest.fn(), + attachRetry: jest.fn().mockResolvedValue(undefined), + requireDraftable: jest.fn().mockResolvedValue(undefined), + afterDraftSaved: jest.fn().mockResolvedValue(undefined), + draftView: jest.fn((_user: unknown, draft: DraftSource) => + Promise.resolve({ + rawHtmlBody: draft.rawHtmlBody, + textBody: draft.textBody, + largeFiles: [], + }), + ), + recognize: jest.fn().mockResolvedValue(null), + }; +} + function createService( provider: ProviderMocks, mailSettings: MailSettings, signatures: SignatureMocks, suggestions: { invalidate: jest.Mock }, ninth: NinthStepMocks, + largeFiles: LargeFileMocks = fakeLargeFiles(), ): MailService { return new MailService( provider, @@ -308,6 +345,7 @@ function createService( ninth.contacts as unknown as ContactsService, ninth.directory as unknown as DirectoryService, ninth.imageTokens, + largeFiles as unknown as MailLargeFilesService, ); } @@ -2733,6 +2771,7 @@ describe('MailService', () => { attachments: [], inReplyTo: 'eaaaaab', updatedAt: '2026-10-27T01:00:00.000Z', + largeFiles: [], }); expect(provider.getDraft).toHaveBeenCalledWith(user.email, 'draft-1'); }); @@ -3298,3 +3337,230 @@ describe('MailService', () => { }); }); }); + +describe('MailService — 대용량 첨부 (11단계)', () => { + const FILE_A = '0f8fad5b-d9cb-469f-a165-70867728950e'; + const FILE_B = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; + let provider: ProviderMocks; + let largeFiles: LargeFileMocks; + let service: MailService; + + beforeEach(() => { + provider = fakeProvider(); + largeFiles = fakeLargeFiles(); + // 덩이 · 링크는 대역이 맡는다 — 받은 메일 그대로 보낸다 + largeFiles.deliverWithLink.mockImplementation( + ( + _user: unknown, + message: OutgoingMessage, + _plan: unknown, + _context: unknown, + deliver: (message: OutgoingMessage) => Promise, + ) => deliver(message), + ); + service = createService( + provider, + settings, + fakeSignatures(), + { invalidate: jest.fn() }, + fakeNinthStep(), + largeFiles, + ); + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + const htmlWithQuote = + '

내 글

----- 원본 메일 -----

'; + const body = '내 글\n\n----- 원본 메일 -----'; + + it('발송 — 인용 경계 표시를 지우고 그 자리 · bodyQuoteStart · 파일(소문자 · 겹치지 않게)을 넘긴다', async () => { + await service.sendMessage( + user, + sendDto({ + body, + bodyHtml: htmlWithQuote, + bodyQuoteStart: '내 글\n\n'.length, + largeFileIds: [FILE_A.toUpperCase(), FILE_B, FILE_A], + }), + ); + expect(largeFiles.assertCount).toHaveBeenCalledWith([FILE_A, FILE_B]); + const [, message, plan, context] = largeFiles.deliverWithLink.mock + .calls[0] as [ + unknown, + OutgoingMessage, + unknown, + { messageId: null; sentAt: Date }, + ]; + expect(message.htmlBody).toBe( + '

내 글

----- 원본 메일 -----

', + ); + expect(plan).toEqual({ + fileIds: [FILE_A, FILE_B], + htmlQuoteStart: '

내 글

'.length, + textQuoteStart: '내 글\n\n'.length, + }); + expect(context.messageId).toBeNull(); + expect(provider.sendMessage).toHaveBeenCalledTimes(1); + }); + + it('대용량 파일이 없으면 링크를 만들지 않는다 — 표시는 그래도 지운다(받는 사람에게 가지 않는다)', async () => { + await service.sendMessage(user, sendDto({ body, bodyHtml: htmlWithQuote })); + expect(largeFiles.deliverWithLink).not.toHaveBeenCalled(); + const sent = provider.sendMessage.mock.calls[0][1]; + expect(sent.htmlBody).not.toContain('data-ara-quote-start'); + }); + + it('bodyQuoteStart 가 줄의 처음이 아니거나 본문 밖이면 400 VAL_001 — 엔진에 묻기 전', async () => { + for (const position of [1, body.length + 1]) { + expect( + await failureOf(() => + service.sendMessage( + user, + sendDto({ body, bodyQuoteStart: position, largeFileIds: [FILE_A] }), + ), + ), + ).toEqual({ code: 'VAL_001', reason: 'invalid-body-quote-start' }); + } + expect(provider.sendMessage).not.toHaveBeenCalled(); + }); + + it('같은 키의 재시도가 처음 메일을 찾으면 그 결과를 주고 링크를 새로 만들지 않는다(처음 링크에 잇는다)', async () => { + const dto = sendDto({ largeFileIds: [FILE_A] }); + await service.sendMessage(user, dto, IDEMPOTENCY_KEY); + const first = provider.sendMessage.mock.calls[0][1]; + expect(first.fingerprint).toBeDefined(); + + provider.findSentMessage.mockResolvedValue( + sentRecord(first.fingerprint ?? null, { id: 'sent-1' }), + ); + largeFiles.deliverWithLink.mockClear(); + await expect( + service.sendMessage(user, dto, IDEMPOTENCY_KEY), + ).resolves.toMatchObject({ + id: 'sent-1', + }); + expect(largeFiles.deliverWithLink).not.toHaveBeenCalled(); + expect(largeFiles.attachRetry).toHaveBeenCalledWith( + user, + first.messageId, + 'sent-1', + ); + + // 대용량 파일을 바꾼 재시도는 지문이 달라 MAIL_005 + expect( + await codeOf(() => + service.sendMessage( + user, + sendDto({ largeFileIds: [FILE_B] }), + IDEMPOTENCY_KEY, + ), + ), + ).toBe('MAIL_005'); + }); + + it('예약 발송의 링크는 보낼 시각부터 센다', async () => { + const sendAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + await service.sendMessage( + user, + sendDto({ sendAt, largeFileIds: [FILE_A] }), + ); + const [, , , context] = largeFiles.deliverWithLink.mock.calls[0] as [ + unknown, + unknown, + unknown, + { sentAt: Date }, + ]; + expect(context.sentAt.getTime()).toBe( + Math.floor(Date.parse(sendAt) / 1000) * 1000, + ); + }); + + it('임시 저장 — 파일을 확인하고(올리는 중도 된다) 머리글로 넘긴 뒤 "임시 저장에 실린 파일"로 적는다 · 표시는 저장본에 남지 않는다', async () => { + provider.saveDraft.mockResolvedValue({ + id: 'draft-2', + attachments: [], + updatedAt: '2026-10-30T00:00:00.000Z', + }); + await service.createDraft( + user, + draftDto({ bodyHtml: htmlWithQuote, largeFileIds: [FILE_A] }), + ); + expect(largeFiles.requireDraftable).toHaveBeenCalledWith(user, [FILE_A]); + const [, content] = provider.saveDraft.mock.calls[0]; + expect(content.largeFileIds).toEqual([FILE_A]); + expect(content.htmlBody).not.toContain('data-ara-quote-start'); + expect(largeFiles.afterDraftSaved).toHaveBeenCalledWith(user, [FILE_A]); + + largeFiles.requireDraftable.mockRejectedValueOnce( + new BusinessException('MAIL_016'), + ); + expect( + await codeOf(() => + service.replaceDraft( + user, + 'draft-2', + draftDto({ largeFileIds: [FILE_B] }), + ), + ), + ).toBe('MAIL_016'); + expect(provider.saveDraft).toHaveBeenCalledTimes(1); + }); + + it('이어 쓸 임시 저장본 — 대용량 파일 · 덩이를 뗀 본문을 쓴다', async () => { + provider.getDraft.mockResolvedValue( + draftSource({ rawHtmlBody: '

덩이 있음

', textBody: '덩이 있음' }), + ); + largeFiles.draftView.mockResolvedValue({ + rawHtmlBody: '

뗌

', + textBody: '뗌', + largeFiles: [ + { + id: FILE_A, + status: 'missing', + reason: 'deleted', + name: 'a.zip', + size: 1, + }, + ], + }); + const draft = await service.getDraft(user, 'draft-1'); + expect(draft.bodyHtml).toBe('

뗌

'); + expect(draft.body).toBe('뗌'); + expect(draft.largeFiles).toEqual([ + { + id: FILE_A, + status: 'missing', + reason: 'deleted', + name: 'a.zip', + size: 1, + }, + ]); + }); + + it('메일 읽기 — 알아본 덩이의 바깥에 data-ara-large-files, largeFiles 를 싣는다 · 알아보지 못하면 본문 그대로', async () => { + provider.getMessage.mockResolvedValue( + source({ rawHtmlBody: '

글

덩이
' }), + ); + largeFiles.recognize.mockResolvedValue({ + html: '

글

덩이
', + marker: 'm1', + largeFiles: [{ fileId: FILE_A }], + }); + const detail = await service.getMessage(user, 'm1'); + expect(detail.htmlBody).toContain('
'); + expect(detail.largeFiles).toEqual([{ fileId: FILE_A }]); + expect(largeFiles.recognize).toHaveBeenCalledWith( + user, + '

글

덩이
', + 'doyun.lee@arasystem.test', + ); + + largeFiles.recognize.mockRejectedValue(new Error('db down')); + const fallback = await service.getMessage(user, 'm1'); + expect(fallback.largeFiles).toEqual([]); + expect(fallback.htmlBody).not.toContain('data-ara-large-files'); + }); +}); diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index d9fb371..2062d9c 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -124,6 +124,12 @@ import { parseIdempotencyKey, payloadFingerprint, } from './send-idempotency'; +import { extractQuoteStart, isValidQuoteStart } from './large-file-block'; +import { + MailLargeFilesService, + type LargeFilePlan, + type RecognizedLargeFiles, +} from './mail-large-files.service'; /** * 엔진 장애(MAIL_001)로 끝난 발송의 키를 잠가 두는 시간. @@ -157,6 +163,23 @@ function rejectedSuffix(result: SentMessage): string { /** 발송·임시 저장 본문이 함께 쓰는 입력 — 받는 사람 규칙만 다르다 (dto/send-message.dto.ts) */ type ComposeInput = ComposeContentDto & { readonly to: RecipientDto[] }; +/** 본문 준비의 결과 — 엔진에 넘길 내용 · 함께 실을 서명 그림 · 대용량 첨부(11단계)의 파일과 덩이 자리 */ +interface PreparedCompose { + readonly content: DraftContent; + readonly inlineImages: OutgoingInlineImage[]; + /** 이 메일에 새로 싣는 내 대용량 파일 (요청 차례, 겹치지 않게, 소문자) */ + readonly largeFileIds: string[]; + /** 서식 본문의 첫 인용 경계 표시 자리 — 표시는 지웠다 */ + readonly htmlQuoteStart: number | null; + /** 텍스트 본문의 인용 시작 자리 (bodyQuoteStart) */ + readonly textQuoteStart: number | null; +} + +/** 발송 · 임시 저장 본문의 대용량 파일 id — 소문자로 겹치지 않게 (요청 차례) */ +function normalizeLargeFileIds(ids: readonly string[] | undefined): string[] { + return [...new Set((ids ?? []).map((id) => id.toLowerCase()))]; +} + /** 준비를 어디에 쓰나 — 보낼 때만 서명 그림을 메일 안에 싣는다 (임시 저장본은 그림 주소 그대로) */ type ComposePurpose = 'send' | 'draft'; @@ -335,6 +358,8 @@ export class MailService { private readonly directory: DirectoryService, /** 외부 이미지 중계 표 (9단계) */ private readonly imageTokens: MailImageTokens, + /** 대용량 첨부 — 발송의 덩이 · 링크, 임시 저장의 파일, 읽기의 덩이 알아보기 (11단계) */ + private readonly largeFiles: MailLargeFilesService, ) {} async listMailboxes( @@ -707,6 +732,8 @@ export class MailService { imagesShown: false, isHtmlBodyOmitted, isBodyTruncated: isTextBodyTruncated, + // 텍스트만 있는 메일 · 정화 한도를 넘은 메일의 덩이는 알아보지 않는다 (계약 4절의 알려진 한계) + largeFiles: [], }); if (rawHtmlBody === null) return withoutHtml(false); @@ -721,23 +748,27 @@ export class MailService { return withoutHtml(true); } - const [inlineImages, showImages] = await Promise.all([ + const [inlineImages, showImages, recognized] = await Promise.all([ this.loadInlineImages(user.email, rawHtmlBody, inlineParts), this.showsExternalImages( user, { sender: detail.from?.email ?? null, isInJunkMailbox }, query, ), + this.recognizeLargeFiles(user, id, rawHtmlBody, detail.from?.email), ]); const sanitized = sanitizeMailHtml( - rawHtmlBody, + recognized?.html ?? rawHtmlBody, inlineImages.dataUris, - showImages - ? { - externalImageSrc: (url) => - `${MAIL_IMAGE_PATH_PREFIX}${this.imageTokens.issue(user.email, url.href)}`, - } - : {}, + { + ...(showImages + ? { + externalImageSrc: (url: URL) => + `${MAIL_IMAGE_PATH_PREFIX}${this.imageTokens.issue(user.email, url.href)}`, + } + : {}), + ...(recognized ? { largeFilesMarker: recognized.marker } : {}), + }, ); const inlined = new Set( [...sanitized.embeddedContentIds].flatMap((contentId) => { @@ -754,9 +785,30 @@ export class MailService { imagesShown: sanitized.proxiedImageCount > 0, isHtmlBodyOmitted: false, isBodyTruncated: showsHtml ? isRawHtmlBodyTruncated : isTextBodyTruncated, + largeFiles: recognized?.largeFiles ?? [], }; } + /** + * 원문 HTML 의 대용량 첨부 덩이를 알아본다 (계약 4절 "우리 웹메일에서 읽기") — 읽지 못하면(DB 장애) 덩이 없이 본문 그대로 보인다(경고만). + * 메일 읽기를 대용량 첨부 때문에 실패로 만들지 않는다 + */ + private async recognizeLargeFiles( + user: AuthenticatedUser, + id: string, + rawHtmlBody: string, + from: string | undefined, + ): Promise { + try { + return await this.largeFiles.recognize(user, rawHtmlBody, from ?? null); + } catch (error) { + this.logger.warn( + `[SYS_001]${traceTag()} 대용량 첨부 덩이를 알아보지 못해 본문 그대로 보입니다: user=${maskEmail(user.email)}, id=${id}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } + } + /** * 외부 이미지를 보일까 (계약 2절 "외부 이미지 보기") — `images=show` 면 늘(스팸메일함도). 아니면 설정: * ask 는 안 보이고, 스팸메일함의 메일은 설정과 상관없이 안 보인다, always 는 보이고, trusted 는 보낸 사람이 내 주소록이나 사내 목록에 있을 때. @@ -893,17 +945,40 @@ export class MailService { ? null : scheduledHold(dto.sendAt, Date.now()); - const prepared = await this.prepareOutgoing(user, dto); + const { message: prepared, plan } = await this.prepareOutgoing(user, dto); const hold = scheduled ?? (await this.undoHold(user)); const message: OutgoingMessage = hold ? { ...prepared, hold } : prepared; + // 대용량 첨부의 보낸 때 — 예약은 보낼 시각(끝나는 날도 그 날짜로), 그 밖은 지금 (보내기 취소의 몇 초는 무시한다) + const sentAt = + hold?.kind === 'scheduled' ? new Date(hold.until) : new Date(); const result = parsed.key === null - ? await this.deliver(user, message) - : await this.deliverOnce(user, message, parsed.key); + ? await this.deliverWithLargeFiles(user, message, plan, { + messageId: null, + sentAt, + }) + : await this.deliverOnce(user, message, parsed.key, plan, sentAt); await this.afterSent(user, dto.draftId); return result; } + /** 보낸다 — 대용량 파일이 있으면 링크를 만들어 덩이를 넣은 뒤 (계약 4절 "링크를 만드는 때") */ + private deliverWithLargeFiles( + user: AuthenticatedUser, + message: OutgoingMessage, + plan: LargeFilePlan | null, + context: { readonly messageId: string | null; readonly sentAt: Date }, + ): Promise { + if (plan === null) return this.deliver(user, message); + return this.largeFiles.deliverWithLink( + user, + message, + plan, + context, + (outgoing) => this.deliver(user, outgoing), + ); + } + /** 보내기 취소를 기다리는 붙잡기 — 그 직원 설정의 초(0 이면 붙잡지 않는다) */ private async undoHold(user: AuthenticatedUser): Promise { const { undoSendSeconds } = await this.userSettingsOf(user); @@ -917,8 +992,7 @@ export class MailService { user: AuthenticatedUser, dto: DraftBodyDto, ): Promise { - const { content } = await this.prepareContent(user, dto, 'draft'); - return this.provider.saveDraft(user.email, content); + return this.saveDraft(user, dto); } /** 임시 저장 — 바꿔 저장. 새 id 로 돌려준다 (초안이 아니면 RES_001, 다른 곳에서 먼저 바뀌었으면 MAIL_008) */ @@ -927,8 +1001,31 @@ export class MailService { id: string, dto: DraftBodyDto, ): Promise { - const { content } = await this.prepareContent(user, dto, 'draft'); - return this.provider.saveDraft(user.email, content, id); + return this.saveDraft(user, dto, id); + } + + /** + * 임시 저장 — 대용량 파일(11단계)은 내 것이면 올리는 중 · 위험 판정이어도 된다(없음 · 남의 것 · 지움 · 기간 지남은 MAIL_016). + * 파일 id 는 초안 머리글로 두고(덩이는 넣지 않는다), 저장한 뒤 그 파일들을 "임시 저장에 실린 파일"로 적는다 + */ + private async saveDraft( + user: AuthenticatedUser, + dto: DraftBodyDto, + replaceId?: string, + ): Promise { + const { content, largeFileIds } = await this.prepareContent( + user, + dto, + 'draft', + ); + await this.largeFiles.requireDraftable(user, largeFileIds); + const saved = await this.provider.saveDraft( + user.email, + largeFileIds.length > 0 ? { ...content, largeFileIds } : content, + replaceId, + ); + await this.largeFiles.afterDraftSaved(user, largeFileIds); + return saved; } /** @@ -937,17 +1034,23 @@ export class MailService { */ async getDraft(user: AuthenticatedUser, id: string): Promise { const source = await this.provider.getDraft(user.email, id); + // 대용량 첨부(11단계) — 머리글의 파일, 취소해 되돌린 발송이면 그 링크의 파일 + 본문에서 뗀 덩이 + const view = await this.largeFiles.draftView(user, source); return { id: source.id, to: source.to, cc: source.cc, bcc: source.bcc, subject: source.subject, - body: source.textBody ?? '', - bodyHtml: await this.draftHtml(user, source), + body: view.textBody ?? '', + bodyHtml: await this.draftHtml(user, { + ...source, + rawHtmlBody: view.rawHtmlBody, + }), attachments: source.attachments, inReplyTo: source.inReplyTo, updatedAt: source.updatedAt, + largeFiles: view.largeFiles, }; } @@ -1032,11 +1135,17 @@ export class MailService { } } - /** 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005 */ + /** + * 같은 키로는 한 번만 보낸다 — 처리 중이면 REQ_001, 이미 보냈으면 그 결과, 내용이 다르면 MAIL_005. + * 대용량 파일(11단계)은 지문에 id 만(있을 때만) 넣고, 덩이는 중복 확인 **뒤에** 넣는다 — 같은 키의 재시도가 처음 메일을 찾으면 + * 처음 결과를 그대로 주고 링크를 새로 만들지 않는다(처음 시도의 링크에 보낸 사본을 잇는다) + */ private async deliverOnce( user: AuthenticatedUser, message: OutgoingMessage, key: string, + plan: LargeFilePlan | null, + sentAt: Date, ): Promise { const lockKey = `${user.email.toLowerCase()}\n${key}`; if (this.sendsInProgress.has(lockKey)) { @@ -1053,7 +1162,7 @@ export class MailService { try { const messageId = idempotentMessageId(user.email, key); // 지문 키는 이 요청의 Idempotency-Key 다 — 서버 비밀을 쓰지 않는다 (send-idempotency.ts 머리말) - const fingerprint = payloadFingerprint(message, key); + const fingerprint = payloadFingerprint(message, key, plan?.fileIds); const previous = await this.provider.findSentMessage( user.email, @@ -1092,11 +1201,19 @@ export class MailService { this.logger.log( `${traceTag()} 같은 Idempotency-Key 의 재발송 — 이미 보낸 메일을 돌려준다: from=${maskEmail(user.email)}, id=${previous.id}${rejectedSuffix(result)}`, ); + if (plan !== null) { + await this.largeFiles.attachRetry(user, messageId, previous.id); + } return result; } try { - return await this.deliver(user, { ...message, messageId, fingerprint }); + return await this.deliverWithLargeFiles( + user, + { ...message, messageId, fingerprint }, + plan, + { messageId, sentAt }, + ); } catch (error) { // 엔진에 닿지 못했거나 답을 못 받았다 — 엔진이 아직 처리 중일 수 있으니 잠시 같은 키를 막아 둔다. // 받는 사람 모두 거절(MAIL_010 · MAIL_011)은 막지 않는다 — 아무것도 나가지 않았고 보낸 사본도 지웠다 (다시 보내면 새로 보낸다) @@ -1116,19 +1233,31 @@ export class MailService { } } - /** 보낼 메일 — 준비에 서명 그림(cid)을 더한다 */ + /** + * 보낼 메일 — 준비에 서명 그림(cid)을 더한다. 대용량 파일(11단계)이 있으면 덩이 자리와 함께 계획을 돌려준다 + * (한 통의 개수가 한도를 넘으면 400 VAL_001 — 파일 확인 · 덩이는 중복 확인 뒤에 한다) + */ private async prepareOutgoing( user: AuthenticatedUser, dto: SendMessageDto, - ): Promise { - const { content, inlineImages } = await this.prepareContent( - user, - dto, - 'send', - ); + ): Promise<{ message: OutgoingMessage; plan: LargeFilePlan | null }> { + const { + content, + inlineImages, + largeFileIds, + htmlQuoteStart, + textQuoteStart, + } = await this.prepareContent(user, dto, 'send'); + this.largeFiles.assertCount(largeFileIds); return { - ...content, - ...(inlineImages.length > 0 ? { inlineImages } : {}), + message: { + ...content, + ...(inlineImages.length > 0 ? { inlineImages } : {}), + }, + plan: + largeFileIds.length > 0 + ? { fileIds: largeFileIds, htmlQuoteStart, textQuoteStart } + : null, }; } @@ -1140,10 +1269,7 @@ export class MailService { user: AuthenticatedUser, dto: ComposeInput, purpose: ComposePurpose, - ): Promise<{ - content: DraftContent; - inlineImages: OutgoingInlineImage[]; - }> { + ): Promise { const to = dto.to.map(toAddress); const cc = (dto.cc ?? []).map(toAddress); const bcc = (dto.bcc ?? []).map(toAddress); @@ -1175,8 +1301,19 @@ export class MailService { return { id: attachment.id, name, type }; }); + // 텍스트 본문의 인용 시작 자리(11단계 — 대용량 첨부 덩이 자리) — 0 ~ body.length 이고 줄의 처음이어야 한다 + const textQuoteStart = dto.bodyQuoteStart ?? null; + if ( + textQuoteStart !== null && + !isValidQuoteStart(dto.body, textQuoteStart) + ) { + throw new BusinessException('VAL_001', { + context: { reason: 'invalid-body-quote-start' }, + }); + } + // 본문 한도를 보고, 서식 본문은 편집기가 만드는 서식만 남긴다 (보내는 메일 전용 허용 목록). 정화해서 비면 텍스트 메일로 보낸다 - const { htmlBody, inlineImages } = await this.prepareBody( + const { htmlBody, inlineImages, htmlQuoteStart } = await this.prepareBody( user, dto.body, dto.bodyHtml, @@ -1195,26 +1332,37 @@ export class MailService { inReplyTo: dto.inReplyTo, }, inlineImages, + largeFileIds: normalizeLargeFileIds(dto.largeFileIds), + htmlQuoteStart, + textQuoteStart, }; } /** * 본문 한도와 서식 본문 정화 — 순서는 위 "본문 한도" 주석 그대로. 정화한 뒤 서명 그림이 서로 다른 3장을 넘으면 VAL_001. * 서명 그림은 보내는 사람 자신의 그림만 남는다 — 그림 주소가 있을 때만 그 사람의 그림 목록을 읽는다. - * @returns 정화한 서식 본문(없거나 비면 빈 문자열 — 텍스트 메일)과 메일에 함께 실을 그림(보낼 때만) + * 쓰기 화면의 인용 경계 표시(11단계)는 정화가 남긴 뒤 마지막에 모두 지우고 첫 자리만 기억한다 — 받는 사람 · 임시 저장본 · 지문에 들지 않는다 + * @returns 정화한 서식 본문(없거나 비면 빈 문자열 — 텍스트 메일), 메일에 함께 실을 그림(보낼 때만), 첫 인용 경계 자리(없으면 null) */ private async prepareBody( user: AuthenticatedUser, body: string, bodyHtml: string | undefined, purpose: ComposePurpose, - ): Promise<{ htmlBody: string; inlineImages: OutgoingInlineImage[] }> { + ): Promise<{ + htmlBody: string; + inlineImages: OutgoingInlineImage[]; + htmlQuoteStart: number | null; + }> { assertBodyLength(body); - if (!bodyHtml) return { htmlBody: '', inlineImages: [] }; + if (!bodyHtml) { + return { htmlBody: '', inlineImages: [], htmlQuoteStart: null }; + } assertHtmlBeforeSanitize(bodyHtml); const sanitized = sanitizeOutgoingHtml(bodyHtml, { signatureImageIds: await this.signatureImageIdsFor(user, bodyHtml), + keepQuoteStart: true, }); // 서명 그림은 서명 저장과 같은 한도 — 서로 다른 내 그림 3장까지 (정화가 남의 그림은 이미 지웠다). 보내기 전이라 메일은 나가지 않는다 const imageCount = findSignatureImageIds(sanitized).length; @@ -1227,8 +1375,15 @@ export class MailService { purpose === 'send' ? await this.embedSignatureImages(user, sanitized) : { htmlBody: sanitized, inlineImages: [] }; - assertHtmlBytes(prepared.htmlBody); - return prepared; + const { html, position } = extractQuoteStart(prepared.htmlBody); + // 표시만 있던 본문은 비어 텍스트 메일이다 + const htmlBody = html.trim() === '' ? '' : html; + assertHtmlBytes(htmlBody); + return { + htmlBody, + inlineImages: prepared.inlineImages, + htmlQuoteStart: htmlBody === '' ? null : position, + }; } /** 서명 그림 주소가 있을 때만 그 사람의 그림 목록을 읽는다 (없으면 DB 를 보지 않는다) */ diff --git a/backend/src/modules/mail/mail.types.ts b/backend/src/modules/mail/mail.types.ts index 3029d7a..7d44fa4 100644 --- a/backend/src/modules/mail/mail.types.ts +++ b/backend/src/modules/mail/mail.types.ts @@ -1,6 +1,11 @@ import type { Readable } from 'stream'; import type { ContactEmailLabel } from '@/modules/contacts/contacts.types'; +import type { SharedFileStatus } from '@/modules/file-sharing/file-sharing.types'; +import type { + LargeFile, + MissingLargeFile, +} from '@/modules/file-storage/file-storage.types'; /** * 메일 도메인 타입 — API 계약(docs/mail/api-contract.md 2절)과 같은 모양이다. @@ -81,6 +86,29 @@ export interface MessageAttachment extends MailAttachment { readonly isInline: boolean; } +/** + * 메일 속 대용량 첨부 하나 (계약 4절 "우리 웹메일에서 읽기" — 11단계) — 서버가 넣은 덩이로 확인될 때만(표시 주석 사이의 우리 링크 · + * 링크 주인 = 그 메일의 보낸 사람). 이름 · 크기 · 상태는 메일 글이 아니라 서버 기록이다 + */ +export interface MessageLargeFile { + readonly linkId: string; + readonly fileId: string; + readonly name: string; + readonly size: number; + readonly status: SharedFileStatus; + /** 링크가 끝나는 때 (ISO 8601 UTC) */ + readonly expiresAt: string; + /** 링크를 끊은 때 (보낸 사람 · 운영) — 끊지 않았으면 null */ + readonly revokedAt: string | null; + /** 공개 화면 주소 — 본문 덩이의 그 주소 (같은 링크의 파일은 같은 주소) */ + readonly url: string; + /** 보는 사람이 그 링크를 만든 사람일 때만 — 아니면 null */ + readonly downloads: { + readonly count: number; + readonly lastDownloadedAt: string | null; + } | null; +} + export interface MessageDetail extends MessageSummary { readonly cc: MailAddress[]; readonly bcc: MailAddress[]; @@ -104,6 +132,11 @@ export interface MessageDetail extends MessageSummary { */ readonly isBodyTruncated: boolean; readonly attachments: MessageAttachment[]; + /** + * 우리 서버가 넣은 대용량 첨부 덩이의 파일 (11단계) — 링크 주인이 이 메일의 보낸 사람일 때만, 없으면 빈 배열. + * 그때 htmlBody 안 그 덩이의 바깥 요소에 `data-ara-large-files` 가 붙는다 + */ + readonly largeFiles: MessageLargeFile[]; } /** 본문이 `cid:` 로 참조할 수 있는 내장 부분 (첨부 목록에 있는 것 중 Content-ID 가 있는 것) */ @@ -131,6 +164,7 @@ export interface MessageDetailSource extends Omit< | 'isHtmlBodyOmitted' | 'isBodyTruncated' | 'attachments' + | 'largeFiles' > { /** 첨부 — 본문에 넣었는지(isInline)는 정화한 뒤 서비스 계층이 정한다 */ readonly attachments: MailAttachment[]; @@ -499,7 +533,13 @@ export type DraftContent = Pick< | 'htmlBody' | 'attachments' | 'inReplyTo' ->; +> & { + /** + * 이 초안에 실린 내 대용량 파일 id (11단계 — 올리는 중인 것도) — 어댑터가 초안 머리글 `X-Ara-Large-Files` 로 둔다. + * 덩이는 넣지 않는다(보낼 때만 만든다). 없거나 비면 머리글을 두지 않는다 + */ + readonly largeFileIds?: readonly string[]; +}; /** 저장한 임시 저장본 — 저장할 때마다 id·첨부 id 가 바뀐다 */ export interface SavedDraft { @@ -530,6 +570,8 @@ export interface DraftSource { * 서명 그림 주소로 되돌린다 — 어댑터는 그 그림 부분을 첨부에서 이미 뺐다 */ readonly isCanceledSend: boolean; + /** 초안 머리글 `X-Ara-Large-Files` 의 파일 id (11단계) — 우리 파일 id 모양(UUID)만, 겹치지 않게. 없으면 빈 배열 */ + readonly largeFileIds: string[]; } /** 쓰기 화면에서 이어 쓸 임시 저장본 (계약 2절 "임시 저장"의 Draft) */ @@ -545,6 +587,11 @@ export interface Draft { readonly attachments: MailAttachment[]; readonly inReplyTo: string | null; readonly updatedAt: string; + /** + * 대용량 첨부 (11단계) — 머리글의 파일 가운데 그 직원의 것만: 있으면 LargeFile(올리는 중 포함), 지웠거나 기간이 지났으면 MissingLargeFile. + * 취소해 되돌린 발송은 그 링크의 파일 (덩이는 본문에서 뗐다) + */ + readonly largeFiles: (LargeFile | MissingLargeFile)[]; } /** 주고받은 주소 하나 — 보낸 메일의 받는 사람·참조·숨은참조, 받은 메일의 보낸 사람 */ diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts index 95756bd..a792de8 100644 --- a/backend/src/modules/mail/message.controller.ts +++ b/backend/src/modules/mail/message.controller.ts @@ -11,6 +11,7 @@ import { Patch, Post, Query, + Req, Res, StreamableFile, UseGuards, @@ -23,7 +24,8 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; -import type { Response } from 'express'; +import { Throttle } from '@nestjs/throttler'; +import type { Request, Response } from 'express'; import { ApiErrorResponse, @@ -35,6 +37,7 @@ import { } from '@/common/decorators/current-user.decorator'; import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { FileIdPipe } from '@/modules/file-storage/file-id.pipe'; import { currentRequestId } from '@/shared/logger/request-context'; import { buildAttachmentDisposition } from '@/shared/utils/content-disposition'; import { @@ -62,6 +65,7 @@ import { MessageListQueryDto } from './dto/message-list-query.dto'; import { SendMessageDto } from './dto/send-message.dto'; import { UpdateMessageDto } from './dto/update-message.dto'; import { MailIdPipe } from './mail-id.pipe'; +import { MailLargeFilesService } from './mail-large-files.service'; import { MailService } from './mail.service'; import type { AttachmentContent } from './mail.types'; import { IDEMPOTENCY_KEY_HEADER } from './send-idempotency'; @@ -72,6 +76,10 @@ const ATTACHMENT_CONTENT_TYPE = 'application/octet-stream'; /** 메일 원문의 형식 (계약 2절 "스팸 · 원문") — attachment 로 내려주므로 브라우저가 열어 그리지 않는다 */ const MESSAGE_SOURCE_CONTENT_TYPE = 'message/rfc822'; +/** 우리 웹메일에서 대용량 첨부 받기의 요청 수 — IP 마다 1분 300회 (계약 4절 — 공개 받기와 같다, HEAD 포함) */ +const LARGE_FILE_THROTTLE_TTL_MS = 60_000; +const LARGE_FILE_THROTTLE_LIMIT = 300; + @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) @@ -83,7 +91,10 @@ const MESSAGE_SOURCE_CONTENT_TYPE = 'message/rfc822'; export class MessageController { private readonly logger = new Logger(MessageController.name); - constructor(private readonly mail: MailService) {} + constructor( + private readonly mail: MailService, + private readonly largeFiles: MailLargeFilesService, + ) {} @Get() @ApiOperation({ @@ -315,6 +326,51 @@ export class MessageController { return this.streamDownload(res, content, ATTACHMENT_CONTENT_TYPE, '첨부'); } + @Get(':id/large-files/:fileId') + @SkipTransform() + @Throttle({ + default: { + ttl: LARGE_FILE_THROTTLE_TTL_MS, + limit: LARGE_FILE_THROTTLE_LIMIT, + }, + }) + @ApiOperation({ + summary: + '우리 웹메일에서 대용량 첨부 받기 (GET · HEAD · Range — 표준 래퍼 없음, 11단계)', + description: + '그 메일에서 첨부처럼 올린 파일(MessageDetail.largeFiles)만 준다 — 아니면 404. 머리글 · 범위 · 상태별 실패는 공개 받기(GET /api/public-links/{token}/files/{fileId})와 같다. ' + + `보는 사람이 링크를 만든 사람이면 받은 수에 넣지 않는다. 요청 수는 IP 마다 1분 ${LARGE_FILE_THROTTLE_LIMIT}회.`, + }) + @ApiProduces(ATTACHMENT_CONTENT_TYPE) + @ApiResponse({ + status: HttpStatus.OK, + description: '파일 (206 — 범위)', + schema: { type: 'string', format: 'binary' }, + }) + @ApiResponse({ + status: HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE, + description: '받을 수 없는 범위 — Content-Range: bytes */<크기>, 본문 없음', + }) + @ApiErrorResponse( + 'RES_001', + '없는 메일 · 그 메일에서 첨부처럼 올린 파일이 아님', + ) + @ApiErrorResponse('FILE_008', '보안 검사 중 — 끝나면 받는다') + @ApiErrorResponse( + 'FILE_007', + '기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음', + ) + @ApiErrorResponse('MAIL_001', '메일 엔진 장애') + async largeFile( + @CurrentUser() user: AuthenticatedUser, + @Param('id', MailIdPipe) id: string, + @Param('fileId', FileIdPipe) fileId: string, + @Req() req: Request, + @Res() res: Response, + ): Promise { + await this.largeFiles.serveMessageFile(user, id, fileId, req, res); + } + @Get(':id/source') @SkipTransform() @ApiOperation({ diff --git a/backend/src/modules/mail/scheduled-messages.service.spec.ts b/backend/src/modules/mail/scheduled-messages.service.spec.ts index 08baf9d..b9a0624 100644 --- a/backend/src/modules/mail/scheduled-messages.service.spec.ts +++ b/backend/src/modules/mail/scheduled-messages.service.spec.ts @@ -1,5 +1,6 @@ import { BusinessException } from '@/common/errors/business.exception'; import { encodeOffsetCursor } from '@/shared/utils/offset-cursor'; +import type { MailLargeFilesService } from './mail-large-files.service'; import type { MailProvider } from './mail.provider'; import type { MessageSummary } from './mail.types'; import { ScheduledMessagesService } from './scheduled-messages.service'; @@ -45,13 +46,20 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취 cancelPendingSend: jest.Mock; }; let service: ScheduledMessagesService; + let largeFiles: { onPendingSendCanceled: jest.Mock }; beforeEach(() => { provider = { listScheduledMessages: jest.fn().mockResolvedValue(all), cancelPendingSend: jest.fn().mockResolvedValue(undefined), }; - service = new ScheduledMessagesService(provider as unknown as MailProvider); + largeFiles = { + onPendingSendCanceled: jest.fn().mockResolvedValue(undefined), + }; + service = new ScheduledMessagesService( + provider as unknown as MailProvider, + largeFiles as unknown as MailLargeFilesService, + ); }); it('목록 — 엔진이 준 차례(보낼 시각)대로 한 쪽, total 은 전체 수 · 커서로 이어 받는다', async () => { @@ -115,4 +123,19 @@ describe('ScheduledMessagesService — 예약한 메일 · 붙잡은 발송 취 code: 'MAIL_014', }); }); + + it('취소가 엔진에서 확인된 뒤에만 그 발송의 대용량 첨부 링크를 취소한다 (11단계) — 취소가 실패하면 건드리지 않는다', async () => { + await service.cancel(user, 'm1'); + expect(largeFiles.onPendingSendCanceled).toHaveBeenCalledWith(user, 'm1'); + expect( + largeFiles.onPendingSendCanceled.mock.invocationCallOrder[0], + ).toBeGreaterThan(provider.cancelPendingSend.mock.invocationCallOrder[0]); + + largeFiles.onPendingSendCanceled.mockClear(); + provider.cancelPendingSend.mockRejectedValueOnce( + new BusinessException('MAIL_001', { context: { reason: 'x' } }), + ); + await failureOf(() => service.cancel(user, 'm2')); + expect(largeFiles.onPendingSendCanceled).not.toHaveBeenCalled(); + }); }); diff --git a/backend/src/modules/mail/scheduled-messages.service.ts b/backend/src/modules/mail/scheduled-messages.service.ts index 2439d8f..2a8e91c 100644 --- a/backend/src/modules/mail/scheduled-messages.service.ts +++ b/backend/src/modules/mail/scheduled-messages.service.ts @@ -11,6 +11,7 @@ import { decodeOffsetCursor, encodeOffsetCursor, } from '@/shared/utils/offset-cursor'; +import { MailLargeFilesService } from './mail-large-files.service'; import { MAIL_PROVIDER, type MailProvider } from './mail.provider'; import type { MessageSummary } from './mail.types'; @@ -24,7 +25,11 @@ const SCHEDULED_SCOPE = 'scheduled-messages'; */ @Injectable() export class ScheduledMessagesService { - constructor(@Inject(MAIL_PROVIDER) private readonly provider: MailProvider) {} + constructor( + @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + /** 취소한 발송의 대용량 첨부 링크를 취소한 링크로 (11단계) */ + private readonly largeFiles: MailLargeFilesService, + ) {} /** * 예약한 메일 — 보낼 시각 차례, total 포함 (화면의 「예약됨」 통 수). 커서는 위치다 — 차례 사이에 보내지거나 취소되면 한두 개가 겹치거나 빠질 수 있다. @@ -58,13 +63,15 @@ export class ScheduledMessagesService { /** * 붙잡은 발송을 취소하고 그 메일을 임시보관함으로 되돌린다 — 쓰기 화면이 같은 메일 id 로 이어 쓴다 (draftId). - * 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다) + * 없는 메일 RES_001, 이미 보냄 · 기다리는 발송이 아님 MAIL_014, 취소를 확인하지 못함 · 옮기지 못함 MAIL_001 (어댑터 — 취소 · 예약의 로그도 어댑터가 남긴다). + * 엔진에서 취소가 확인된 **뒤에** 그 발송이 만든 대용량 첨부 링크를 취소한 링크로 둔다 (11단계 — 파일은 되돌린 초안이 쓴다) */ async cancel( user: AuthenticatedUser, id: string, ): Promise<{ draftId: string }> { await this.provider.cancelPendingSend(user.email, id); + await this.largeFiles.onPendingSendCanceled(user, id); return { draftId: id }; } } diff --git a/backend/src/modules/mail/send-idempotency.spec.ts b/backend/src/modules/mail/send-idempotency.spec.ts index 33256c1..06b6dd4 100644 --- a/backend/src/modules/mail/send-idempotency.spec.ts +++ b/backend/src/modules/mail/send-idempotency.spec.ts @@ -200,3 +200,32 @@ describe('payloadFingerprint (v2 — Idempotency-Key 로 만든 HMAC)', () => { ); }); }); + +describe('payloadFingerprint — 대용량 첨부(11단계)', () => { + const FILE_A = '0f8fad5b-d9cb-469f-a165-70867728950e'; + const FILE_B = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; + + it('대용량 파일이 없으면 지금과 같은 지문이다 (빈 목록 = 넘기지 않음)', () => { + expect(payloadFingerprint(message, KEY, [])).toBe( + payloadFingerprint(message, KEY), + ); + }); + + it('파일 id 만(정렬 · 소문자) 넣는다 — 차례만 바꾼 재시도는 같고, 파일을 바꾼 재시도는 다르다', () => { + const withFiles = payloadFingerprint(message, KEY, [FILE_A, FILE_B]); + expect( + payloadFingerprint(message, KEY, [FILE_B.toUpperCase(), FILE_A]), + ).toBe(withFiles); + expect(withFiles).not.toBe(payloadFingerprint(message, KEY)); + expect(withFiles).not.toBe(payloadFingerprint(message, KEY, [FILE_A])); + }); + + it('서식 본문이 없는 메일의 파일 목록이 서식 본문 자리의 글과 섞이지 않는다', () => { + const textWithFiles = payloadFingerprint(message, KEY, [FILE_A]); + const htmlLookalike = payloadFingerprint( + { ...message, htmlBody: JSON.stringify([FILE_A]) }, + KEY, + ); + expect(textWithFiles).not.toBe(htmlLookalike); + }); +}); diff --git a/backend/src/modules/mail/send-idempotency.ts b/backend/src/modules/mail/send-idempotency.ts index 2a07861..ed811f6 100644 --- a/backend/src/modules/mail/send-idempotency.ts +++ b/backend/src/modules/mail/send-idempotency.ts @@ -105,10 +105,13 @@ function fingerprintKey(idempotencyKey: string): Buffer { * 정규화: 주소는 소문자·정렬(순서만 바꾼 것은 같은 메일), 표시 이름은 넣지 않는다, 본문 줄바꿈은 LF 로, 첨부는 id 만 정렬. * 서식 본문(정화한 HTML)은 **있을 때만** 끝에 붙인다 — 텍스트 메일의 지문 입력은 서식 본문이 생기기 전과 같아서, * 배포 전에 보낸 메일을 같은 키로 다시 보내도 MAIL_005 로 오판하지 않는다 (그래서 버전을 올리지 않았다). + * 대용량 첨부(11단계)도 같은 결이다 — 덩이(시도마다 새 토큰)가 아니라 파일 id(정렬)를 **있을 때만** `{ largeFileIds }` 로 붙인다. + * 대용량 파일이 없는 발송의 지문은 지금과 같다. 인용 경계 표시 · bodyQuoteStart 는 넣지 않는다 (계약 4절 "지문 · 같은 키 재시도") */ export function payloadFingerprint( message: FingerprintSource, idempotencyKey: string, + largeFileIds: readonly string[] = [], ): string { const canonical = JSON.stringify([ FINGERPRINT_VERSION, @@ -120,6 +123,16 @@ export function payloadFingerprint( message.attachments.map((attachment) => attachment.id).sort(), message.inReplyTo ?? null, ...(message.htmlBody ? [normalizeNewlines(message.htmlBody)] : []), + // 서식 본문(글)과 섞이지 않게 객체로 — 같은 자리에 와도 JSON 이 다르다 + ...(largeFileIds.length > 0 + ? [ + { + largeFileIds: [...largeFileIds] + .map((id) => id.toLowerCase()) + .sort(), + }, + ] + : []), ]); const digest = createHmac('sha256', fingerprintKey(idempotencyKey)) .update(canonical) diff --git a/backend/src/modules/mail/share-link.controller.ts b/backend/src/modules/mail/share-link.controller.ts new file mode 100644 index 0000000..5ece335 --- /dev/null +++ b/backend/src/modules/mail/share-link.controller.ts @@ -0,0 +1,81 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { + CurrentUser, + type AuthenticatedUser, +} from '@/common/decorators/current-user.decorator'; +import { NoStore } from '@/common/decorators/no-store.decorator'; +import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto'; +import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + ShareLinkDto, + ShareLinkPageDto, +} from '@/modules/file-sharing/dto/share-link.dto'; +import { FileIdPipe } from '@/modules/file-storage/file-id.pipe'; +import { MailLargeFilesService } from './mail-large-files.service'; + +/** 보낸 대용량 첨부 링크 경로 (`/api/share-links`) */ +export const SHARE_LINKS_ROUTE = 'share-links'; + +/** + * 보낸 사람의 대용량 첨부 링크 관리 (11단계 — 계약 4절 "보낸 사람 관리") — 메일 한 통 = 링크 하나. + * 링크는 공유 모듈이 맡고, 보낸 메일 제목을 메일 엔진에서 읽어 더하므로 메일 모듈에 둔다 (메일 → 공유 한 방향). + * 링크 복사는 없다 — 토큰은 SHA-256 만이라 되살릴 수 없다(보낸 메일의 덩이에서 복사한다) + */ +@ApiTags('Files') +@ApiCookieAuth() +@UseGuards(JwtAuthGuard) +@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@Controller(SHARE_LINKS_ROUTE) +export class ShareLinkController { + constructor(private readonly largeFiles: MailLargeFilesService) {} + + @Get() + @NoStore() + @ApiOperation({ + summary: '보낸 대용량 첨부 링크 (보낸 때 최근부터, 커서 페이지네이션)', + description: + '내가 만든 링크 — 취소한 링크(보내기 취소 · 보내지 못함)는 빠진다. 기록을 지우기 전(끝나거나 끊긴 뒤 90일)까지 기간 지남 · 끊음도 나온다. ' + + 'subject 는 보낸 사본을 메일 서버에서 한 번에 읽는다 — 지웠거나 읽지 못하면 null 이고 목록은 그대로 준다. total 포함.', + }) + @ApiStandardResponse(ShareLinkPageDto, { description: '보낸 링크' }) + @ApiErrorResponse('VAL_001', '깨진 커서 · limit 범위 밖') + list( + @CurrentUser() user: AuthenticatedUser, + @Query() query: CursorPaginationQueryDto, + ) { + return this.largeFiles.listShareLinks(user, query); + } + + @Post(':id/revocation') + @HttpCode(HttpStatus.OK) + @NoStore() + @ApiOperation({ + summary: '링크 끊기 — 그 링크(메일)의 파일 모두', + description: + '되돌릴 수 없다. 다음 요청부터 공개 화면은 "공유 멈춤"이고 받기는 410 FILE_007 이다 — 이미 시작한 내려받기는 끝까지 간다. ' + + '그 파일들이 다른 살아 있는 링크 · 임시 저장에 없으면 보관량에서 바로 빠진다. 이미 끊었거나 기간이 지났으면 바꾸지 않고 200(지금 상태).', + }) + @ApiStandardResponse(ShareLinkDto, { description: '끊은 링크 (revoked)' }) + @ApiErrorResponse('RES_001', '남의 링크 · 없는 것 · 취소한 링크') + revoke( + @CurrentUser() user: AuthenticatedUser, + @Param('id', FileIdPipe) id: string, + ) { + return this.largeFiles.revokeShareLink(user, id); + } +} diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index 988f083..b0bc365 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -19,6 +19,8 @@ const prod = (extra: Record = {}) => ({ MAIL_GATEWAY_PASSWORD: 'Gw7xQ2pLm9Rt4Vz8Kc3N', CORS_ORIGIN: 'https://mail.arasystem.it.kr', TRUST_PROXY: '2', + SHARE_PUBLIC_URL: 'https://files.arasystem.it.kr', + CLAMAV_HOST: 'clamav', ...extra, }); @@ -396,11 +398,221 @@ describe('validateEnv', () => { }); }); + describe('대용량 첨부 (11단계)', () => { + const dev = (extra: Record) => ({ + NODE_ENV: 'development', + ...extra, + }); + + it('비면 기본값 — 개발은 공개 주소 · 검사기 없이도 기동한다', () => { + expect(() => + validateEnv( + dev({ SHARE_PUBLIC_URL: '', CLAMAV_HOST: '', CLAMAV_PORT: '' }), + ), + ).not.toThrow(); + }); + + it('숫자는 앞자리 0 없는 양의 정수 · 범위 안', () => { + expect(() => + validateEnv(dev({ MAIL_LARGE_FILE_MAX_SIZE_MB: '02047' })), + ).toThrow(/MAIL_LARGE_FILE_MAX_SIZE_MB 는 양의 정수/); + for (const [key, value, max] of [ + ['MAIL_LARGE_FILE_MAX_SIZE_MB', '2048', '2,047'], + ['MAIL_LARGE_FILE_MAX_COUNT', '21', '20'], + ['MAIL_LARGE_FILE_USER_QUOTA_GB', '1001', '1,000'], + ['MAIL_LARGE_FILE_RETENTION_DAYS', '91', '90'], + ['SHARE_DOWNLOAD_LIMIT', '100001', '100,000'], + ['CLAMAV_PORT', '65536', '65,535'], + ]) { + expect(() => validateEnv(dev({ [key]: value }))).toThrow( + new RegExp(`${key} 는 ${max} 이하여야 합니다`), + ); + } + expect(() => + validateEnv( + dev({ + MAIL_LARGE_FILE_MAX_SIZE_MB: '2047', + MAIL_LARGE_FILE_MAX_COUNT: '20', + MAIL_LARGE_FILE_USER_QUOTA_GB: '1000', + MAIL_LARGE_FILE_RETENTION_DAYS: '90', + SHARE_DOWNLOAD_LIMIT: '100000', + FILE_STORAGE_MAX_TOTAL_GB: '500', + FILE_STORAGE_MIN_FREE_GB: '10', + CLAMAV_PORT: '3310', + }), + ), + ).not.toThrow(); + }); + + it('한 파일 한도는 일반 첨부 한도 이상 · 보관량은 한 파일을 담을 수 있어야', () => { + expect(() => + validateEnv( + dev({ + MAIL_ATTACHMENT_MAX_SIZE_MB: '30', + MAIL_LARGE_FILE_MAX_SIZE_MB: '25', + }), + ), + ).toThrow(/MAIL_LARGE_FILE_MAX_SIZE_MB\(25\)는 일반 첨부 한도/); + expect(() => + validateEnv(dev({ MAIL_LARGE_FILE_USER_QUOTA_GB: '1' })), + ).toThrow(/MAIL_LARGE_FILE_USER_QUOTA_GB\(1GB\)가 한 파일 한도/); + }); + + it('공개 주소는 출처만 — 경로 · 끝 / · 스킴이 아닌 값은 실패', () => { + for (const value of [ + 'https://files.arasystem.it.kr/', + 'https://files.arasystem.it.kr/s', + 'files.arasystem.it.kr', + 'ftp://files.arasystem.it.kr', + ]) { + expect(() => validateEnv(dev({ SHARE_PUBLIC_URL: value }))).toThrow( + /SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처/, + ); + } + expect(() => + validateEnv(dev({ SHARE_PUBLIC_URL: 'http://localhost:5173' })), + ).not.toThrow(); + }); + + it('공개 주소는 앞단 nginx 가 받는 모양만 — nginx(frontend/nginx-env.envsh 의 nginx_env_origin_host)가 기동을 멈추는 값이면 백엔드도 멈춘다(반쪽 배포 막기)', () => { + for (const value of [ + // 끝 점 — nginx 의 $host 는 끝 점을 떼어 백엔드 판정과 어긋난다 + 'https://files.arasystem.it.kr.', + 'https://files.arasystem.it.kr..', + // 점으로 시작 · 연이은 점 · 하이픈으로 시작하거나 끝나는 이름 + 'https://.files.arasystem.it.kr', + 'https://files..arasystem.it.kr', + 'https://-files.arasystem.it.kr', + 'https://files.arasystem.it.kr-', + 'https://.', + 'https://-', + // 0 으로 시작하는 포트 · 6자리 이상 포트 · 빈 포트 + 'https://files.arasystem.it.kr:0', + 'https://files.arasystem.it.kr:0443', + 'https://files.arasystem.it.kr:08443', + 'https://files.arasystem.it.kr:100000', + 'https://files.arasystem.it.kr:000443', + 'https://files.arasystem.it.kr:', + // 사용자 정보 · 대괄호(IPv6) + 'https://user@files.arasystem.it.kr', + 'https://@files.arasystem.it.kr', + 'https://[2001:db8::1]', + 'https://[2001:db8::1]:8443', + // 경로 · 끝 / + 'https://files.arasystem.it.kr/', + 'https://files.arasystem.it.kr/s', + // nginx 는 스킴을 글자 그대로 본다 · 영문 · 숫자 · . - : 밖의 글자 · 다듬지 않은 공백 + 'HTTPS://files.arasystem.it.kr', + 'Https://files.arasystem.it.kr', + 'https://files_1.arasystem.it.kr', + 'https://files!.arasystem.it.kr', + ' https://files.arasystem.it.kr', + 'https://files.arasystem.it.kr ', + ' ', + ]) { + expect(() => validateEnv(dev({ SHARE_PUBLIC_URL: value }))).toThrow( + /SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처/, + ); + } + // nginx 와 백엔드가 함께 받는 모양 — 포트 · 대문자 호스트 · IPv4 · 이름 가운데의 하이픈 + for (const value of [ + 'https://files.arasystem.it.kr:8443', + 'https://FILES.arasystem.it.kr', + 'http://127.0.0.1:8080', + 'https://files-1.arasystem.it.kr', + 'http://localhost:5173', + ]) { + expect(() => + validateEnv(dev({ SHARE_PUBLIC_URL: value })), + ).not.toThrow(); + } + }); + + it('신고 주소는 메일 주소 · 검사기 주소는 호스트 이름만', () => { + expect(() => + validateEnv(dev({ SHARE_REPORT_ADDRESS: 'postmaster' })), + ).toThrow(/SHARE_REPORT_ADDRESS 는 메일 주소/); + expect(() => + validateEnv(dev({ CLAMAV_HOST: 'tcp://clamav:3310' })), + ).toThrow(/CLAMAV_HOST 는 악성 검사기/); + expect(() => + validateEnv( + dev({ + SHARE_REPORT_ADDRESS: 'postmaster@arasystem.it.kr', + CLAMAV_HOST: 'clamav', + }), + ), + ).not.toThrow(); + }); + }); + describe('운영 환경', () => { it('정상 설정이면 통과한다', () => { expect(() => validateEnv(prod())).not.toThrow(); }); + it('대용량 첨부의 공개 주소 · 검사기가 없으면 실패한다 (11단계)', () => { + expect(() => validateEnv(omit(prod(), 'SHARE_PUBLIC_URL'))).toThrow( + /SHARE_PUBLIC_URL 가 설정되지 않았습니다/, + ); + expect(() => validateEnv(omit(prod(), 'CLAMAV_HOST'))).toThrow( + /CLAMAV_HOST 가 설정되지 않았습니다/, + ); + }); + + it('운영의 공개 주소는 https · 로컬 주소 · 예시 도메인 금지 (ALLOW_LOCALHOST_URL 이면 건너뛴다)', () => { + expect(() => + validateEnv(prod({ SHARE_PUBLIC_URL: 'http://files.arasystem.it.kr' })), + ).toThrow(/SHARE_PUBLIC_URL 은 운영에서 https/); + expect(() => + validateEnv(prod({ SHARE_PUBLIC_URL: 'https://files.example.com' })), + ).toThrow(/SHARE_PUBLIC_URL 가 운영에서 쓸 수 없는 주소입니다/); + expect(() => + validateEnv( + prod({ + SHARE_PUBLIC_URL: 'http://localhost:5173', + ALLOW_LOCALHOST_URL: 'true', + }), + ), + ).not.toThrow(); + }); + + it('운영의 공개 주소는 포털(CORS_ORIGIN)과 다른 호스트여야 한다 — 포트만 다르거나 대소문자 · 끝의 점만 달라도 같은 호스트다', () => { + for (const [share, portal] of [ + ['https://mail.arasystem.it.kr', 'https://mail.arasystem.it.kr'], + ['https://mail.arasystem.it.kr:8443', 'https://mail.arasystem.it.kr'], + [ + 'https://files.arasystem.it.kr', + 'https://portal.arasystem.it.kr,https://FILES.arasystem.it.kr.', + ], + ]) { + expect(() => + validateEnv(prod({ SHARE_PUBLIC_URL: share, CORS_ORIGIN: portal })), + ).toThrow( + /SHARE_PUBLIC_URL 의 호스트\(.+\)가 CORS_ORIGIN\(포털\)의 호스트와 같습니다/, + ); + } + // 로컬에서 운영 이미지를 검증할 때도 이름을 나눈다 — localhost 와 127.0.0.1 은 다른 호스트 + expect(() => + validateEnv( + prod({ + SHARE_PUBLIC_URL: 'http://127.0.0.1:8080', + CORS_ORIGIN: 'http://localhost:8080', + BACKEND_API_URL: 'http://localhost:8080/api', + ALLOW_LOCALHOST_URL: 'true', + }), + ), + ).not.toThrow(); + // 비운영(개발)은 같은 주소를 쓴다 — 막지 않는다 + expect(() => + validateEnv({ + NODE_ENV: 'development', + SHARE_PUBLIC_URL: 'http://localhost:5173', + CORS_ORIGIN: 'http://localhost:5173', + }), + ).not.toThrow(); + }); + it('필수 DB 설정이 없으면 실패한다', () => { expect(() => validateEnv(omit(prod(), 'DB_PASSWORD'))).toThrow( /DB_PASSWORD 가 설정되지 않았습니다/, diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index 4a14a7c..2be44f1 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -14,14 +14,27 @@ import { REFRESH_TTL_MAX_SECONDS, } from '@/modules/auth/auth.constants'; import { isLocalPart } from '@/modules/auth/employee-address'; +import { SHARE_DOWNLOAD_LIMIT_MAX } from '@/modules/file-sharing/file-sharing.settings'; import { + DEFAULT_LARGE_FILE_MAX_SIZE_MB, + DEFAULT_LARGE_FILE_USER_QUOTA_GB, + LARGE_FILE_MAX_COUNT_LIMIT, + LARGE_FILE_MAX_SIZE_MB_LIMIT, + LARGE_FILE_RETENTION_DAYS_LIMIT, + LARGE_FILE_USER_QUOTA_GB_LIMIT, +} from '@/modules/file-storage/file-storage.settings'; +import { + DEFAULT_ATTACHMENT_MAX_SIZE_MB, DEFAULT_MAIL_ADMIN_LOCAL_PART, DEFAULT_MAIL_DIRECTORY_LOCAL_PART, parseAccountList, } from '@/modules/mail/mail.settings'; import { parseDurationSeconds } from '@/shared/utils/duration'; import { isPositiveIntString } from '@/shared/utils/env'; -import { parseOriginList } from '@/shared/utils/request-origin'; +import { + normalizeOrigin, + parseOriginList, +} from '@/shared/utils/request-origin'; import { parseTrustProxy, unsafeTrustProxyReason, @@ -49,6 +62,10 @@ const REQUIRED_IN_PRODUCTION = [ 'CORS_ORIGIN', // 백엔드 앞의 프록시 — 클라이언트 IP(로그인 실패 한도·요청 수 한도의 기준)를 정한다 'TRUST_PROXY', + // 대용량 첨부(11단계)의 공개 내려받기 주소 — 덩이 · 링크 · 덩이 알아보기 · 공개 호스트 막기 (계약 4절 "공개 호스트") + 'SHARE_PUBLIC_URL', + // 대용량 첨부의 악성 검사기(clamd) — 운영은 검사 없이 받을 수 있게 되는 일이 없게 (계약 4절 "악성 검사") + 'CLAMAV_HOST', ] as const; /** 값이 있다면 양의 정수(앞자리 0 없이)여야 하는 키 — shared/utils/env.ts 의 isPositiveIntString */ @@ -61,6 +78,15 @@ const POSITIVE_INT_KEYS = [ 'UPLOAD_MAX_SIZE_MB', 'MAIL_ATTACHMENT_MAX_SIZE_MB', 'MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB', + // 대용량 첨부(11단계 — 계약 4절 "환경 변수") — 범위는 validateLargeFileSettings + 'SHARE_DOWNLOAD_LIMIT', + 'MAIL_LARGE_FILE_MAX_SIZE_MB', + 'MAIL_LARGE_FILE_MAX_COUNT', + 'MAIL_LARGE_FILE_USER_QUOTA_GB', + 'MAIL_LARGE_FILE_RETENTION_DAYS', + 'FILE_STORAGE_MAX_TOTAL_GB', + 'FILE_STORAGE_MIN_FREE_GB', + 'CLAMAV_PORT', ] as const; /** @@ -102,7 +128,12 @@ const JWT_SECRET_MIN_LENGTH_IN_PRODUCTION = 32; * 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만 * 브라우저가 API 에 닿지 못하거나 CORS 화이트리스트가 실제 도메인을 막아 **조용히** 실패한다. */ -const PUBLIC_URL_IN_PRODUCTION = ['CORS_ORIGIN', 'BACKEND_API_URL'] as const; +const PUBLIC_URL_IN_PRODUCTION = [ + 'CORS_ORIGIN', + 'BACKEND_API_URL', + // 대용량 첨부의 공개 내려받기 주소 — 메일 본문의 링크가 된다 (11단계) + 'SHARE_PUBLIC_URL', +] as const; /** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */ const NON_PUBLIC_URL_PATTERNS = [ @@ -255,6 +286,171 @@ function validateAttachmentLimits( } } +/** 숫자 env 의 범위 — 형식(양의 정수)은 위에서 이미 알렸다. 값이 있을 때만 */ +const LARGE_FILE_RANGES: readonly [key: string, max: number][] = [ + ['SHARE_DOWNLOAD_LIMIT', SHARE_DOWNLOAD_LIMIT_MAX], + ['MAIL_LARGE_FILE_MAX_SIZE_MB', LARGE_FILE_MAX_SIZE_MB_LIMIT], + ['MAIL_LARGE_FILE_MAX_COUNT', LARGE_FILE_MAX_COUNT_LIMIT], + ['MAIL_LARGE_FILE_USER_QUOTA_GB', LARGE_FILE_USER_QUOTA_GB_LIMIT], + ['MAIL_LARGE_FILE_RETENTION_DAYS', LARGE_FILE_RETENTION_DAYS_LIMIT], + ['CLAMAV_PORT', 65_535], +]; + +/** 견줄 호스트 이름 — 출처의 호스트를 소문자로, 끝의 점을 떼고 (포트는 보지 않는다 — 앞단 nginx 는 포트 없는 Host 를 넘긴다) */ +function comparableHostname(origin: string): string { + return new URL(origin).hostname.toLowerCase().replace(/\.+$/, ''); +} + +/** 해석한 양의 정수 — 형식이 틀렸거나 비었으면 null (형식 오류는 따로 알린다) */ +function readPositiveInt( + config: Record, + key: string, +): number | null { + const value = readString(config, key); + return value !== undefined && isPositiveIntString(value) + ? Number(value) + : null; +} + +/** 호스트 이름 · IPv4 (스킴 · 포트 · 경로 없이) — 악성 검사기 주소 */ +const HOST_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/; + +/** nginx-env.envsh 가 받는 출처의 스킴 — 글자 그대로(대문자 없이) */ +const NGINX_ORIGIN_SCHEMES = ['http://', 'https://'] as const; +/** 스킴 뒤(호스트[:포트])에 쓸 수 있는 글자 — 경로(/) · 쿼리 · 공백 · 대괄호(IPv6) · 사용자 정보(@) · `_` 는 받지 않는다 */ +const NGINX_ORIGIN_HOST_PORT = /^[A-Za-z0-9.:-]+$/; +/** 포트 — 0 으로 시작하지 않는 숫자 5자리까지 */ +const NGINX_ORIGIN_PORT = /^[1-9][0-9]{0,4}$/; +/** 호스트 이름의 틀린 모양 — 점 · 하이픈으로 시작하거나 끝남(끝 점 포함) · 연이은 점 */ +const NGINX_ORIGIN_BAD_HOST = /^[.-]|[.-]$|\.\./; + +/** + * 앞단 nginx 가 받는 출처인가 — frontend/nginx-env.envsh 의 nginx_env_origin_host 와 **짝**이다(한쪽을 고치면 둘 다). + * nginx 는 공개 내려받기 호스트를 server_name 에 그대로 넣으려고 SHARE_PUBLIC_URL 을 다듬지 않고 셸의 case 로 보고, 틀리면 기동을 멈춘다. + * 백엔드가 그 값을 받으면 백엔드만 뜨고 화면은 뜨지 않는 반쪽 배포가 되므로 같은 값을 거절한다 (NGINX_SHARED_INT_KEYS 와 같은 원칙): + * - 스킴은 글자 그대로 `http://` · `https://` + * - 나머지는 영문 · 숫자 · `.` · `-` · `:` 만 + * - 첫 `:` 뒤의 포트는 비지 않고 0 으로 시작하지 않는 숫자 5자리까지 (65535 를 넘는 값은 URL 해석이 따로 막는다) + * - 호스트는 비지 않고 점 · 하이픈으로 시작하거나 끝나지 않으며 점이 이어지지 않는다 — 끝 점은 nginx 의 $host 가 떼어 백엔드 판정과 어긋난다 + */ +function isNginxReadableOrigin(value: string): boolean { + const scheme = NGINX_ORIGIN_SCHEMES.find((prefix) => + value.startsWith(prefix), + ); + if (scheme === undefined) return false; + const hostPort = value.slice(scheme.length); + if (!NGINX_ORIGIN_HOST_PORT.test(hostPort)) return false; + const colon = hostPort.indexOf(':'); + const host = colon < 0 ? hostPort : hostPort.slice(0, colon); + if (colon >= 0 && !NGINX_ORIGIN_PORT.test(hostPort.slice(colon + 1))) { + return false; + } + return host !== '' && !NGINX_ORIGIN_BAD_HOST.test(host); +} + +/** + * 대용량 첨부 설정 (11단계 — 계약 4절 "환경 변수") — 값이 있을 때 범위 · 형식을 본다(모든 환경). + * - 한 파일 ≤ 2,047MB(검사기가 검사할 수 있는 2GiB 아래) · 일반 첨부 한도 이상, 한 통 ≤ 20, 보관량 ≤ 1,000GB 이고 한 파일을 담을 수 있어야, 보관 ≤ 90일, 내려받기 ≤ 100,000회 + * - 공개 주소는 출처만(스킴 · 호스트 · 포트 — 경로 · 끝 / 없음)이고 앞단 nginx 가 받는 모양이어야(isNginxReadableOrigin — 모든 환경), + * 운영은 https (로컬 · 예시 도메인은 아래 공개 주소 검사 — ALLOW_LOCALHOST_URL 이면 둘 다 건너뛴다) + * - 신고 주소는 메일 주소, 검사기 주소는 호스트 이름만 + */ +function validateLargeFileSettings( + config: Record, + isProduction: boolean, + errors: string[], +): void { + for (const [key, max] of LARGE_FILE_RANGES) { + const value = readPositiveInt(config, key); + if (value !== null && value > max) { + errors.push( + `${key} 는 ${max.toLocaleString('en-US')} 이하여야 합니다. (현재 값: "${readString(config, key)}")`, + ); + } + } + + const configuredLargeFileMb = readPositiveInt( + config, + 'MAIL_LARGE_FILE_MAX_SIZE_MB', + ); + const largeFileMb = configuredLargeFileMb ?? DEFAULT_LARGE_FILE_MAX_SIZE_MB; + const attachmentMb = + readPositiveInt(config, 'MAIL_ATTACHMENT_MAX_SIZE_MB') ?? + DEFAULT_ATTACHMENT_MAX_SIZE_MB; + // 한 파일 한도를 정했을 때만 견준다 (값이 있을 때만 보는 다른 검사와 같다) + if (configuredLargeFileMb !== null && largeFileMb < attachmentMb) { + errors.push( + `MAIL_LARGE_FILE_MAX_SIZE_MB(${largeFileMb})는 일반 첨부 한도 MAIL_ATTACHMENT_MAX_SIZE_MB(${attachmentMb})보다 작을 수 없습니다 — 일반 첨부보다 큰 파일을 링크로 보낸다.`, + ); + } + const quotaGb = + readPositiveInt(config, 'MAIL_LARGE_FILE_USER_QUOTA_GB') ?? + DEFAULT_LARGE_FILE_USER_QUOTA_GB; + if (quotaGb * 1024 < largeFileMb) { + errors.push( + `MAIL_LARGE_FILE_USER_QUOTA_GB(${quotaGb}GB)가 한 파일 한도 MAIL_LARGE_FILE_MAX_SIZE_MB(${largeFileMb}MB)보다 작습니다 — 한도까지 큰 파일 하나를 올릴 수 없게 됩니다.`, + ); + } + + // 앞단 nginx 는 이 값을 다듬지 않고 읽는다(비었을 때만 "없음") — 앞뒤 공백도 같은 값으로 본다 + const rawPublicUrl = config.SHARE_PUBLIC_URL; + const publicUrl = + typeof rawPublicUrl === 'string' && rawPublicUrl !== '' + ? rawPublicUrl + : undefined; + if (publicUrl !== undefined) { + const origin = normalizeOrigin(publicUrl); + if ( + origin === null || + origin !== publicUrl.toLowerCase() || + !isNginxReadableOrigin(publicUrl) + ) { + errors.push( + `SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처만 씁니다 — http(s)://호스트[:포트]. 앞단 nginx(frontend/nginx-env.envsh)와 같은 규칙이라 틀리면 화면이 뜨지 않습니다:` + + ` 스킴은 소문자, 호스트는 영문 · 숫자 · '.' · '-' 만(점 · 하이픈으로 시작하거나 끝나지 않고 점이 이어지지 않게 — 끝 점 없이), 포트는 0 으로 시작하지 않는 숫자,` + + ` 경로 · 끝의 / · 사용자 정보(@) · 대괄호(IPv6) · 앞뒤 공백 없이. (예: https://files.arasystem.it.kr, 현재 값: "${publicUrl}")`, + ); + } else if (isProduction) { + if ( + !origin.startsWith('https://') && + readString(config, ALLOW_LOCALHOST_URL_KEY) !== 'true' + ) { + errors.push( + `SHARE_PUBLIC_URL 은 운영에서 https 여야 합니다 — 받는 사람이 내려받는 주소다. (현재 값: "${publicUrl}")`, + ); + } + // 공개 호스트는 포털과 따로 둔다 — 호스트 이름이 같으면(포트만 달라도) 백엔드가 둘을 가르지 못해 공개 호스트 막기가 소리 없이 꺼진다 + // (file-sharing.settings.ts 의 dedicatedPublicHost · 계약 4절 "공개 호스트") + const shareHost = comparableHostname(origin); + const { origins: portalOrigins } = parseOriginList( + readString(config, 'CORS_ORIGIN'), + ); + if ( + portalOrigins.some((portal) => comparableHostname(portal) === shareHost) + ) { + errors.push( + `SHARE_PUBLIC_URL 의 호스트(${shareHost})가 CORS_ORIGIN(포털)의 호스트와 같습니다 — 운영에서는 공개 내려받기 호스트를 포털과 따로 둡니다(같으면 공개 호스트 막기가 꺼진다).` + + ` 로컬에서 운영 이미지를 검증할 때도 서로 다른 이름(예: localhost 와 127.0.0.1)을 씁니다.`, + ); + } + } + } + + const reportAddress = readString(config, 'SHARE_REPORT_ADDRESS'); + if (reportAddress !== undefined && !isAccountAddress(reportAddress)) { + errors.push( + `SHARE_REPORT_ADDRESS 는 메일 주소여야 합니다. (예: postmaster@arasystem.it.kr, 현재 값: "${reportAddress}")`, + ); + } + + const clamavHost = readString(config, 'CLAMAV_HOST'); + if (clamavHost !== undefined && !HOST_PATTERN.test(clamavHost)) { + errors.push( + `CLAMAV_HOST 는 악성 검사기(clamd)의 호스트 이름만 씁니다 — 스킴 · 포트 없이(포트는 CLAMAV_PORT). (예: clamav, 현재 값: "${clamavHost}")`, + ); + } +} + /** 도메인 이름 형식 (예: arasystem.it.kr, arasystem.test) — '@' 나 스킴이 섞이면 거부한다 */ const DOMAIN_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i; @@ -421,6 +617,9 @@ export function validateEnv( validateTrustProxy(config, isProduction, errors); validateOrigins(config, errors); + // 1-5. 대용량 첨부 (11단계) + validateLargeFileSettings(config, isProduction, errors); + if (isProduction) { // 2. 운영 필수값 — 없으면 기동 실패 for (const key of REQUIRED_IN_PRODUCTION) { diff --git a/backend/src/shared/utils/bigint-column.spec.ts b/backend/src/shared/utils/bigint-column.spec.ts new file mode 100644 index 0000000..f58a617 --- /dev/null +++ b/backend/src/shared/utils/bigint-column.spec.ts @@ -0,0 +1,9 @@ +import { BIGINT_NUMBER } from './bigint-column'; + +describe('BIGINT_NUMBER (bigint 열 ↔ 숫자)', () => { + it('DB 의 문자열을 숫자로 — null 은 그대로', () => { + expect(BIGINT_NUMBER.from('2146435072')).toBe(2_146_435_072); + expect(BIGINT_NUMBER.from(null)).toBeNull(); + expect(BIGINT_NUMBER.to(10)).toBe(10); + }); +}); diff --git a/backend/src/shared/utils/bigint-column.ts b/backend/src/shared/utils/bigint-column.ts new file mode 100644 index 0000000..ca336b1 --- /dev/null +++ b/backend/src/shared/utils/bigint-column.ts @@ -0,0 +1,11 @@ +import type { ValueTransformer } from 'typeorm'; + +/** + * PostgreSQL `bigint` 열 ↔ JS 숫자 (TypeORM 열의 transformer) — pg 드라이버는 bigint 를 문자열로 돌려준다 + * (2^53 을 넘을 수 있어서). 크기 · 횟수처럼 안전한 정수 범위 안의 값에만 쓴다 — 그대로 두면 `'10' > '9'` 같은 문자열 비교가 된다. + */ +export const BIGINT_NUMBER: ValueTransformer = { + to: (value: number | null | undefined) => value, + from: (value: string | number | null) => + value === null || value === undefined ? null : Number(value), +}; diff --git a/backend/src/shared/utils/file-name.spec.ts b/backend/src/shared/utils/file-name.spec.ts new file mode 100644 index 0000000..d4fb8b4 --- /dev/null +++ b/backend/src/shared/utils/file-name.spec.ts @@ -0,0 +1,46 @@ +import { + FALLBACK_FILE_NAME, + MAX_FILE_NAME_LENGTH, + fileExtension, + isControlOrBidiChar, + sanitizeFileName, +} from './file-name'; + +describe('sanitizeFileName', () => { + it('경로를 버리고 파일명만 남긴다 (경로 탈출 방지)', () => { + expect(sanitizeFileName('../../etc/passwd')).toBe('passwd'); + expect(sanitizeFileName('C:\\Users\\kim\\보고서.pdf')).toBe('보고서.pdf'); + }); + + it('제어 문자와 방향 제어 문자를 지운다 (확장자 뒤집기 위장 방지)', () => { + expect(sanitizeFileName('a\r\nb.txt')).toBe('ab.txt'); + expect(sanitizeFileName('invoice\u202Etxt.exe')).toBe('invoicetxt.exe'); + }); + + it('너무 긴 이름은 확장자를 남기고 자른다', () => { + const long = `${'가'.repeat(300)}.xlsx`; + const result = sanitizeFileName(long); + + expect(Array.from(result)).toHaveLength(MAX_FILE_NAME_LENGTH); + expect(result.endsWith('.xlsx')).toBe(true); + }); + + it('남는 것이 없으면 기본 이름을 쓴다', () => { + expect(sanitizeFileName('')).toBe(FALLBACK_FILE_NAME); + expect(sanitizeFileName('dir/')).toBe(FALLBACK_FILE_NAME); + }); +}); + +describe('fileExtension · isControlOrBidiChar', () => { + it('확장자는 소문자 · 점 포함, 없으면 빈 문자열', () => { + expect(fileExtension('보고서.PDF')).toBe('.pdf'); + expect(fileExtension('archive.tar.7Z')).toBe('.7z'); + expect(fileExtension('README')).toBe(''); + }); + + it('제어 문자 · 방향 제어 문자를 알아본다', () => { + expect(isControlOrBidiChar(0x0a)).toBe(true); + expect(isControlOrBidiChar(0x202e)).toBe(true); + expect(isControlOrBidiChar('가'.codePointAt(0) ?? 0)).toBe(false); + }); +}); diff --git a/backend/src/shared/utils/file-name.ts b/backend/src/shared/utils/file-name.ts new file mode 100644 index 0000000..33de3a4 --- /dev/null +++ b/backend/src/shared/utils/file-name.ts @@ -0,0 +1,56 @@ +import { extname } from 'path'; + +/** + * 파일 이름 정리 (순수 함수) — 메일 첨부와 대용량 첨부(11단계 — modules/file-storage)가 함께 쓴다. + * 한 곳에 두려고 메일 모듈(attachment.policy.ts)에서 옮겨 왔다 — 파일 모듈이 메일 모듈을 가져오지 않게 (메일 → 공유 → 저장 한 방향). + */ + +/** 파일 이름 최대 길이(코드 포인트) — 메일 첨부 · 대용량 첨부가 같다 (계약 2절 · 4절) */ +export const MAX_FILE_NAME_LENGTH = 255; + +/** 정리 후 이름이 비면 쓰는 이름 */ +export const FALLBACK_FILE_NAME = '첨부파일'; + +/** C0·C1 제어 문자, DEL, 방향 제어 문자 */ +export function isControlOrBidiChar(codePoint: number): boolean { + return ( + codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) || + codePoint === 0x200e || + codePoint === 0x200f || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069) + ); +} + +/** + * 화면 표시·헤더용으로 파일명을 정리한다. + * - 경로 구분자 앞부분을 버린다 (클라이언트가 보낸 경로를 쓰지 않는다) + * - 제어 문자와 방향 제어 문자(확장자를 거꾸로 보이게 하는 U+202E 등)를 지운다 + * - 길이를 제한하되 확장자는 남긴다 (코드 포인트 단위 — 서로게이트 쌍을 반쪽으로 자르지 않는다) + */ +export function sanitizeFileName(rawName: string): string { + const lastSegment = rawName.split(/[\\/]/).pop() ?? ''; + const cleaned = Array.from(lastSegment) + .filter((char) => !isControlOrBidiChar(char.codePointAt(0) ?? 0)) + .join('') + .trim(); + + const chars = Array.from(cleaned); + if (chars.length === 0) return FALLBACK_FILE_NAME; + if (chars.length <= MAX_FILE_NAME_LENGTH) return cleaned; + + const extensionChars = Array.from(extname(cleaned)); + if (extensionChars.length >= MAX_FILE_NAME_LENGTH) { + return chars.slice(0, MAX_FILE_NAME_LENGTH).join(''); + } + return ( + chars.slice(0, MAX_FILE_NAME_LENGTH - extensionChars.length).join('') + + extensionChars.join('') + ); +} + +/** 이름의 확장자 — 소문자, 점 포함 (`보고서.PDF` → `.pdf`). 없으면 빈 문자열 */ +export function fileExtension(fileName: string): string { + return extname(fileName).toLowerCase(); +} diff --git a/backend/src/shared/utils/format.spec.ts b/backend/src/shared/utils/format.spec.ts index fb69fae..0efc2b3 100644 --- a/backend/src/shared/utils/format.spec.ts +++ b/backend/src/shared/utils/format.spec.ts @@ -1,4 +1,4 @@ -import { toWholeSecondIso } from './format'; +import { formatFileSize, toWholeSecondIso } from './format'; describe('toWholeSecondIso', () => { it('소수 초를 버리고 UTC ISO 문자열로 돌려준다 — 올리지 않는다', () => { @@ -26,3 +26,32 @@ describe('toWholeSecondIso', () => { expect(toWholeSecondIso('내일')).toBe('내일'); }); }); + +describe('formatFileSize — frontend shared/utils/format.ts 와 같은 표본', () => { + const GB = 1024 * 1024 * 1024; + + it.each([ + [0, '0 B'], + [512, '512 B'], + [48 * 1024, '48 KB'], + [312 * 1024, '312 KB'], + [1024 * 1024 - 100, '1 MB'], + [1.5 * 1024 * 1024, '1.5 MB'], + [38 * 1024 * 1024, '38 MB'], + [5 * 1024 * 1024 * 1024, '5 GB'], + ])('%d 바이트 → %s', (bytes, expected) => { + expect(formatFileSize(bytes)).toBe(expected); + }); + + it('음수·숫자가 아닌 값은 빈 문자열', () => { + expect(formatFileSize(-1)).toBe(''); + expect(formatFileSize(Number.NaN)).toBe(''); + }); + + it('TB 까지 줄인다 · 대용량 첨부의 흔한 크기', () => { + expect(formatFileSize(2 * 1024 * GB)).toBe('2 TB'); + expect(formatFileSize(1.2 * GB)).toBe('1.2 GB'); + expect(formatFileSize(612 * 1024 * 1024)).toBe('612 MB'); + expect(formatFileSize(2047 * 1024 * 1024)).toBe('2 GB'); + }); +}); diff --git a/backend/src/shared/utils/format.ts b/backend/src/shared/utils/format.ts index d7e2d07..2531409 100644 --- a/backend/src/shared/utils/format.ts +++ b/backend/src/shared/utils/format.ts @@ -25,3 +25,32 @@ export function toWholeSecondIso(iso: string): string { if (Number.isNaN(time)) return iso; return new Date(Math.floor(time / SECOND_MS) * SECOND_MS).toISOString(); } + +const BYTES_PER_UNIT = 1024; +const FILE_SIZE_UNITS = ['KB', 'MB', 'GB', 'TB'] as const; +/** 이 값보다 작으면 소수 한 자리까지 보인다 (예: 1.5 MB) */ +const DECIMAL_THRESHOLD = 10; + +/** + * 파일 크기 표기 — 1KB 미만은 바이트 그대로, 그 위는 KB · MB · GB · TB (1024 단위). KB 는 정수, MB 이상은 10 미만일 때 소수 한 자리. + * 대용량 첨부 덩이(11단계 — 메일 본문에 서버가 넣는 안내)가 쓴다 — 화면과 같은 표기다. + * ⚠️ 짝: frontend `src/shared/utils/format.ts` 의 `formatFileSize` — 같은 반올림 규칙 · 같은 표본(format.spec.ts). 한쪽을 고치면 둘 다 + */ +export function formatFileSize(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return ''; + if (bytes < BYTES_PER_UNIT) return `${bytes} B`; + + const lastUnitIndex = FILE_SIZE_UNITS.length - 1; + let value = bytes / BYTES_PER_UNIT; + let unitIndex = 0; + // 반올림 결과가 1024 가 되면("1024 KB") 다음 단위로 올린다 + while (Math.round(value) >= BYTES_PER_UNIT && unitIndex < lastUnitIndex) { + value /= BYTES_PER_UNIT; + unitIndex += 1; + } + const rounded = + unitIndex > 0 && value < DECIMAL_THRESHOLD + ? Math.round(value * 10) / 10 + : Math.round(value); + return `${rounded} ${FILE_SIZE_UNITS[unitIndex]}`; +} diff --git a/backend/src/shared/utils/http-range.spec.ts b/backend/src/shared/utils/http-range.spec.ts new file mode 100644 index 0000000..1993c20 --- /dev/null +++ b/backend/src/shared/utils/http-range.spec.ts @@ -0,0 +1,81 @@ +import { startCpuTimer } from '@/../test/cpu-time'; +import { decideRange, type RangeValidators } from './http-range'; + +const SIZE = 1_000; +const validators: RangeValidators = { + etag: '"11111111-2222-4333-8444-555555555555"', + lastModified: new Date('2026-10-30T03:00:00.000Z'), +}; +const decide = (range?: string, ifRange?: string, size = SIZE) => + decideRange(range, ifRange, size, validators); + +describe('decideRange — Range 판정 (RFC 9110)', () => { + it('범위가 없으면 전체', () => { + expect(decide()).toEqual({ kind: 'full' }); + }); + + it.each([ + ['bytes=0-99', 0, 99], + ['bytes=100-', 100, 999], + ['bytes=-100', 900, 999], + ['bytes=990-5000', 990, 999], + ['bytes=-5000', 0, 999], + ['BYTES=10-19', 10, 19], + [' bytes=0-0 ', 0, 0], + ['bytes=999-999', 999, 999], + ])('%s → 206 %d-%d', (range, start, end) => { + expect(decide(range)).toEqual({ kind: 'partial', start, end }); + }); + + it.each([ + 'bytes=0-9,20-29', + 'bytes=50-10', + 'bytes=-', + 'bytes=abc', + 'items=0-9', + 'bytes=0 - 9', + 'bytes=1.5-9', + 'bytes=', + ])('여럿 · 형식이 틀린 범위(%s)는 무시하고 전체', (range) => { + expect(decide(range)).toEqual({ kind: 'full' }); + }); + + it.each(['bytes=1000-', 'bytes=5000-6000', 'bytes=-0'])( + '처음이 크기 이상 · 끝 0 바이트(%s)는 416', + (range) => { + expect(decide(range)).toEqual({ kind: 'unsatisfiable' }); + }, + ); + + it('If-Range 가 ETag · Last-Modified 와 같을 때만 범위를 따른다 — 약한 ETag · 다른 날짜는 전체', () => { + expect(decide('bytes=10-19', validators.etag)).toEqual({ + kind: 'partial', + start: 10, + end: 19, + }); + expect(decide('bytes=10-19', 'Fri, 30 Oct 2026 03:00:00 GMT')).toEqual({ + kind: 'partial', + start: 10, + end: 19, + }); + expect(decide('bytes=10-19', `W/${validators.etag}`)).toEqual({ + kind: 'full', + }); + expect(decide('bytes=10-19', '"other"')).toEqual({ kind: 'full' }); + expect(decide('bytes=10-19', 'Fri, 30 Oct 2026 03:00:01 GMT')).toEqual({ + kind: 'full', + }); + expect(decide('bytes=10-19', '어제')).toEqual({ kind: 'full' }); + }); + + it('너무 긴 머리글은 읽지 않는다 — 공백이 아주 많아도 입력 길이에 비례한다', () => { + const cpuMs = startCpuTimer(); + expect(decide(`bytes=${' '.repeat(100_000)}-`)).toEqual({ kind: 'full' }); + expect(decide(`bytes=0-${'1'.repeat(150)}`)).toEqual({ + kind: 'partial', + start: 0, + end: SIZE - 1, + }); + expect(cpuMs()).toBeLessThan(1_000); + }); +}); diff --git a/backend/src/shared/utils/http-range.ts b/backend/src/shared/utils/http-range.ts new file mode 100644 index 0000000..3e857bd --- /dev/null +++ b/backend/src/shared/utils/http-range.ts @@ -0,0 +1,106 @@ +/** + * HTTP 범위 요청(Range · If-Range) 판정 (순수 함수) — RFC 9110 14절. 대용량 첨부 내려받기(11단계 — 계약 4절 "파일 응답")가 쓴다. + * + * - 범위가 없으면 전체(200) + * - `bytes=` 범위 **하나**면 부분(206) — 끝이 크기를 넘으면 맞춰 자르고, `bytes=-N` 은 끝 N 바이트 + * - 범위가 여럿이거나 형식이 틀리면 범위를 무시하고 전체(200) — RFC 9110 이 허락한다 + * - 처음이 크기 이상이면(또는 끝 0 바이트) 받을 수 없음(416) + * - `If-Range` 가 강한 ETag · Last-Modified 와 다르면 전체(200) — 파일이 바뀌었을 수 있는 이어 받기를 막는 장치(우리 파일은 바뀌지 않지만 규격대로) + * + * 머리글은 클라이언트가 보낸 값이다 — 정규식은 입력 길이에 비례하게 쓰고(겹칠 수 있는 공백 반복 없이), 너무 긴 값은 읽지 않는다. + */ + +export type RangeDecision = + | { readonly kind: 'full' } + /** 끝 포함 (`bytes=0-99` 면 start 0 · end 99) */ + | { readonly kind: 'partial'; readonly start: number; readonly end: number } + | { readonly kind: 'unsatisfiable' }; + +/** 이어 받기의 검증자 — 응답의 ETag · Last-Modified 와 같은 값 */ +export interface RangeValidators { + /** 강한 ETag 그대로 (따옴표 포함 — `""`) */ + readonly etag: string; + readonly lastModified: Date; +} + +/** 이보다 긴 Range · If-Range 는 읽지 않는다(전체를 준다) — 브라우저 · 내려받기 관리자가 보내는 값은 수십 자다 */ +const MAX_HEADER_LENGTH = 200; + +const FULL: RangeDecision = { kind: 'full' }; +const UNSATISFIABLE: RangeDecision = { kind: 'unsatisfiable' }; + +/** 단위(bytes)는 대소문자를 가리지 않는다 */ +const BYTES_UNIT = /^bytes=/i; +/** 범위 하나 — 공백 없이 `처음-끝` · `처음-` · `-끝길이` (RFC 9110 의 int-range · suffix-range) */ +const RANGE_SPEC = /^(\d*)-(\d*)$/; + +/** If-Range 가 지금 파일을 가리키는가 — 강한 ETag 는 글자 그대로, 날짜는 초 단위로 같아야 한다 */ +function ifRangeMatches(value: string, validators: RangeValidators): boolean { + const trimmed = value.trim(); + if (trimmed.startsWith('"') || trimmed.startsWith('W/')) { + // 약한 ETag(W/)는 범위 요청에 쓰지 못한다 (RFC 9110 13.1.5) + return trimmed === validators.etag; + } + const date = Date.parse(trimmed); + if (Number.isNaN(date)) return false; + return ( + Math.floor(date / 1000) === + Math.floor(validators.lastModified.getTime() / 1000) + ); +} + +/** + * 응답할 범위를 정한다. + * @param size 파일 크기 (1 이상) + */ +export function decideRange( + rangeHeader: string | undefined, + ifRangeHeader: string | undefined, + size: number, + validators: RangeValidators, +): RangeDecision { + if (rangeHeader === undefined || rangeHeader.length > MAX_HEADER_LENGTH) { + return FULL; + } + if (ifRangeHeader !== undefined) { + if ( + ifRangeHeader.length > MAX_HEADER_LENGTH || + !ifRangeMatches(ifRangeHeader, validators) + ) { + return FULL; + } + } + + const trimmed = rangeHeader.trim(); + if (!BYTES_UNIT.test(trimmed)) return FULL; + const specs = trimmed + .slice('bytes='.length) + .split(',') + .map((spec) => spec.trim()) + .filter((spec) => spec !== ''); + // 여럿(multipart/byteranges)은 보내지 않는다 — 전체를 준다 + if (specs.length !== 1) return FULL; + + const match = RANGE_SPEC.exec(specs[0]); + if (!match) return FULL; + const [, first, last] = match; + if (first === '' && last === '') return FULL; + + if (first === '') { + // 끝 N 바이트 — 0 이면 받을 것이 없다 + const suffix = Number(last); + if (suffix === 0) return UNSATISFIABLE; + return { + kind: 'partial', + start: Math.max(0, size - suffix), + end: size - 1, + }; + } + + const start = Number(first); + const end = last === '' ? null : Number(last); + // 끝이 처음보다 앞이면 형식이 틀린 범위다 — 무시한다 (끝을 쓰지 않은 `처음-` 은 형식이 맞다) + if (end !== null && end < start) return FULL; + if (start >= size) return UNSATISFIABLE; + return { kind: 'partial', start, end: Math.min(end ?? size - 1, size - 1) }; +} diff --git a/backend/src/shared/utils/kst-date.spec.ts b/backend/src/shared/utils/kst-date.spec.ts new file mode 100644 index 0000000..2038431 --- /dev/null +++ b/backend/src/shared/utils/kst-date.spec.ts @@ -0,0 +1,48 @@ +import { + formatKstDate, + formatKstDateEnglish, + kstEndOfDayAfter, +} from './kst-date'; + +describe('kstEndOfDayAfter — 한국 날짜 + n일의 끝', () => { + it('계약 4절의 예 — 2026-10-30 에 보내면 2026-11-29T14:59:59.999Z (11월 29일 끝)', () => { + expect( + kstEndOfDayAfter(new Date('2026-10-30T03:00:00.000Z'), 30).toISOString(), + ).toBe('2026-11-29T14:59:59.999Z'); + }); + + it('UTC 로는 전날이어도 한국 날짜로 센다 — 한국 10월 31일 0시 5분은 31일이다', () => { + // UTC 10-30 15:05 = 한국 10-31 00:05 + expect( + kstEndOfDayAfter(new Date('2026-10-30T15:05:00.000Z'), 30).toISOString(), + ).toBe('2026-11-30T14:59:59.999Z'); + // UTC 10-30 14:59 = 한국 10-30 23:59 + expect( + kstEndOfDayAfter(new Date('2026-10-30T14:59:59.999Z'), 30).toISOString(), + ).toBe('2026-11-29T14:59:59.999Z'); + }); + + it('0일이면 그날의 끝 · 달 · 해를 넘긴다', () => { + expect( + kstEndOfDayAfter(new Date('2026-12-20T00:00:00.000Z'), 0).toISOString(), + ).toBe('2026-12-20T14:59:59.999Z'); + expect( + kstEndOfDayAfter(new Date('2026-12-20T00:00:00.000Z'), 30).toISOString(), + ).toBe('2027-01-19T14:59:59.999Z'); + }); +}); + +describe('formatKstDate · formatKstDateEnglish', () => { + it('한국 날짜와 요일 — 덩이의 "2026년 11월 29일(일)" · "Nov 29, 2026"', () => { + const end = new Date('2026-11-29T14:59:59.999Z'); + expect(formatKstDate(end)).toBe('2026년 11월 29일(일)'); + expect(formatKstDateEnglish(end)).toBe('Nov 29, 2026'); + }); + + it('UTC 날짜가 아니라 한국 날짜로 적는다', () => { + // UTC 11-29 16:00 = 한국 11-30(월) 01:00 + const at = new Date('2026-11-29T16:00:00.000Z'); + expect(formatKstDate(at)).toBe('2026년 11월 30일(월)'); + expect(formatKstDateEnglish(at)).toBe('Nov 30, 2026'); + }); +}); diff --git a/backend/src/shared/utils/kst-date.ts b/backend/src/shared/utils/kst-date.ts new file mode 100644 index 0000000..1f146a7 --- /dev/null +++ b/backend/src/shared/utils/kst-date.ts @@ -0,0 +1,63 @@ +/** + * 한국 날짜 계산 · 표기 (순수 함수) — 대용량 첨부(11단계)의 보관 끝 · 본문 덩이의 날짜가 쓴다 (계약 4절 "기간 · 보관 · 정리"). + * + * 한국 시간은 UTC+9 고정이다 — 일광 절약이 없어 시간대 자료(ICU)를 읽지 않고 더하기로 계산한다 (서버는 UTC 로 돈다). + */ + +/** 한국 시간과 UTC 의 차이 */ +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; + +const WEEKDAYS_KO = ['일', '월', '화', '수', '목', '금', '토'] as const; +const MONTHS_EN = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] as const; + +/** + * `at` 의 한국 날짜에 `days` 일을 더한 날의 끝(23:59:59.999 한국 시간)을 UTC 로. + * 예) 2026-10-30(한국) + 30일 → 2026-11-29 끝 → `2026-11-29T14:59:59.999Z` (계약 4절의 예) + */ +export function kstEndOfDayAfter(at: Date, days: number): Date { + const kstMidnight = + Math.floor((at.getTime() + KST_OFFSET_MS) / DAY_MS) * DAY_MS; + return new Date(kstMidnight + (days + 1) * DAY_MS - 1 - KST_OFFSET_MS); +} + +/** 한국 날짜의 연 · 월(1~12) · 일 · 요일(0=일) */ +function kstParts(at: Date): { + year: number; + month: number; + day: number; + weekday: number; +} { + const shifted = new Date(at.getTime() + KST_OFFSET_MS); + return { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth() + 1, + day: shifted.getUTCDate(), + weekday: shifted.getUTCDay(), + }; +} + +/** "2026년 11월 29일(일)" — 한국 날짜 */ +export function formatKstDate(at: Date): string { + const { year, month, day, weekday } = kstParts(at); + return `${year}년 ${month}월 ${day}일(${WEEKDAYS_KO[weekday]})`; +} + +/** "Nov 29, 2026" — 한국 날짜를 영어로 */ +export function formatKstDateEnglish(at: Date): string { + const { year, month, day } = kstParts(at); + return `${MONTHS_EN[month - 1]} ${day}, ${year}`; +} diff --git a/backend/src/shared/utils/mask.spec.ts b/backend/src/shared/utils/mask.spec.ts index 263ee52..4f2f4e7 100644 --- a/backend/src/shared/utils/mask.spec.ts +++ b/backend/src/shared/utils/mask.spec.ts @@ -83,6 +83,53 @@ describe('maskUrlForLog (로그에 남길 요청 주소)', () => { '/Api/Mail-Images/<6자>?q=<1자>', ); }); + it('공개 링크의 토큰(경로)은 길이만 남긴다 — 뒤의 파일 경로 · 쿼리는 그대로, 대소문자와 상관없이 (11단계)', () => { + const token = 'Ab_-9'.repeat(8) + 'xyz'; + expect(maskUrlForLog(`/api/public-links/${token}`)).toBe( + '/api/public-links/<43자>', + ); + expect( + maskUrlForLog( + `/api/public-links/${token}/files/11111111-2222-4333-8444-555555555555`, + ), + ).toBe( + '/api/public-links/<43자>/files/11111111-2222-4333-8444-555555555555', + ); + expect(maskUrlForLog(`/API/Public-Links/${token}?x=1`)).toBe( + '/API/Public-Links/<43자>?x=1', + ); + expect(maskUrlForLog('/api/public-linksx/abc')).toBe( + '/api/public-linksx/abc', + ); + // 다른 경로의 같은 모양은 그대로 (외부 이미지 중계의 표만 따로 가린다) + expect(maskUrlForLog(`/api/share-links/${token}`)).toBe( + `/api/share-links/${token}`, + ); + }); + + it('공개 링크 · 외부 이미지의 조각은 경로 어디서든 가린다 — 겹친 / · . · .. 조각이 끼어도 (nginx 를 거치지 않은 요청 — 2026-10-30 리뷰 L2)', () => { + const token = 'Ab_-9'.repeat(8) + 'xyz'; + const fileId = '11111111-2222-4333-8444-555555555555'; + for (const [raw, masked] of [ + [`//api/public-links/${token}`, '//api/public-links/<43자>'], + [`/api//public-links/${token}`, '/api//public-links/<43자>'], + [`/api/./public-links/${token}`, '/api/./public-links/<43자>'], + [`/api/public-links//${token}`, '/api/public-links//<43자>'], + [`/api/public-links/./${token}`, '/api/public-links/./<43자>'], + [ + `/api/x/../public-links/${token}/files/${fileId}?q=a`, + `/api/x/../public-links/<43자>/files/${fileId}?q=<1자>`, + ], + // 파일 자리에 토큰 같은 글이 와도 가린다 — files 와 UUID 만 남긴다 + [ + `/api/public-links/${token}/${token}`, + '/api/public-links/<43자>/<43자>', + ], + [`//api//mail-images/AbCdef`, '//api//mail-images/<6자>'], + ]) { + expect(maskUrlForLog(raw)).toBe(masked); + } + }); }); describe('maskEmail', () => { diff --git a/backend/src/shared/utils/mask.ts b/backend/src/shared/utils/mask.ts index 8179613..5aa1720 100644 --- a/backend/src/shared/utils/mask.ts +++ b/backend/src/shared/utils/mask.ts @@ -17,13 +17,48 @@ const MASK = '***'; */ const REDACTED_QUERY_NAME = /^(?:q|cursor)(?:\[[^\]]*\])*$/i; +/** UUID 모양의 경로 조각 — 공개 링크의 파일 id 는 남긴다 */ +const UUID_SEGMENT = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + /** - * 값을 남기지 않는 경로 조각 — 외부 이미지 중계의 표(`/api/mail-images/<표>` — 9단계). - * 표는 원래 주소(추적 번호가 든다)를 서버 비밀로 암호화한 글이라 풀리지는 않지만, 4,000자까지 길고 로그에 둘 까닭이 없다 — 길이만 남긴다 - * (계약 2절 "외부 이미지 보기"의 로그). 경로의 대소문자는 가리지 않는다 — Express 의 라우팅이 가리지 않아(`/API/MAIL-IMAGES/<표>` 도 처리기에 닿는다) - * 그 요청이 401 · 429 로 끝나면 전역 필터의 로그에 표가 남는다 (2026-10-29 리뷰). 앞부분은 들어온 모양 그대로 둔다 + * 값을 남기지 않는 경로 — 이 이름의 조각 **뒤의** 조각은 길이만 남긴다(keep 이 받아 주는 조각과 빈 조각 · `.` · `..` 은 그대로). + * 경로 어디에 있든 본다 — `//api/…` · `/api//…` · `/api/./…` 도(Express 는 겹친 `/` 를 합치지 않고, 앞단을 거치지 않은 요청 · 실패한 요청도 + * 전역 필터의 로그에 주소가 남는다 — 2026-10-30 리뷰 L2). 이름의 대소문자는 가리지 않는다 — Express 의 라우팅이 가리지 않아 + * (`/API/MAIL-IMAGES/<표>` 도 처리기에 닿는다) 그 요청이 401 · 429 로 끝나면 로그에 값이 남는다 (2026-10-29 리뷰). 앞부분은 들어온 모양 그대로 둔다 + * - 외부 이미지 중계의 표(`/api/mail-images/<표>` — 9단계): 원래 주소(추적 번호가 든다)를 서버 비밀로 암호화한 글이라 풀리지는 않지만, + * 4,000자까지 길고 로그에 둘 까닭이 없다 (계약 2절 "외부 이미지 보기"의 로그) + * - 대용량 첨부의 공개 링크 토큰(`/api/public-links/<토큰>…` — 11단계): 토큰을 가진 누구나 파일을 받는다 — 로그를 읽는 사람이 받지 못하게 + * (계약 4절 "로그"). 뒤의 `files` · 파일 id(UUID)는 그대로 둔다 */ -const REDACTED_PATH = /^(\/api\/mail-images\/)([^/?#]+)/i; +const REDACTED_PATH_SEGMENTS: ReadonlyMap< + string, + (segment: string) => boolean +> = new Map([ + ['mail-images', () => false], + [ + 'public-links', + (segment: string) => /^files$/i.test(segment) || UUID_SEGMENT.test(segment), + ], +]); + +/** 경로(쿼리 앞)를 가린다 — 가리는 이름의 조각을 처음 만난 뒤로 */ +function maskPath(path: string): string { + let keep: ((segment: string) => boolean) | null = null; + return path + .split('/') + .map((segment) => { + if (keep === null) { + keep = REDACTED_PATH_SEGMENTS.get(segment.toLowerCase()) ?? null; + return segment; + } + if (segment === '' || segment === '.' || segment === '..') { + return segment; + } + return keep(segment) ? segment : `<${segment.length}자>`; + }) + .join('/'); +} /** 쿼리 조각을 디코딩한다 (`+` 는 공백) — 잘못된 퍼센트 인코딩이면 원문 그대로 */ function decodeQueryPart(part: string): string { @@ -51,20 +86,18 @@ export function maskEmail(email: string): string { /** * 로그에 남길 요청 주소 — 검색어(q)와 커서(cursor)는 값 대신 길이만 남기고, 경로와 나머지 쿼리는 그대로 둔다. - * 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다). 외부 이미지 중계의 표(경로)도 길이만 (REDACTED_PATH). + * 길이는 디코딩한 값의 JS 문자열 길이다 (클라이언트가 세는 방법과 같다). 외부 이미지 중계의 표 · 공개 링크 토큰(경로 어디서든)도 길이만 (REDACTED_PATH_SEGMENTS). * * 예) '/api/recipient-suggestions?q=%EB%8F%84%EC%9C%A4&limit=20&cursor=eyJ2Ijox' → '/api/recipient-suggestions?q=<2자>&limit=20&cursor=<8자>' * '/api/mail-images/AbC…(120자)' → '/api/mail-images/<120자>' + * '/api/public-links/<토큰 43자>/files/' → '/api/public-links/<43자>/files/' */ export function maskUrlForLog(rawUrl: string): string { - const url = rawUrl.replace( - REDACTED_PATH, - (_whole, prefix: string, token: string) => `${prefix}<${token.length}자>`, - ); - const queryStart = url.indexOf('?'); - if (queryStart < 0) return url; + const queryStart = rawUrl.indexOf('?'); + const path = maskPath(queryStart < 0 ? rawUrl : rawUrl.slice(0, queryStart)); + if (queryStart < 0) return path; - const query = url + const query = rawUrl .slice(queryStart + 1) .split('&') .map((pair) => { @@ -76,5 +109,5 @@ export function maskUrlForLog(rawUrl: string): string { return `${name}=<${value.length}자>`; }) .join('&'); - return `${url.slice(0, queryStart)}?${query}`; + return `${path}?${query}`; } diff --git a/backend/src/shared/utils/stream-abort.spec.ts b/backend/src/shared/utils/stream-abort.spec.ts new file mode 100644 index 0000000..bfc4272 --- /dev/null +++ b/backend/src/shared/utils/stream-abort.spec.ts @@ -0,0 +1,29 @@ +import { isStreamAbortError } from './stream-abort'; + +const errorWithCode = (code: string) => + Object.assign(new Error('stream'), { code }); + +describe('isStreamAbortError', () => { + it.each([ + 'ECONNRESET', + 'ECONNABORTED', + 'EPIPE', + 'ERR_STREAM_PREMATURE_CLOSE', + ])('상대가 끊은 흐름(%s)이면 true', (code) => { + expect(isStreamAbortError(errorWithCode(code))).toBe(true); + }); + + it.each(['EIO', 'EACCES', 'ENOSPC', 'ENOENT'])( + '디스크 · 그 밖의 오류(%s)는 false — 서버 장애로 남긴다', + (code) => { + expect(isStreamAbortError(errorWithCode(code))).toBe(false); + }, + ); + + it('코드가 없거나 오류가 아니면 false', () => { + expect(isStreamAbortError(new Error('aborted'))).toBe(false); + expect(isStreamAbortError(null)).toBe(false); + expect(isStreamAbortError('ECONNRESET')).toBe(false); + expect(isStreamAbortError({ code: 104 })).toBe(false); + }); +}); diff --git a/backend/src/shared/utils/stream-abort.ts b/backend/src/shared/utils/stream-abort.ts new file mode 100644 index 0000000..02f23cb --- /dev/null +++ b/backend/src/shared/utils/stream-abort.ts @@ -0,0 +1,22 @@ +/** + * 상대가 연결을 끊어 흐름이 멈췄을 때의 오류인가 (순수 함수) — 요청 본문을 읽다(클라이언트가 올리기를 끊음) · 응답을 쓰다(받는 쪽이 내려받기를 끊음). + * 이런 오류는 서버 장애가 아니라 로그를 error 로 남기지 않는다. 디스크 · 그 밖의 오류(EIO · EACCES 등)는 여기에 들지 않는다 — 그대로 500 · error 로 남긴다. + * + * ⚠️ "스트림이 destroyed 인가"로 가르지 않는다 — `for await` · pipeline 은 어느 쪽이 실패해도 원본 스트림을 destroy 하므로, + * 디스크 오류까지 "끊김"으로 보게 된다 (2026-10-30 — 조각 올리기의 디스크 오류가 400 으로 숨을 뻔했다) + */ +const STREAM_ABORT_CODES: ReadonlySet = new Set([ + // 상대가 연결을 끊음 (Node 의 IncomingMessage 는 끝나기 전에 끊기면 "aborted" + ECONNRESET 으로 멈춘다) + 'ECONNRESET', + 'ECONNABORTED', + // 쓰는 쪽이 이미 닫힌 연결에 썼다 + 'EPIPE', + // 끝(end)이 오기 전에 닫혔다 — pipeline · finished 가 낸다 + 'ERR_STREAM_PREMATURE_CLOSE', +]); + +export function isStreamAbortError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const { code } = error as { code?: unknown }; + return typeof code === 'string' && STREAM_ABORT_CODES.has(code); +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 94b9451..cbe7c00 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -1,18 +1,19 @@ import type { NestExpressApplication } from '@nestjs/platform-express'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; -import { createHash, randomUUID } from 'crypto'; -import { existsSync, unlinkSync } from 'fs'; +import { createHash, randomBytes, randomUUID } from 'crypto'; +import { existsSync, mkdtempSync, rmSync, unlinkSync } from 'fs'; import { get as httpGet, type IncomingMessage, type Server } from 'http'; +import { tmpdir } from 'os'; import { basename, join } from 'path'; import { Readable } from 'stream'; import request from 'supertest'; -import type { Response } from 'supertest'; +import type { Response, Test as HttpTest } from 'supertest'; import { App } from 'supertest/types'; import { DataSource } from 'typeorm'; import { AppModule } from '@/app.module'; -import { configureApp } from '@/app.setup'; +import { configureApp, createHttpAdapter } from '@/app.setup'; import { BusinessException } from '@/common/errors/business.exception'; import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings'; import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants'; @@ -28,6 +29,18 @@ import { type DirectoryAccount, type RemovedCardOriginal, } from '@/modules/contacts/contacts.provider'; +import { FileSharingRepository } from '@/modules/file-sharing/file-sharing.repository'; +import { + FILE_SHARING_SETTINGS, + type FileSharingSettings, +} from '@/modules/file-sharing/file-sharing.settings'; +import { FILE_CHUNK_SIZE } from '@/modules/file-storage/file-storage.constants'; +import { FileStorageService } from '@/modules/file-storage/file-storage.service'; +import { + FILE_STORAGE_SETTINGS, + MEGABYTE, + type FileStorageSettings, +} from '@/modules/file-storage/file-storage.settings'; import { MAX_BODY_HTML_LENGTH, MAX_BODY_LENGTH, @@ -373,6 +386,33 @@ const TEST_MAIL_SETTINGS: MailSettings = { directoryAccount: DIRECTORY_ACCOUNT, }; +/** 11단계 — 대용량 첨부의 공개 호스트. 포털과 다른 이름이라 공개 호스트 막기(공개 호스트는 공개 경로만 · 포털은 공개 경로를 닫는다)도 함께 본다 */ +const E2E_SHARE_HOST = 'files.e2e.arasystem.test'; +const E2E_SHARE_ORIGIN = `https://${E2E_SHARE_HOST}`; + +/** e2e 전용 공개 링크 설정 — 파일당 내려받기 상한은 작게 둬서 상한에 닿는 것을 본다 */ +const TEST_FILE_SHARING_SETTINGS: FileSharingSettings = { + publicOrigin: E2E_SHARE_ORIGIN, + reportAddress: 'postmaster@e2e.arasystem.test', + downloadLimit: 3, + dedicatedPublicHost: E2E_SHARE_HOST, +}; + +/** + * e2e 전용 파일 저장 설정 — 폴더는 시작할 때 임시로 만들고 끝나면 지운다(개발 볼륨에 남기지 않는다). + * 검사기는 쓰지 않는다(다 올리면 곧 unscannable — DB 의 검사 차례가 개발 중인 사람의 파일까지 집지 않게). 검사 중 · 위험은 표의 값을 바꿔 본다 + */ +const testFileStorageSettings = (dir: string): FileStorageSettings => ({ + dir, + maxFileBytes: 2047 * MEGABYTE, + maxFilesPerMessage: 2, + userQuotaBytes: 64 * MEGABYTE, + retentionDays: 30, + serverMaxTotalBytes: null, + minFreeBytes: 1, + clamav: null, +}); + /** 목록 응답의 data — 계약의 { items, pageInfo } */ interface Page { items: T[]; @@ -389,6 +429,8 @@ describe('응답 계약 (e2e)', () => { let jwt: JwtService; let authSettings: AuthSettings; let dataSource: DataSource; + /** 11단계 — 이 테스트의 파일 저장 폴더 (시작할 때 만들고 끝나면 지운다) */ + let fileDir = ''; /** 대역 엔진이 비밀번호를 확인한 횟수 (주소별) — 확인이 엔진에 도착한 때 는다 */ const engineChecks = new Map(); /** @@ -413,6 +455,73 @@ describe('응답 계약 (e2e)', () => { /** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */ const correspondents = new Map(); + /** + * 11단계 — 대역 엔진의 보낸 사본: 발송 응답의 id(`sent<차례>`)로 그 메일을 찾는다. 보낸 사람과 받는 사람(to · cc)이 읽는다 + * (받는 사람의 사본도 같은 메일이다 — 계약 4절 머리말) + */ + const sentCopy = (user: string, id: string) => { + const match = /^sent(\d+)$/.exec(id); + const sent = match ? sentMessages[Number(match[1]) - 1] : undefined; + if (!sent) return undefined; + const readers = [ + sent.user, + ...[...sent.message.to, ...sent.message.cc].map(({ email }) => email), + ]; + return readers.includes(user) ? sent : undefined; + }; + + /** 보낸 사본을 읽은 메일 (보낸 사람 = 보낸 직원 — 대용량 첨부 덩이 알아보기의 "보낸 사람 = 링크 주인") */ + const sentDetailOf = ( + id: string, + sent: { user: string; message: OutgoingMessage }, + ): MessageDetailSource => ({ + id, + threadId: id, + mailboxIds: ['e'], + from: { name: '테스트 직원', email: sent.user }, + to: sent.message.to, + subject: sent.message.subject, + preview: '', + receivedAt: '2026-10-30T01:00:00.000Z', + isRead: true, + isFlagged: false, + hasAttachment: false, + size: 1_000, + pendingSend: null, + cc: sent.message.cc, + bcc: [], + replyTo: [], + sentAt: null, + messageId: `${id}@e2e`, + references: [], + rawHtmlBody: sent.message.htmlBody ?? null, + isRawHtmlBodyTruncated: false, + textBody: sent.message.body, + isTextBodyTruncated: false, + attachments: [], + inlineParts: [], + isInJunkMailbox: false, + }); + + /** 취소해 되돌린 발송의 초안 (9단계 — 같은 id · 취소 표시). 보낸 사본에는 대용량 파일 머리글이 없다 */ + const canceledDraftOf = ( + id: string, + message: OutgoingMessage, + ): DraftSource => ({ + id, + to: message.to, + cc: message.cc, + bcc: message.bcc, + subject: message.subject, + textBody: message.body, + rawHtmlBody: message.htmlBody ?? null, + attachments: [], + inReplyTo: null, + updatedAt: '2026-10-30T01:00:00.000Z', + isCanceledSend: true, + largeFileIds: [], + }); + /** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */ const listQueries: { user: string; query: MessageListQuery }[] = []; /** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */ @@ -654,6 +763,7 @@ describe('응답 계약 (e2e)', () => { cookieValue(setCookie(res, 'refresh_token')); beforeAll(async () => { + fileDir = mkdtempSync(join(tmpdir(), 'arasystem-e2e-files-')); const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }) @@ -745,6 +855,15 @@ describe('응답 계약 (e2e)', () => { getDraft: (user: string, id: string): Promise => { const draft = drafts.get(id); if (!draft || draft.user !== user) { + // 9단계 — 취소한 발송은 같은 id 의 초안으로 돌아온다 (11단계 덩이 떼기 확인용 — 실제 되돌리기는 jmap-mail.provider.spec.ts) + const canceled = canceledSends.some( + (entry) => entry.user === user && entry.id === id, + ) + ? sentCopy(user, id) + : undefined; + if (canceled?.user === user) { + return Promise.resolve(canceledDraftOf(id, canceled.message)); + } return Promise.reject( new BusinessException('RES_001', { context: { reason: 'e2e-not-a-draft' }, @@ -764,6 +883,7 @@ describe('응답 계약 (e2e)', () => { inReplyTo: null, updatedAt, isCanceledSend: false, + largeFileIds: [...(content.largeFileIds ?? [])], }); }, deleteDraft: (user: string, id: string) => { @@ -1075,6 +1195,9 @@ describe('응답 계약 (e2e)', () => { user: string, id: string, ): Promise => { + // 11단계 — 보낸 사본 (대용량 첨부 덩이 알아보기 · 우리 웹메일 받기) + const sent = sentCopy(user, id); + if (sent) return Promise.resolve(sentDetailOf(id, sent)); if (id !== IMAGE_MESSAGE) { return Promise.reject( new BusinessException('RES_001', { @@ -1111,6 +1234,19 @@ describe('응답 계약 (e2e)', () => { isInJunkMailbox: false, }); }, + // 11단계 — 보낸 링크 목록의 메일 제목 (보낸 사본만 — 없는 메일은 결과에 들지 않는다) + getMessageSubjects: ( + user: string, + ids: readonly string[], + ): Promise> => + Promise.resolve( + new Map( + ids.flatMap((id): [string, string][] => { + const sent = sentCopy(user, id); + return sent ? [[id, sent.message.subject]] : []; + }), + ), + ), // 9단계 — 차단 목록 (저장 자리 · Sieve · 판은 jmap-filter-script.spec.ts 가 본다) getMailBlocks: (user: string): Promise => Promise.resolve({ @@ -1198,16 +1334,22 @@ describe('응답 계약 (e2e)', () => { .useValue(fakeImageResolver) .overrideProvider(MAIL_IMAGE_TRANSPORT) .useValue(fakeImageTransport) + // 11단계 — 파일은 임시 폴더에, 공개 링크는 고정한 공개 호스트로 (env 와 무관하게) + .overrideProvider(FILE_STORAGE_SETTINGS) + .useValue(testFileStorageSettings(fileDir)) + .overrideProvider(FILE_SHARING_SETTINGS) + .useValue(TEST_FILE_SHARING_SETTINGS) .compile(); // CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다) dataSource = moduleFixture.get(DataSource); await dataSource.runMigrations(); - // main.ts 와 **같은 함수**로 전역 설정을 적용한다 — 실제 응답과 같아진다 - app = moduleFixture.createNestApplication({ - bodyParser: false, - }); + // main.ts 와 **같은 함수**로 어댑터를 만들고(대소문자를 가리는 라우팅) 전역 설정을 적용한다 — 실제 응답과 같아진다 + app = moduleFixture.createNestApplication( + createHttpAdapter(), + { bodyParser: false }, + ); // 요청 추적 ID 도 configureApp 이 맨 앞에 붙인다 (본문 파서 오류에도 traceId) configureApp(app); @@ -1236,10 +1378,14 @@ describe('응답 계약 (e2e)', () => { 'DELETE FROM mail_signatures WHERE user_email LIKE $1', 'DELETE FROM mail_directory WHERE email LIKE $1', 'DELETE FROM mail_settings WHERE user_email LIKE $1', + // 11단계 — 링크(파일 묶음은 함께 지워진다) · 파일(조각 기록도 함께) + 'DELETE FROM share_links WHERE owner_email LIKE $1', + 'DELETE FROM stored_files WHERE owner_email LIKE $1', ]) { await dataSource.query(statement, [TEST_DOMAIN_PATTERN]); } await app.close(); + if (fileDir) rmSync(fileDir, { recursive: true, force: true }); }); it('성공 응답은 { success, data } 로 감싸진다', async () => { @@ -1822,6 +1968,8 @@ describe('응답 계약 (e2e)', () => { attachments: [], inReplyTo: null, updatedAt: second.updatedAt, + // 11단계 — 대용량 파일이 없으면 빈 배열 (계약 4절 "임시 저장") + largeFiles: [], }); }); @@ -5151,6 +5299,1287 @@ describe('응답 계약 (e2e)', () => { }); }); + /** + * 11단계 — 대용량 첨부 (계약 4절). 파일은 이 테스트의 임시 폴더, 기록은 DB(끝나면 지운다), 검사기는 쓰지 않는다(다 올리면 unscannable). + * 공개 호스트는 포털과 다른 이름(files.e2e.arasystem.test)이라 공개 경로는 그 Host 로 부르고, 호스트 막기도 함께 본다. + * 순수 규칙(덩이 · 범위 · 기간 · 상태 · 토큰 · ClamAV · 정리)은 단위 테스트가 본다 — 여기서는 실제 HTTP 의 형식 · 머리글 · 흐름을 본다 + */ + describe('11단계 — 대용량 첨부 (/api/file-uploads · /api/share-links · /api/public-links)', () => { + const SENDER = 'lf.sender@e2e.arasystem.test'; + const COLLEAGUE = { + name: '이도윤', + email: 'lf.colleague@e2e.arasystem.test', + }; + const STRANGER = 'lf.stranger@e2e.arasystem.test'; + /** 이 묶음의 요청은 IP 를 따로 둔다 — 경로마다의 요청 수 한도(IP 당)가 앞 묶음의 요청과 섞이지 않게 */ + const CLIENT_IP = '198.51.100.110'; + const SUBJECT = '11월 견적 회신'; + /** 쓰기 화면이 인용 바로 앞에 두는 경계 표시 — 서버가 덩이 자리로 쓰고 지운다 */ + const QUOTE_HTML = + '

견적서를 보냅니다.

----- 원본 메일 -----

지난 메일

'; + const QUOTE_HTML_WITHOUT_MARKER = + '

견적서를 보냅니다.

----- 원본 메일 -----

지난 메일

'; + const QUOTE_TEXT = '견적서를 보냅니다.\n\n----- 원본 메일 -----\n지난 메일'; + const QUOTE_START = '견적서를 보냅니다.\n\n'.length; + const BLOCK_START = ''; + const BLOCK_END = ''; + const SHARE_URL = new RegExp( + `${E2E_SHARE_ORIGIN.replace(/\./g, '\\.')}/s/([A-Za-z0-9_-]{43})`, + ); + /** 링크가 끝나는 때 — 한국 날짜의 23:59:59.999 (UTC 로 14:59:59.999) */ + const KST_END_OF_DAY = /T14:59:59\.999Z$/; + + interface LargeFileBody { + id: string; + name: string; + size: number; + status: string; + chunkSize: number; + chunkCount: number; + receivedChunks: number[]; + firstChunkSha256: string | null; + scanStatus: string | null; + createdAt: string; + expiresAt: string; + lastModified: number | null; + } + interface SharedFileBody { + id: string; + name: string; + size: number; + status: string; + downloadCount: number; + lastDownloadedAt: string | null; + } + interface ShareLinkBody { + id: string; + sentEmailId: string | null; + subject: string | null; + sentAt: string; + expiresAt: string; + status: string; + revokedAt: string | null; + files: SharedFileBody[]; + } + interface PublicLinkBody { + status: string; + sender: { name: string | null; email: string }; + sentAt: string; + expiresAt: string; + files: { id: string; name: string; size: number; status: string }[]; + reportAddress: string; + } + interface MessageLargeFileBody { + linkId: string; + fileId: string; + name: string; + size: number; + status: string; + expiresAt: string; + revokedAt: string | null; + url: string; + downloads: { count: number; lastDownloadedAt: string | null } | null; + } + + /** 이 묶음의 IP 로 · 그 직원으로(주면) */ + const client = (test: HttpTest, email?: string): HttpTest => { + const fromClient = test.set('X-Forwarded-For', CLIENT_IP); + return email ? fromClient.set('Cookie', cookieOf(email)) : fromClient; + }; + /** 공개 경로 — 공개 호스트로, 쿠키 없이 */ + const publicGet = (path: string) => + client(request(server).get(path)).set('Host', E2E_SHARE_HOST); + const publicHead = (path: string) => + client(request(server).head(path)).set('Host', E2E_SHARE_HOST); + + /** + * 요청 줄의 대상을 그대로 보낸다 — supertest 는 절대 주소 꼴(`GET http://호스트/경로`) · `\` · `#` 를 그대로 보내지 못한다. + * 서버가 닫혀 있으면 열고 끝나면 닫는다. 답은 상태와 실패 코드만 + */ + const rawGet = async ( + target: string, + host: string, + ): Promise<{ status: number; code: string | undefined }> => { + const httpServer = app.getHttpServer(); + const startedServer = !httpServer.listening; + if (startedServer) await listenOnAnyPort(httpServer); + try { + const address = httpServer.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return await new Promise((resolve, reject) => { + const sent = httpGet( + { + host: '127.0.0.1', + port, + path: target, + headers: { host, 'x-forwarded-for': CLIENT_IP }, + agent: false, + }, + (response) => { + let text = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + text += chunk; + }); + response.on('end', () => + resolve({ + status: response.statusCode ?? 0, + code: (JSON.parse(text) as ContractBody).error?.code, + }), + ); + }, + ); + sent.on('error', reject); + }); + } finally { + if (startedServer) await closeServer(httpServer); + } + }; + + const sha256Of = (bytes: Buffer) => + createHash('sha256').update(bytes).digest('base64'); + const digestOf = (bytes: Buffer) => `sha-256=:${sha256Of(bytes)}:`; + + const createUpload = (email: string, payload: object) => + client(request(server).post('/api/file-uploads'), email).send(payload); + const putChunk = ( + email: string, + id: string, + index: number | string, + bytes: Buffer, + headers: Record = {}, + ) => + client( + request(server).put(`/api/file-uploads/${id}/chunks/${index}`), + email, + ) + .set({ 'Content-Type': 'application/octet-stream', ...headers }) + .send(bytes); + const complete = (email: string, id: string) => + client(request(server).post(`/api/file-uploads/${id}/completion`), email); + const getUpload = (email: string, id: string) => + client(request(server).get(`/api/file-uploads/${id}`), email); + const removeUpload = (email: string, id: string) => + client(request(server).delete(`/api/file-uploads/${id}`), email); + const quotaOf = async (email: string) => + dataOf<{ usedBytes: number }>( + await client(request(server).get('/api/file-quota'), email).expect(200), + ).usedBytes; + const unsentIds = async (email: string) => + dataOf>( + await client( + request(server).get('/api/file-uploads?limit=100'), + email, + ).expect(200), + ).items.map(({ id }) => id); + const shareLinksOf = async (email: string) => + dataOf>( + await client( + request(server).get('/api/share-links?limit=100'), + email, + ).expect(200), + ); + + /** 한 조각 파일을 다 올린다 (만들기 → 조각 → 끝내기) */ + const uploadReady = async ( + email: string, + content: string, + name: string, + ) => { + const bytes = Buffer.from(content, 'utf8'); + const created = await createUpload(email, { + name, + size: bytes.length, + purpose: 'mail', + }).expect(201); + const { id } = dataOf(created); + await putChunk(email, id, 0, bytes, { + 'Content-Digest': digestOf(bytes), + }).expect(200); + await complete(email, id).expect(200); + return { id, bytes, name }; + }; + + /** 인용이 있는 서식 메일로 보낸다 — 덩이는 내 글 아래 · 인용 위 */ + const sendLarge = ( + email: string, + largeFileIds: string[], + payload: object = {}, + ) => + client(request(server).post('/api/messages'), email).send({ + to: [COLLEAGUE], + subject: SUBJECT, + body: QUOTE_TEXT, + bodyHtml: QUOTE_HTML, + bodyQuoteStart: QUOTE_START, + largeFileIds, + ...payload, + }); + + /** 보내고 — 보낸 사본(대역 엔진이 받은 메일) · 덩이의 토큰 · 보낸 링크 목록의 그 링크 */ + const sendAndShare = async ( + email: string, + largeFileIds: string[], + payload: object = {}, + ) => { + const res = await sendLarge(email, largeFileIds, payload).expect(201); + const sentId = dataOf<{ id: string }>(res).id; + const { message } = sentMessages[sentMessages.length - 1]; + const token = SHARE_URL.exec(message.htmlBody ?? '')?.[1] ?? ''; + const link = (await shareLinksOf(email)).items.find( + (item) => item.sentEmailId === sentId, + ); + return { sentId, message, token, link }; + }; + + it('로그인하지 않았으면 401 AUTH_001 — 공개 경로 밖은 모두', async () => { + const id = randomUUID(); + for (const res of [ + await client(request(server).post('/api/file-uploads')).send({ + name: 'a.zip', + size: 1, + purpose: 'mail', + }), + await client(request(server).get('/api/file-uploads')), + await client(request(server).get(`/api/file-uploads/${id}`)), + await client(request(server).put(`/api/file-uploads/${id}/chunks/0`)) + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from('a')), + await client( + request(server).post(`/api/file-uploads/${id}/completion`), + ), + await client(request(server).delete(`/api/file-uploads/${id}`)), + await client(request(server).get('/api/file-quota')), + await client(request(server).get('/api/share-links')), + await client(request(server).post(`/api/share-links/${id}/revocation`)), + await client(request(server).post('/api/share-link-lookups')).send({ + urls: [`${E2E_SHARE_ORIGIN}/s/x`], + }), + await client( + request(server).get(`/api/messages/sent1/large-files/${id}`), + ), + ]) { + expect(res.status).toBe(401); + expect(body(res).error?.code).toBe('AUTH_001'); + } + }); + + it('만들기 — 201 LargeFile(올리는 중 · 받은 조각 없음), 사용량은 선언한 크기로 바로 잡는다 · private, no-store', async () => { + const ME = 'lf.create@e2e.arasystem.test'; + const before = await quotaOf(ME); + + const res = await createUpload(ME, { + name: '현장영상.mp4', + size: 5, + purpose: 'mail', + lastModified: 1_793_241_600_000, + }).expect(201); + expect(res.headers['cache-control']).toBe('private, no-store'); + const file = dataOf(res); + expect(Object.keys(file).sort()).toEqual([ + 'chunkCount', + 'chunkSize', + 'createdAt', + 'expiresAt', + 'firstChunkSha256', + 'id', + 'lastModified', + 'name', + 'receivedChunks', + 'scanStatus', + 'size', + 'status', + ]); + expect(file).toMatchObject({ + name: '현장영상.mp4', + size: 5, + status: 'uploading', + chunkSize: FILE_CHUNK_SIZE, + chunkCount: 1, + receivedChunks: [], + firstChunkSha256: null, + scanStatus: null, + lastModified: 1_793_241_600_000, + }); + expect(file.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + + const quota = await client( + request(server).get('/api/file-quota'), + ME, + ).expect(200); + expect(quota.headers['cache-control']).toBe('private, no-store'); + expect(dataOf(quota)).toEqual({ + usedBytes: before + 5, + limitBytes: 64 * MEGABYTE, + maxFileBytes: 2047 * MEGABYTE, + maxFilesPerMessage: 2, + chunkSize: FILE_CHUNK_SIZE, + retentionDays: 30, + }); + }); + + it.each<[string, object, number, string]>([ + [ + '실행 형식', + { name: 'setup.exe', size: 10, purpose: 'mail' }, + 400, + 'FILE_001', + ], + [ + '디스크 이미지', + { name: '설치.iso', size: 10, purpose: 'mail' }, + 400, + 'FILE_001', + ], + [ + '한 파일 한도 초과', + { name: '큰파일.zip', size: 2047 * MEGABYTE + 1, purpose: 'mail' }, + 413, + 'FILE_002', + ], + [ + '직원 보관 한도 초과', + { name: '영상.mp4', size: 65 * MEGABYTE, purpose: 'mail' }, + 409, + 'FILE_005', + ], + ['빈 파일', { name: 'a.zip', size: 0, purpose: 'mail' }, 400, 'VAL_001'], + [ + '정수가 아닌 크기', + { name: 'a.zip', size: 1.5, purpose: 'mail' }, + 400, + 'VAL_001', + ], + [ + '모르는 쓰임', + { name: 'a.zip', size: 1, purpose: 'drive' }, + 400, + 'VAL_001', + ], + ['빈 이름', { name: '', size: 1, purpose: 'mail' }, 400, 'VAL_001'], + [ + '브라우저가 주는 형식(받지 않는 칸)', + { name: 'a.zip', size: 1, purpose: 'mail', type: 'application/zip' }, + 400, + 'VAL_001', + ], + ])( + '만들기 실패 — %s 는 %i %s (만들지 않는다)', + async (_label, payload, status, code) => { + const ME = 'lf.reject@e2e.arasystem.test'; + const before = await quotaOf(ME); + const res = await createUpload(ME, payload).expect(status); + expect(body(res).error?.code).toBe(code); + expect(await quotaOf(ME)).toBe(before); + }, + ); + + it('조각 — 다 받아 적고(맨 앞 조각의 SHA-256) 끝내면 ready(검사기를 쓰지 않으면 unscannable), 다시 보낸 같은 조각 · 다시 끝내기는 200', async () => { + const ME = 'lf.chunk@e2e.arasystem.test'; + const bytes = Buffer.from('대용량 첨부 내용', 'utf8'); + const { id } = dataOf( + await createUpload(ME, { + name: '자료.zip', + size: bytes.length, + purpose: 'mail', + }).expect(201), + ); + + const chunk = await putChunk(ME, id, 0, bytes, { + 'Content-Digest': digestOf(bytes), + }).expect(200); + expect(chunk.headers['cache-control']).toBe('private, no-store'); + expect(dataOf(chunk)).toMatchObject({ + status: 'uploading', + receivedChunks: [0], + firstChunkSha256: sha256Of(bytes), + scanStatus: null, + }); + + // 응답을 받지 못한 재시도 — 같은 내용이면 그대로 200, 다르면 409 FILE_003 (아무것도 바꾸지 않는다) + await putChunk(ME, id, 0, bytes).expect(200); + const differs = await putChunk( + ME, + id, + 0, + Buffer.alloc(bytes.length, 0x61), + ).expect(409); + expect(body(differs).error?.code).toBe('FILE_003'); + + const done = await complete(ME, id).expect(200); + expect(dataOf(done)).toMatchObject({ + status: 'ready', + receivedChunks: [0], + scanStatus: 'unscannable', + }); + await complete(ME, id).expect(200); + const closed = await putChunk(ME, id, 0, bytes).expect(409); + expect(body(closed).error?.code).toBe('FILE_003'); + + expect( + dataOf(await getUpload(ME, id).expect(200)), + ).toMatchObject({ + id, + name: '자료.zip', + status: 'ready', + }); + const page = dataOf>( + await client(request(server).get('/api/file-uploads'), ME).expect(200), + ); + expect(page.items.map((item) => item.id)).toContain(id); + expect(typeof page.pageInfo.total).toBe('number'); + + // 남의 것 · 모양이 틀린 id 는 가리지 않고 404 + for (const [email, fileId] of [ + [STRANGER, id], + [ME, 'not-a-uuid'], + ]) { + const res = await getUpload(email, fileId).expect(404); + expect(body(res).error?.code).toBe('RES_001'); + } + }); + + it('조각 실패 — 형식 · 길이 · 압축 · 번호 · 다이제스트 모양 400 VAL_001, 손상 400 FILE_004, 없는 · 남의 올리기 404 — 받은 조각은 없다', async () => { + const ME = 'lf.badchunk@e2e.arasystem.test'; + const bytes = Buffer.from('0123456789'); + const { id } = dataOf( + await createUpload(ME, { + name: 'b.pdf', + size: 10, + purpose: 'mail', + }).expect(201), + ); + + const cases: [string, () => HttpTest, number, string][] = [ + [ + 'JSON 본문', + () => + client(request(server).put(`/api/file-uploads/${id}/chunks/0`), ME) + .set('Content-Type', 'application/json') + .send('{}'), + 400, + 'VAL_001', + ], + [ + '길이가 다름', + () => putChunk(ME, id, 0, bytes.subarray(0, 9)), + 400, + 'VAL_001', + ], + [ + '압축', + () => putChunk(ME, id, 0, bytes, { 'Content-Encoding': 'gzip' }), + 400, + 'VAL_001', + ], + ['범위 밖 번호', () => putChunk(ME, id, 1, bytes), 400, 'VAL_001'], + [ + '숫자가 아닌 번호', + () => putChunk(ME, id, 'x', bytes), + 400, + 'VAL_001', + ], + [ + '다이제스트 모양', + () => + putChunk(ME, id, 0, bytes, { 'Content-Digest': 'sha-256=:abc:' }), + 400, + 'VAL_001', + ], + [ + '손상(다이제스트가 다름)', + () => + putChunk(ME, id, 0, bytes, { + 'Content-Digest': digestOf(Buffer.from('9876543210')), + }), + 400, + 'FILE_004', + ], + [ + '모양이 틀린 id', + () => putChunk(ME, 'not-a-uuid', 0, bytes), + 404, + 'RES_001', + ], + ['남의 올리기', () => putChunk(STRANGER, id, 0, bytes), 404, 'RES_001'], + ]; + for (const [label, send, status, code] of cases) { + const res = await send(); + expect({ + label, + status: res.status, + code: body(res).error?.code, + }).toEqual({ + label, + status, + code, + }); + } + expect( + dataOf(await getUpload(ME, id).expect(200)), + ).toMatchObject({ + receivedChunks: [], + firstChunkSha256: null, + }); + // 같은 조각을 제대로 다시 보내면 받는다 + await putChunk(ME, id, 0, bytes, { + 'Content-Digest': digestOf(bytes), + }).expect(200); + }); + + it('조각 번호는 앞자리 0 없는 10진수만 — 숫자로 바뀌면 1 이 되는 글(01 · +1 · " 1" · 1.0 · 0x1 · 0b1 · 1e0)도 400 VAL_001 (전역 파이프의 숫자 변환을 거치지 않는다 — 리뷰 L3)', async () => { + const ME = 'lf.index@e2e.arasystem.test'; + const { id } = dataOf( + await createUpload(ME, { + name: '번호.zip', + size: FILE_CHUNK_SIZE + 3, + purpose: 'mail', + }).expect(201), + ); + const last = Buffer.from('end'); + for (const index of [ + '01', + '+1', + '%201', + '1.0', + '0x1', + '0b1', + '1e0', + '1e1', + '+3', + '%202', + '007', + '0b11', + ]) { + const res = await putChunk(ME, id, index, last); + expect({ + index, + status: res.status, + code: body(res).error?.code, + }).toEqual({ index, status: 400, code: 'VAL_001' }); + } + expect( + dataOf(await getUpload(ME, id).expect(200)) + .receivedChunks, + ).toEqual([]); + await putChunk(ME, id, '1', last).expect(200); + }); + + it('끝내기 — 빠진 조각이 있으면 409 FILE_003, 그만두면(DELETE 204) 404 이고 사용량에서 바로 빠진다', async () => { + const ME = 'lf.partial@e2e.arasystem.test'; + const before = await quotaOf(ME); + const size = FILE_CHUNK_SIZE + 3; + const { id } = dataOf( + await createUpload(ME, { + name: '현장.mov', + size, + purpose: 'mail', + }).expect(201), + ); + + // 마지막 조각(3바이트)만 — 마지막 조각의 길이는 size − chunkSize + const last = await putChunk(ME, id, 1, Buffer.from('end')).expect(200); + expect(dataOf(last)).toMatchObject({ + chunkCount: 2, + receivedChunks: [1], + firstChunkSha256: null, + }); + const incomplete = await complete(ME, id).expect(409); + expect(body(incomplete).error?.code).toBe('FILE_003'); + expect(await quotaOf(ME)).toBe(before + size); + + await removeUpload(ME, id).expect(204); + expect(await quotaOf(ME)).toBe(before); + const gone = await getUpload(ME, id).expect(404); + expect(body(gone).error?.code).toBe('RES_001'); + const again = await removeUpload(ME, id).expect(404); + expect(body(again).error?.code).toBe('RES_001'); + }); + + it('보내기 — 덩이를 내 글 아래 · 인용 위에(HTML · 텍스트) 넣고 경계 표시는 지운다, 링크 하나 · 보낸 링크 목록 · 실린 파일은 지울 수 없다(409 FILE_003)', async () => { + const file = await uploadReady(SENDER, 'large file body', '견적서.zip'); + const { sentId, message, token, link } = await sendAndShare(SENDER, [ + file.id, + ]); + + const html = message.htmlBody ?? ''; + expect(html).not.toContain('data-ara-quote-start'); + expect(html.split(BLOCK_START)).toHaveLength(2); + expect(html.indexOf('

견적서를 보냅니다.

')).toBeLessThan( + html.indexOf(BLOCK_START), + ); + expect(html.indexOf(BLOCK_END)).toBeLessThan( + html.indexOf('

----- 원본 메일 -----

'), + ); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + const url = `${E2E_SHARE_ORIGIN}/s/${token}`; + expect(html).toContain(url); + expect(html).toContain('견적서.zip'); + // 받는 사람 · 제목은 덩이에 넣지 않는다 + expect(html).not.toContain(SUBJECT); + expect(html).not.toContain(COLLEAGUE.email); + // 텍스트 — bodyQuoteStart 자리 (내 글은 그대로 앞에) + expect(message.body.startsWith('견적서를 보냅니다.\n\n')).toBe(true); + expect(message.body.indexOf(url)).toBeGreaterThan(0); + expect(message.body.indexOf(url)).toBeLessThan( + message.body.indexOf('----- 원본 메일 -----'), + ); + + expect(link).toMatchObject({ + sentEmailId: sentId, + subject: SUBJECT, + status: 'active', + revokedAt: null, + files: [ + { + id: file.id, + name: '견적서.zip', + size: file.bytes.length, + status: 'unscannable', + downloadCount: 0, + lastDownloadedAt: null, + }, + ], + }); + expect(link?.expiresAt).toMatch(KST_END_OF_DAY); + + // 살아 있는 링크에 실린 파일 — 보내지 않은 파일 목록에서 빠지고, 지우기는 409 FILE_003 (링크 끊기로) + expect(await unsentIds(SENDER)).not.toContain(file.id); + const locked = await removeUpload(SENDER, file.id).expect(409); + expect(body(locked).error?.code).toBe('FILE_003'); + }); + + it('보내기 실패 — 올리는 중 · 없는 · 남의 파일은 400 MAIL_016, 개수 · 모양 · 겹침 · 인용 자리는 400 VAL_001 — 메일도 링크도 만들지 않는다', async () => { + const uploading = dataOf( + await createUpload(SENDER, { + name: '올리는중.zip', + size: 10, + purpose: 'mail', + }).expect(201), + ); + const strangers = await uploadReady(STRANGER, 'not yours', '남의것.zip'); + const mine = await uploadReady(SENDER, 'mine', '내것.zip'); + const linksBefore = (await shareLinksOf(SENDER)).pageInfo.total; + const sentBefore = sentMessages.length; + + const cases: [string, string[], object, string][] = [ + ['올리는 중', [uploading.id], {}, 'MAIL_016'], + ['없는 파일', [randomUUID()], {}, 'MAIL_016'], + ['남의 파일', [strangers.id], {}, 'MAIL_016'], + [ + '한 통의 개수 초과(2)', + [mine.id, uploading.id, randomUUID()], + {}, + 'VAL_001', + ], + ['UUID 모양이 아님', ['not-a-uuid'], {}, 'VAL_001'], + ['겹침', [mine.id, mine.id], {}, 'VAL_001'], + [ + '인용 자리가 줄의 처음이 아님', + [mine.id], + { bodyQuoteStart: 1 }, + 'VAL_001', + ], + [ + '인용 자리가 본문 밖', + [mine.id], + { bodyQuoteStart: QUOTE_TEXT.length + 1 }, + 'VAL_001', + ], + ]; + for (const [label, ids, extra, code] of cases) { + const res = await sendLarge(SENDER, ids, extra); + expect({ + label, + status: res.status, + code: body(res).error?.code, + }).toEqual({ + label, + status: 400, + code, + }); + } + expect(sentMessages).toHaveLength(sentBefore); + expect((await shareLinksOf(SENDER)).pageInfo.total).toBe(linksBefore); + }); + + it('공개 링크 정보 — 로그인 없이 보낸 사람 이름 · 주소만(제목 · 받는 사람 없음), no-store · noindex · no-referrer. 포털 호스트에서는 404 · 공개 호스트의 다른 경로도 404', async () => { + const file = await uploadReady(SENDER, 'public info', '도면.dwg'); + const { token } = await sendAndShare(SENDER, [file.id]); + + const res = await publicGet(`/api/public-links/${token}`).expect(200); + expect(res.headers['cache-control']).toBe('no-store'); + expect(res.headers['x-robots-tag']).toBe('noindex'); + expect(res.headers['referrer-policy']).toBe('no-referrer'); + const info = dataOf(res); + expect(info).toEqual({ + status: 'active', + sender: { name: '테스트 직원', email: SENDER }, + sentAt: expect.any(String) as string, + expiresAt: expect.stringMatching(KST_END_OF_DAY) as string, + files: [ + { + id: file.id, + name: '도면.dwg', + size: file.bytes.length, + status: 'unscannable', + }, + ], + reportAddress: 'postmaster@e2e.arasystem.test', + }); + expect(JSON.stringify(body(res))).not.toContain(SUBJECT); + expect(JSON.stringify(body(res))).not.toContain(COLLEAGUE.email); + + // 공개 호스트는 공개 경로만, 포털 호스트는 공개 경로를 열지 않는다 (경로의 대소문자는 가리지 않는다) + // 요청은 차례로 만든다 — supertest 는 요청을 만들 때 서버를 열고 끝나면 닫는다 + for (const blocked of [ + () => client(request(server).get(`/api/public-links/${token}`)), + () => client(request(server).get(`/API/PUBLIC-LINKS/${token}`)), + () => + client(request(server).get('/api/users')).set('Host', E2E_SHARE_HOST), + () => + client(request(server).get('/api/file-quota'), SENDER).set( + 'Host', + E2E_SHARE_HOST, + ), + ]) { + const denied = await blocked().expect(404); + expect(body(denied).error?.code).toBe('RES_001'); + } + + // 라우터는 절대 주소 꼴(absolute-form)과 `#` 이 섞인 `\` 를 원문과 다른 경로(공개 링크)로 읽는다 — 막기도 그 경로를 본다. + // 포털에서는 404, 공개 호스트는 절대 주소 꼴을 받지 않는다 (같은 대상을 공개 호스트에 경로만으로 보내면 200) + const portalHost = '127.0.0.1'; + for (const [target, host] of [ + [`http://${E2E_SHARE_HOST}/api/public-links/${token}`, portalHost], + [`HTTPS://${E2E_SHARE_HOST}:443/api/public-links/${token}`, portalHost], + [`/api\\public-links\\${token}#a`, portalHost], + [`/api\\public-links\\${token}`, portalHost], + [`/api%2Fpublic-links/${token}`, portalHost], + [`http://${E2E_SHARE_HOST}/api/public-links/${token}`, E2E_SHARE_HOST], + [`/api/public-links/${token}/%2e%2e/%2e%2e/users`, E2E_SHARE_HOST], + ]) { + expect({ target, host, ...(await rawGet(target, host)) }).toEqual({ + target, + host, + status: 404, + code: 'RES_001', + }); + } + expect( + await rawGet(`/api/public-links/${token}`, E2E_SHARE_HOST), + ).toEqual({ status: 200, code: undefined }); + + // 모르는 토큰 · 모양이 틀린 토큰은 404 + for (const unknown of [randomBytes(32).toString('base64url'), 'short']) { + const missing = await publicGet(`/api/public-links/${unknown}`).expect( + 404, + ); + expect(body(missing).error?.code).toBe('RES_001'); + } + }); + + it('공개 받기 — 머리글 · HEAD(세지 않음) · 범위 206 · 416 · If-Range, 0번째 바이트부터 보낸 응답만 세고 상한(3)에 닿으면 410 FILE_007', async () => { + const file = await uploadReady(SENDER, 'abcdefghij', '사진 모음.7z'); + const { token, link } = await sendAndShare(SENDER, [file.id]); + const path = `/api/public-links/${token}/files/${file.id}`; + const download = (range?: string, ifRange?: string) => { + const test = publicGet(path).responseType('blob'); + if (range) void test.set('Range', range); + if (ifRange) void test.set('If-Range', ifRange); + return test; + }; + + const head = await publicHead(path).expect(200); + expect(head.headers).toMatchObject({ + 'content-type': 'application/octet-stream', + 'content-length': '10', + 'content-disposition': buildAttachmentDisposition('사진 모음.7z'), + 'x-content-type-options': 'nosniff', + 'content-security-policy': "default-src 'none'; sandbox", + 'cache-control': 'private, no-store', + 'referrer-policy': 'no-referrer', + 'x-robots-tag': 'noindex', + 'accept-ranges': 'bytes', + etag: `"${file.id}"`, + 'x-accel-buffering': 'no', + }); + expect(typeof head.headers['last-modified']).toBe('string'); + + const full = await download().expect(200); // 센다 (1) + expect((full.body as Buffer).toString()).toBe('abcdefghij'); + + const middle = await download('bytes=2-4').expect(206); + expect(middle.headers['content-range']).toBe('bytes 2-4/10'); + expect(middle.headers['content-length']).toBe('3'); + expect((middle.body as Buffer).toString()).toBe('cde'); + const suffix = await download('bytes=-3').expect(206); + expect(suffix.headers['content-range']).toBe('bytes 7-9/10'); + expect((suffix.body as Buffer).toString()).toBe('hij'); + const beyond = await download('bytes=10-').expect(416); + expect(beyond.headers['content-range']).toBe('bytes */10'); + expect(beyond.headers['content-length']).toBe('0'); + + await download('bytes=0-1').expect(206); // 0번째 바이트부터 — 센다 (2) + // If-Range 가 ETag 와 다르면 범위를 무시하고 전체 — 센다 (3) + const stale = await download('bytes=2-4', '"other"').expect(200); + expect((stale.body as Buffer).toString()).toBe('abcdefghij'); + + // 상한에 닿은 파일 — 처음부터도 이어 받기도 410 + for (const range of [undefined, 'bytes=5-']) { + const test = publicGet(path); + if (range) void test.set('Range', range); + const limited = await test.expect(410); + expect(body(limited).error?.code).toBe('FILE_007'); + } + const info = dataOf( + await publicGet(`/api/public-links/${token}`).expect(200), + ); + expect(info.files[0].status).toBe('limit-reached'); + const counted = (await shareLinksOf(SENDER)).items.find( + (item) => item.id === link?.id, + )?.files[0]; + expect(counted).toMatchObject({ + downloadCount: 3, + status: 'limit-reached', + }); + expect(typeof counted?.lastDownloadedAt).toBe('string'); + + // 그 링크에 없는 파일은 404 + const notInLink = await publicGet( + `/api/public-links/${token}/files/${randomUUID()}`, + ).expect(404); + expect(body(notInLink).error?.code).toBe('RES_001'); + }); + + it('검사 중이면 받기 409 FILE_008(정보는 scanning), 위험이면 410 FILE_007(infected) — 위험 판정 파일은 다시 보낼 수 없다(400 MAIL_016)', async () => { + const file = await uploadReady(SENDER, 'scan me', '검사.zip'); + const { token } = await sendAndShare(SENDER, [file.id]); + const path = `/api/public-links/${token}/files/${file.id}`; + const setScanStatus = async (status: string): Promise => { + await dataSource.query( + 'UPDATE stored_files SET scan_status = $2 WHERE id = $1', + [file.id, status], + ); + }; + const statusInInfo = async () => + dataOf( + await publicGet(`/api/public-links/${token}`).expect(200), + ).files[0].status; + + await setScanStatus('pending'); + const scanning = await publicGet(path).expect(409); + expect(body(scanning).error?.code).toBe('FILE_008'); + expect(await statusInInfo()).toBe('scanning'); + + await setScanStatus('infected'); + const infected = await publicGet(path).expect(410); + expect(body(infected).error?.code).toBe('FILE_007'); + expect(await statusInInfo()).toBe('infected'); + + const sentBefore = sentMessages.length; + const blocked = await sendLarge(SENDER, [file.id]).expect(400); + expect(body(blocked).error?.code).toBe('MAIL_016'); + expect(sentMessages).toHaveLength(sentBefore); + }); + + it('임시 저장 — 올리는 중인 파일도 머리글로 두고(덩이 없음) 다시 열면 largeFiles, 지운 파일은 MissingLargeFile(deleted), 쓸 수 없는 파일은 400 MAIL_016', async () => { + const ME = 'lf.draft@e2e.arasystem.test'; + const uploading = dataOf( + await createUpload(ME, { + name: '이어올리기.mp4', + size: 10, + purpose: 'mail', + }).expect(201), + ); + const ready = await uploadReady(ME, 'draft file', '초안.zip'); + const save = (largeFileIds: string[]) => + client(request(server).post('/api/drafts'), ME).send({ + to: [], + subject: '쓰는 중', + body: QUOTE_TEXT, + bodyHtml: QUOTE_HTML, + largeFileIds, + }); + + const saved = dataOf<{ id: string }>( + await save([uploading.id, ready.id]).expect(201), + ); + const content = drafts.get(saved.id)?.content; + expect(content?.largeFileIds).toEqual([uploading.id, ready.id]); + expect(content?.htmlBody).toBe(QUOTE_HTML_WITHOUT_MARKER); + + const largeFilesOfDraft = async () => + dataOf<{ largeFiles: Record[] }>( + await client( + request(server).get(`/api/drafts/${saved.id}`), + ME, + ).expect(200), + ).largeFiles; + const opened = await largeFilesOfDraft(); + expect(opened).toEqual([ + expect.objectContaining({ + id: uploading.id, + status: 'uploading', + receivedChunks: [], + }) as unknown, + expect.objectContaining({ + id: ready.id, + status: 'ready', + scanStatus: 'unscannable', + }) as unknown, + ]); + // 임시 저장에 실린 다 올린 파일은 다 올린 날(한국)부터 30일째 날 끝까지 남는다 + expect(opened[1].expiresAt).toMatch(KST_END_OF_DAY); + + await removeUpload(ME, ready.id).expect(204); + expect(await largeFilesOfDraft()).toEqual([ + expect.objectContaining({ + id: uploading.id, + status: 'uploading', + }) as unknown, + { + id: ready.id, + status: 'missing', + reason: 'deleted', + name: '초안.zip', + size: ready.bytes.length, + }, + ]); + + for (const ids of [[randomUUID()], [ready.id]]) { + const res = await save(ids).expect(400); + expect(body(res).error?.code).toBe('MAIL_016'); + } + }); + + it('우리 웹메일 — 보낸 사람의 메일 읽기에 largeFiles · data-ara-large-files, 보낸 사람이 받으면 세지 않고 사내 받는 사람이 받으면 센다', async () => { + const file = await uploadReady(SENDER, 'webmail bytes', '보고서.pdf'); + const { sentId, token, link } = await sendAndShare(SENDER, [file.id]); + const read = async (email: string) => + dataOf<{ htmlBody: string | null; largeFiles: MessageLargeFileBody[] }>( + await client( + request(server).get(`/api/messages/${sentId}`), + email, + ).expect(200), + ); + const download = (email: string) => + client( + request(server).get(`/api/messages/${sentId}/large-files/${file.id}`), + email, + ).responseType('blob'); + + const mine = await read(SENDER); + expect(mine.htmlBody).toContain('
'); + expect(mine.largeFiles).toEqual([ + { + linkId: link?.id, + fileId: file.id, + name: '보고서.pdf', + size: file.bytes.length, + status: 'unscannable', + expiresAt: link?.expiresAt, + revokedAt: null, + url: `${E2E_SHARE_ORIGIN}/s/${token}`, + downloads: { count: 0, lastDownloadedAt: null }, + }, + ]); + + const own = await download(SENDER).expect(200); + expect((own.body as Buffer).toString()).toBe('webmail bytes'); + expect(own.headers['content-disposition']).toBe( + buildAttachmentDisposition('보고서.pdf'), + ); + expect((await read(SENDER)).largeFiles[0].downloads?.count).toBe(0); + + const colleague = await read(COLLEAGUE.email); + expect(colleague.largeFiles[0]).toMatchObject({ + fileId: file.id, + downloads: null, + }); + await download(COLLEAGUE.email).expect(200); + expect((await read(SENDER)).largeFiles[0].downloads?.count).toBe(1); + + const ranged = await download(SENDER) + .set('Range', 'bytes=0-6') + .expect(206); + expect((ranged.body as Buffer).toString()).toBe('webmail'); + await client( + request(server).head(`/api/messages/${sentId}/large-files/${file.id}`), + SENDER, + ).expect(200); + + // 그 메일에서 첨부처럼 올린 파일이 아니면 404 + const other = await client( + request(server).get( + `/api/messages/${sentId}/large-files/${randomUUID()}`, + ), + SENDER, + ).expect(404); + expect(body(other).error?.code).toBe('RES_001'); + }); + + it('경로는 대소문자를 가린다 — 받기 경로의 대소문자 변형(/api/Messages · LARGE-FILES · 공개 FILES · /API)은 처리기에 닿지 않고 404 RES_001, 받은 수도 그대로 (앞단 nginx 의 받기 제한을 비켜 가지 못하게 — 2026-10-30 인프라 리뷰 High)', async () => { + const file = await uploadReady(SENDER, 'case bytes', '대소문자.pdf'); + const { sentId, token, link } = await sendAndShare(SENDER, [file.id]); + const downloadCount = async () => + (await shareLinksOf(SENDER)).items.find((item) => item.id === link?.id) + ?.files[0].downloadCount; + + // 소문자 경로는 그대로 — 공개 받기 한 번(센다) · 보낸 사람의 받기 · 내 정보 + await publicGet(`/api/public-links/${token}/files/${file.id}`) + .responseType('blob') + .expect(200); + await client( + request(server).get(`/api/messages/${sentId}/large-files/${file.id}`), + SENDER, + ) + .responseType('blob') + .expect(200); + await client(request(server).get('/api/auth/me'), SENDER).expect(200); + expect(await downloadCount()).toBe(1); + + for (const [label, blocked] of [ + [ + '포털 · /api/Messages', + () => + client( + request(server).get( + `/api/Messages/${sentId}/large-files/${file.id}`, + ), + SENDER, + ), + ], + [ + '포털 · LARGE-FILES', + () => + client( + request(server).get( + `/api/messages/${sentId}/LARGE-FILES/${file.id}`, + ), + SENDER, + ), + ], + [ + '공개 호스트 · FILES', + () => publicGet(`/api/public-links/${token}/FILES/${file.id}`), + ], + [ + '공개 호스트 · /API', + () => publicGet(`/API/public-links/${token}/files/${file.id}`), + ], + [ + '포털 · /API/auth/me', + () => client(request(server).get('/API/auth/me'), SENDER), + ], + [ + '포털 · /api/Auth/Me', + () => client(request(server).get('/api/Auth/Me'), SENDER), + ], + ] as const) { + const denied = await blocked(); + expect({ + label, + status: denied.status, + code: body(denied).error?.code, + }).toEqual({ label, status: 404, code: 'RES_001' }); + } + // 변형은 받기 처리기에 닿지 않았다 — 받은 수가 그대로 + expect(await downloadCount()).toBe(1); + }); + + it('링크 끊기 — 그 메일의 파일 모두(되돌릴 수 없다) · 공개 화면 revoked(파일 없음) · 받기 410 · 사용량에서 바로 빠진다, 인용 속 링크의 상태', async () => { + const ME = 'lf.revoke@e2e.arasystem.test'; + const first = await uploadReady(ME, 'first file', '하나.zip'); + const second = await uploadReady(ME, 'second file!', '둘.zip'); + const { token, link } = await sendAndShare(ME, [first.id, second.id]); + const url = `${E2E_SHARE_ORIGIN}/s/${token}`; + const unknownUrl = `${E2E_SHARE_ORIGIN}/s/${randomBytes(32).toString('base64url')}`; + const otherUrl = 'https://other.example/s/abc'; + expect(link?.files.map(({ id }) => id)).toEqual([first.id, second.id]); + + // 인용 속 링크의 상태 — 누구의 링크든 상태 · 끝나는 날만, 요청 차례 그대로 + const lookup = async (urls: string[]) => + dataOf<{ links: unknown[] }>( + await client( + request(server).post('/api/share-link-lookups'), + STRANGER, + ) + .send({ urls }) + .expect(200), + ).links; + expect(await lookup([url, unknownUrl, otherUrl])).toEqual([ + { url, status: 'active', expiresAt: link?.expiresAt }, + { url: unknownUrl, status: 'unknown', expiresAt: null }, + { url: otherUrl, status: 'unknown', expiresAt: null }, + ]); + + const usedBefore = await quotaOf(ME); + const revoke = (email: string, id: string) => + client( + request(server).post(`/api/share-links/${id}/revocation`), + email, + ); + const revoked = await revoke(ME, link?.id ?? '').expect(200); + expect(dataOf(revoked)).toMatchObject({ + id: link?.id, + subject: SUBJECT, + status: 'revoked', + revokedAt: expect.any(String) as string, + files: [ + { id: first.id, status: 'revoked' }, + { id: second.id, status: 'revoked' }, + ], + }); + // 다시 끊어도 200 (지금 상태) + await revoke(ME, link?.id ?? '').expect(200); + expect(await quotaOf(ME)).toBe( + usedBefore - first.bytes.length - second.bytes.length, + ); + + expect( + dataOf( + await publicGet(`/api/public-links/${token}`).expect(200), + ), + ).toMatchObject({ status: 'revoked', files: [] }); + const gone = await publicGet( + `/api/public-links/${token}/files/${first.id}`, + ).expect(410); + expect(body(gone).error?.code).toBe('FILE_007'); + expect(await lookup([url])).toEqual([ + { url, status: 'revoked', expiresAt: link?.expiresAt }, + ]); + + // 남의 링크 · 모양이 틀린 id 는 404 + for (const [email, id] of [ + [STRANGER, link?.id ?? ''], + [ME, 'not-a-uuid'], + ]) { + const res = await revoke(email, id).expect(404); + expect(body(res).error?.code).toBe('RES_001'); + } + // 본문 검사 — 빈 목록 · 겹침 · 21개는 400 VAL_001 + for (const urls of [ + [], + [url, url], + Array.from({ length: 21 }, (_, index) => `${url}?n=${index}`), + ]) { + const res = await client( + request(server).post('/api/share-link-lookups'), + STRANGER, + ) + .send({ urls }) + .expect(400); + expect(body(res).error?.code).toBe('VAL_001'); + } + }); + + describe('DB 동시성 (실제 PostgreSQL — 대역으로는 볼 수 없다)', () => { + it('받은 수 — 상한 3 에 동시에 10번 더하면 정확히 3번만 된다 (원자적 UPDATE … WHERE download_count < 상한)', async () => { + const ME = 'lf.count@e2e.arasystem.test'; + const file = await uploadReady(ME, 'count me', '동시.zip'); + const { link } = await sendAndShare(ME, [file.id]); + const repository = app.get(FileSharingRepository, { strict: false }); + + const results = await Promise.all( + Array.from({ length: 10 }, () => + repository.countDownload(link?.id ?? '', file.id, 3, new Date()), + ), + ); + expect(results.filter(Boolean)).toHaveLength(3); + const rows: { download_count: number }[] = await dataSource.query( + 'SELECT download_count FROM share_link_files WHERE link_id = $1 AND file_id = $2', + [link?.id, file.id], + ); + expect(rows).toEqual([{ download_count: 3 }]); + }); + + it('만들기 — 같은 직원이 동시에 8개(10MiB씩)를 만들어도 보관량(64MiB)을 넘지 않는다: 정확히 6개 (DB 잠금 안에서 세고 넣는다)', async () => { + const ME = 'lf.burst@e2e.arasystem.test'; + const files = app.get(FileStorageService, { strict: false }); + const size = 10 * MEGABYTE; + + const results = await Promise.allSettled( + Array.from({ length: 8 }, (_, index) => + files.create(ME, { + name: `동시${index}.zip`, + size, + purpose: 'mail', + lastModified: null, + }), + ), + ); + const created = results.filter( + (result) => result.status === 'fulfilled', + ); + const refused = results.flatMap((result) => + result.status === 'rejected' ? [result.reason as unknown] : [], + ); + expect(created).toHaveLength(6); + expect( + refused.map((reason) => + reason instanceof BusinessException ? reason.code : 'NOT_BUSINESS', + ), + ).toEqual(['FILE_005', 'FILE_005']); + expect(await quotaOf(ME)).toBe(6 * size); + }); + }); + + it('예약 발송을 취소하면 그 링크는 없는 링크(404) · 목록에서 빠지고, 되돌린 초안은 덩이를 떼고 파일을 largeFiles 로 준다 — 다시 보내면 덩이는 하나', async () => { + const ME = 'lf.schedule@e2e.arasystem.test'; + const file = await uploadReady(ME, 'scheduled bytes', '예약.zip'); + const sendAt = new Date(Date.now() + 60 * 60 * 1_000).toISOString(); + const { sentId, token, link } = await sendAndShare(ME, [file.id], { + sendAt, + }); + // 예약 링크는 보낼 시각 전에도 열린다 + await publicGet(`/api/public-links/${token}`).expect(200); + + await client( + request(server).delete(`/api/scheduled-messages/${sentId}`), + ME, + ).expect(200); + const canceled = await publicGet(`/api/public-links/${token}`).expect( + 404, + ); + expect(body(canceled).error?.code).toBe('RES_001'); + expect((await shareLinksOf(ME)).items.map(({ id }) => id)).not.toContain( + link?.id, + ); + // 파일은 남는다 — 보내지 않은 파일로 돌아온다 + expect(await unsentIds(ME)).toContain(file.id); + + const draft = dataOf<{ + body: string; + bodyHtml: string | null; + largeFiles: { id: string; status: string }[]; + }>( + await client(request(server).get(`/api/drafts/${sentId}`), ME).expect( + 200, + ), + ); + expect(draft.body).toBe(QUOTE_TEXT); + expect(draft.bodyHtml).toBe(QUOTE_HTML_WITHOUT_MARKER); + expect(draft.largeFiles).toEqual([ + expect.objectContaining({ id: file.id, status: 'ready' }) as unknown, + ]); + + // 되돌린 초안을 다시 보내면 새 링크 — 덩이는 하나 (경계 표시가 없으면 맨 끝) + const again = await sendAndShare(ME, [file.id], { + body: draft.body, + bodyHtml: draft.bodyHtml, + }); + expect((again.message.htmlBody ?? '').split(BLOCK_START)).toHaveLength(2); + expect(again.message.body.split(`${E2E_SHARE_ORIGIN}/s/`)).toHaveLength( + 2, + ); + expect(again.token).not.toBe(token); + expect(again.link?.status).toBe('active'); + }); + }); + /** * 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다. * 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말). diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 396bfd9..1b71207 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -13,6 +13,12 @@ const Map kErrorCodeMessages = { 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', 'FILE_001': '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', 'FILE_002': '파일 크기가 허용 범위를 초과했습니다.', + 'FILE_003': '파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요.', + 'FILE_004': '파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요.', + 'FILE_005': '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.', + 'FILE_006': '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', + 'FILE_007': '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.', + 'FILE_008': '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.', 'MAIL_001': '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.', 'MAIL_002': '받는 사람 주소를 다시 확인해 주세요.', 'MAIL_003': '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.', @@ -28,6 +34,7 @@ const Map kErrorCodeMessages = { 'MAIL_013': '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.', 'MAIL_014': '이미 보낸 메일이라 취소할 수 없습니다.', 'MAIL_015': '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', + 'MAIL_016': '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.', 'CONTACT_001': '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', 'CONTACT_002': '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', }; diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 4728a48..6b453f2 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -13,6 +13,12 @@ export const ERROR_CODE_MESSAGES = { BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', FILE_001: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.', FILE_002: '파일 크기가 허용 범위를 초과했습니다.', + FILE_003: '파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요.', + FILE_004: '파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요.', + FILE_005: '대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.', + FILE_006: '서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.', + FILE_007: '이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.', + FILE_008: '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.', MAIL_001: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.', MAIL_002: '받는 사람 주소를 다시 확인해 주세요.', MAIL_003: '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.', @@ -28,6 +34,7 @@ export const ERROR_CODE_MESSAGES = { MAIL_013: '필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.', MAIL_014: '이미 보낸 메일이라 취소할 수 없습니다.', MAIL_015: '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', + MAIL_016: '대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.', CONTACT_001: '같은 이름의 그룹이 있습니다. 다른 이름을 써 주세요.', CONTACT_002: '주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)', } as const