feat(backend): 대용량 첨부 — 조각 올리기 · 악성 검사 · 공개 링크 · 내려받기

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 00:37:36 +09:00
parent 93147b88b7
commit 93154ad9a5
102 changed files with 15058 additions and 156 deletions
+6
View File
@@ -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,
],
+82 -3
View File
@@ -1,6 +1,17 @@
import { BadRequestException, HttpStatus } from '@nestjs/common';
import {
BadRequestException,
Controller,
Get,
HttpStatus,
} from '@nestjs/common';
import type {
ExpressAdapter,
NestExpressApplication,
} from '@nestjs/platform-express';
import { Test } from '@nestjs/testing';
import type { Request, Response } from 'express';
import type { IncomingMessage, ServerResponse } from 'http';
import request from 'supertest';
import { startCpuTimer } from '@/../test/cpu-time';
import { BusinessException } from '@/common/errors/business.exception';
@@ -9,6 +20,8 @@ import { MailFilterUpdateDto } from '@/modules/mail/dto/mail-filter.dto';
import { SendMessageDto } from '@/modules/mail/dto/send-message.dto';
import {
JSON_SHAPE_LIMITS,
configureApp,
createHttpAdapter,
createValidationPipe,
isDraftSaveRequest,
isSendMessageRequest,
@@ -273,7 +286,7 @@ describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () =>
});
describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => {
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 끝의 / ·쿼리는 가리지 않고, 본문 한도는 대소문자 변형도 넓게 고른다(그 변형은 라우터에서 404)', () => {
for (const [method, url] of [
['POST', '/api/drafts'],
['POST', '/API/Drafts/'],
@@ -301,7 +314,7 @@ describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () =>
});
describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
it('POST /api/messages 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => {
it('POST /api/messages 는 발송 요청이다 — 끝의 / ·쿼리는 라우터처럼 가리지 않고, 본문 한도는 대소문자 변형도 넓게 고른다(그 변형은 라우터에서 404 — 크기 때문에 413 이 되지 않게)', () => {
for (const url of [
'/api/messages',
'/api/messages/',
@@ -331,3 +344,69 @@ describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
);
});
});
/** 라우팅 대소문자 시험용 처리기 — 받기 경로와 같은 모양 */
@Controller('probe-routes')
class ProbeRoutesController {
@Get(':id/large-files/:fileId')
download(): string {
return 'ok';
}
}
describe('createHttpAdapter — 라우팅은 대소문자를 가린다 (2026-10-30 인프라 리뷰 High)', () => {
const apps: NestExpressApplication[] = [];
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
});
/** 처리기 하나만 둔 앱 — 어댑터를 주면 그것으로, 아니면 Nest 의 기본 어댑터로 */
const createProbeApp = async (
adapter?: ExpressAdapter,
): Promise<NestExpressApplication> => {
const moduleRef = await Test.createTestingModule({
controllers: [ProbeRoutesController],
}).compile();
const app = adapter
? moduleRef.createNestApplication<NestExpressApplication>(adapter, {
logger: false,
})
: moduleRef.createNestApplication<NestExpressApplication>({
logger: false,
});
apps.push(app);
return app;
};
it('소문자 경로만 처리기에 닿는다 — 대소문자 변형은 404', async () => {
const app = await createProbeApp(createHttpAdapter());
await app.init();
const server = app.getHttpServer();
await request(server).get('/probe-routes/a/large-files/b').expect(200);
for (const path of [
'/Probe-Routes/a/large-files/b',
'/probe-routes/a/LARGE-FILES/b',
'/PROBE-ROUTES/a/large-files/b',
]) {
await request(server).get(path).expect(404);
}
});
it('어댑터를 만든 뒤에 켜면 늦다 — Nest 의 ExpressAdapter 가 만들어질 때 라우터를 이미 만들어 설정 값만 바뀐다', async () => {
const app = await createProbeApp();
app.set('case sensitive routing', true);
await app.init();
await request(app.getHttpServer())
.get('/Probe-Routes/a/large-files/b')
.expect(200);
});
it('configureApp 은 대소문자를 가리지 않는 라우터로 만든 앱이면 멈춘다 — 기본 어댑터 · 만든 뒤에 켠 설정 모두', async () => {
const plain = await createProbeApp();
expect(() => configureApp(plain)).toThrow(/createHttpAdapter/);
const late = await createProbeApp();
late.set('case sensitive routing', true);
expect(() => configureApp(late)).toThrow(/createHttpAdapter/);
});
});
+72 -5
View File
@@ -1,8 +1,17 @@
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import {
ExpressAdapter,
type NestExpressApplication,
} from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { json, type NextFunction, type Request, type Response } from 'express';
import express, {
json,
type Express,
type NextFunction,
type Request,
type Response,
} from 'express';
import helmet from 'helmet';
import type { IncomingMessage, ServerResponse } from 'http';
@@ -10,6 +19,11 @@ import { BusinessException } from '@/common/errors/business.exception';
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
import {
FILE_SHARING_SETTINGS,
type FileSharingSettings,
} from '@/modules/file-sharing/file-sharing.settings';
import { createPublicHostMiddleware } from '@/modules/file-sharing/public-host';
import { DRAFTS_ROUTE, MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
import {
findJsonShapeProblem,
@@ -50,17 +64,55 @@ export const JSON_SHAPE_LIMITS: JsonShapeLimits = {
maxDepth: 32,
};
/** Express 설정 이름 — 라우팅이 대소문자를 가린다 (createHttpAdapter) */
const CASE_SENSITIVE_ROUTING = 'case sensitive routing';
/**
* 앱을 만들 HTTP 어댑터 — main.ts 와 e2e 가 **같은 함수**를 쓴다(NestFactory.create · createNestApplication 의 어댑터 인자).
*
* 라우팅은 **대소문자를 가린다** — `/api/Messages/…` · `…/LARGE-FILES/…` · `/api/public-links/…/FILES/…` · `/API/…` 는 처리기에 닿지 않고 404 RES_001.
* 대소문자를 가리지 않으면 앞단 nginx 의 받기 location(대소문자를 가리는 정규식 · prefix)이 놓친 변형이 받기 처리기까지 와서
* 동시 연결 상한(limit_conn)과 버퍼링 끄기를 비켜 갔다(2026-10-30 인프라 리뷰 High — nginx 도 고친다). 클라이언트(웹 · 앱)는 모두 소문자 경로다.
*
* Express 5 는 라우터를 처음 꺼낼 때 만들며 그때 이 설정을 읽는데, Nest 의 ExpressAdapter 는 만들어질 때 이미 `app.use` 를 부른다
* (express-adapter.js 의 생성자) — 그래서 configureApp() 에서 켜면 늦다(2026-10-30 확인: express 5.2.1 · router 2.2.0 · @nestjs/platform-express 11.2.5).
* Express 인스턴스를 직접 만들어 어댑터보다 먼저 켠다. configureApp 이 맨 앞에서 라우터가 정말 대소문자를 가리는지 확인한다
*/
export function createHttpAdapter(): ExpressAdapter {
const instance = express();
instance.enable(CASE_SENSITIVE_ROUTING);
return new ExpressAdapter(instance);
}
/**
* 이 앱의 라우터가 대소문자를 가리는가 — Express 5 의 `app.router` 가 만들어질 때 받은 값(router 패키지가 `caseSensitive` 로 둔다).
* 설정 값(`app.enabled`)이 아니라 라우터를 본다 — 앱을 만든 뒤에 설정을 켜면 값은 켜져도 라우터는 그대로라서다.
* router 가 이 이름을 바꾸면 여기서 기동이 멈춘다 — 그때는 e2e 의 대소문자 테스트로 확인하고 고친다
*/
function routesAreCaseSensitive(app: NestExpressApplication): boolean {
const instance: unknown = app.getHttpAdapter().getInstance();
const router: unknown = (instance as Partial<Express> | null)?.router;
return (
typeof router === 'function' &&
(router as { caseSensitive?: unknown }).caseSensitive === true
);
}
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`;
/** 라우터가 보는 경로 — Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다 */
/**
* 본문 한도를 고를 경로 — 쿼리를 빼고, 라우터처럼 끝의 '/' 는 가리지 않는다.
* 대소문자는 가리지 않는다 — 라우터는 가리지만(createHttpAdapter) 본문 한도는 넓게 고른다: 발송 · 임시 저장 경로의 대소문자 변형도 같은 4MB 파서로 읽은 뒤
* 라우터에서 404 RES_001 이 된다(본문 크기 때문에 413 이 되지 않고 다른 대소문자 변형처럼 404 로 같게)
*/
function routedPath(req: Pick<IncomingMessage, 'url'>): string {
return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
}
/**
* 발송 요청인가 — `POST /api/messages`.
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다.
* 라우터가 발송 핸들러로 보내는 모양(끝의 '/' · 쿼리)이면 모두 해당하고, 대소문자 변형도 넓게 해당한다 — 그 변형은 라우터에서 404 (위 routedPath).
*/
export function isSendMessageRequest(
req: Pick<IncomingMessage, 'method' | 'url'>,
@@ -173,14 +225,29 @@ function isJsonBody(req: IncomingMessage): boolean {
* HTTP 파이프라인 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다
* (따로 적으면 e2e 가 실제 응답과 다른 설정으로 돌게 된다).
*
* 앱은 `{ bodyParser: false }` 로 만들어야 한다 — 본문 파서를 여기서 직접 등록한다.
* 앱은 `createHttpAdapter()` 와 `{ bodyParser: false }` 로 만들어야 한다 — 라우팅의 대소문자 구분은 어댑터를 만들 때 켜고(여기서는 늦다),
* 본문 파서는 여기서 직접 등록한다. 대소문자를 가리지 않는 라우터로 만든 앱이면 기동을 멈춘다.
* CORS·Swagger·종료 훅은 배포 환경에 묶인 설정이라 main.ts 에 둔다.
*/
export function configureApp(app: NestExpressApplication): void {
if (!routesAreCaseSensitive(app)) {
throw new Error(
'라우팅이 대소문자를 가리지 않습니다 — 앱은 createHttpAdapter() 로 만든 어댑터로 만들어야 합니다(app.setup.ts). ' +
'예: NestFactory.create(AppModule, createHttpAdapter(), { bodyParser: false })',
);
}
// 요청 추적 ID — **가장 먼저** 붙인다. 본문 파서·helmet 보다 앞이라 그 단계의 실패(413·400·415)에도
// 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다
app.use(requestIdMiddleware);
// 공개 호스트(대용량 첨부의 내려받기 주소 — 11단계)를 포털과 따로 두면 그 호스트에서는 공개 링크 API 만, 포털에서는 공개 링크 API 를 닫는다.
// 본문 파서 앞이라 막은 요청의 본문을 읽지 않는다. 같은 주소면(개발) 아무것도 막지 않는다 (modules/file-sharing/public-host.ts)
const sharing = app.get<FileSharingSettings>(FILE_SHARING_SETTINGS, {
strict: false,
});
app.use(createPublicHostMiddleware(sharing.dedicatedPublicHost));
// 믿을 프록시 뒤에서 실제 클라이언트 IP 를 읽는다 — 로그인 실패 한도·요청 수 한도의 기준 (TRUST_PROXY).
// 운영 체인: 클라이언트 → 외부 리버스 프록시 → 프론트 nginx → 백엔드. 값이 없으면(비운영) 1
app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
@@ -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);
@@ -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)
+41
View File
@@ -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: {
@@ -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();
});
});
@@ -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<number>;
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}`);
}
}
+11 -6
View File
@@ -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<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(winstonConfig),
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
bodyParser: false,
});
// 어댑터는 createHttpAdapter — 라우팅이 대소문자를 가린다(어댑터를 만들 때 켜야 한다 — app.setup.ts). e2e 도 같은 함수를 쓴다
const app = await NestFactory.create<NestExpressApplication>(
AppModule,
createHttpAdapter(),
{
logger: WinstonModule.createLogger(winstonConfig),
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
bodyParser: false,
},
);
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 (컨테이너 healthcheck 가 읽는다)
const expressApp = app.getHttpAdapter().getInstance();
@@ -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<void> {
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<void> {
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"`);
}
}
@@ -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<void> {
await queryRunner.query(
`ALTER TABLE "stored_files" ADD "completion_started_at" TIMESTAMP WITH TIME ZONE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "stored_files" DROP COLUMN "completion_started_at"`,
);
}
}
@@ -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;
}
@@ -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<number> {
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;
}
}
}
@@ -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<string>(key)),
},
FileSharingRepository,
FileSharingService,
FileSharingCleanup,
],
exports: [FileSharingService, FILE_SHARING_SETTINGS, FileStorageModule],
})
export class FileSharingModule {}
@@ -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<ShareLinkEntity>,
@InjectRepository(ShareLinkFileEntity)
private readonly linkFiles: Repository<ShareLinkFileEntity>,
private readonly dataSource: DataSource,
) {}
/** 링크와 파일을 한 트랜잭션에 넣는다 */
async insert(record: ShareLinkRecord): Promise<void> {
await this.dataSource.transaction(async (manager) => {
await manager.insert(ShareLinkEntity, record.link);
await manager.insert(ShareLinkFileEntity, record.files);
});
}
async findByTokenHashes(
hashes: readonly Buffer[],
): Promise<ShareLinkRecord[]> {
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<ShareLinkRecord | null> {
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<ShareLinkRecord | null> {
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<ShareLinkEntity[]> {
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<void> {
await this.links.update({ id }, { sentEmailId });
}
/** 취소한 링크로 — 이미 취소했으면 그대로 (true = 이번에 바꿨다) */
async cancel(
id: string,
reason: ShareLinkCancelReason,
now: Date,
): Promise<boolean> {
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<boolean> {
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<boolean> {
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<FileLinkRetention[]> {
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<number> {
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<ShareLinkRecord[]> {
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),
}));
}
}
@@ -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<FileLinkRetention[]> {
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<string, string>();
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> = {}): 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<FileSharingSettings> = {}) {
const repository = new FakeRepository();
const views = new Map<string, StoredFileView>([
[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<string, string> = {}) =>
({ 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<unknown>): Promise<string | null> {
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<void> {
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);
});
});
});
@@ -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<Response>();
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<CreatedShareLink> {
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<ShareLinkEntity>);
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<void> {
await this.repository.markSent(linkId, sentEmailId);
}
/**
* 같은 Idempotency-Key 의 재시도가 처음 메일을 찾았다 — 그 발송이 만든 링크(보낸 메일을 모르는 것)에 보낸 사본을 잇는다.
* 결과를 모르게 끝난 첫 시도의 링크가 이렇게 제자리를 찾는다 (계약 4절 "링크를 만드는 때")
*/
async attachRetry(
owner: string,
messageId: string,
sentEmailId: string,
): Promise<void> {
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<void> {
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<ShareLinkRecord | null> {
return this.repository.findBySentEmail(owner.toLowerCase(), sentEmailId);
}
/**
* 토큰으로 링크를 찾는다 — 취소한 링크는 빼고 (메일 읽기 화면의 덩이 알아보기). 돌려준 맵의 키는 토큰
*/
async findByTokens(
tokens: readonly string[],
): Promise<Map<string, ShareLinkRecord>> {
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<string, ShareLinkRecord>();
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<Map<string, SharedFileStatus>> {
const views = await this.files.views(
records.flatMap((record) => record.files.map((file) => file.fileId)),
);
const now = new Date();
const result = new Map<string, SharedFileStatus>();
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<PaginatedResult<ShareLinkSummary>> {
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<ShareLinkSummary> {
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<ShareLinkState[]> {
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<PublicLink> {
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<ShareLinkRecord> {
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<void> {
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<string, SharedFileStatus>,
): 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<void> {
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}`,
);
}
}
}
@@ -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,
};
}
@@ -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<SharedFileStatus, 'revoked' | 'expired'>;
}
/** 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;
}
@@ -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<string, string> = {},
): 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);
});
});
@@ -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 } : {}),
},
});
};
}
@@ -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("<fileId>") · 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<void> {
const record = await this.sharing.requirePublicRecord(token);
await this.sharing.serveFile(req, res, record, fileId.toLowerCase(), {
count: true,
});
}
}
@@ -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;
}
@@ -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:
'`<SHARE_PUBLIC_URL>/s/<토큰>` 모양이 아니거나 모르는 · 보내기를 취소한 · 기록을 지운 링크는 unknown. 파일마다의 상태(검사 중 · 위험 등)는 주지 않는다.',
})
@ApiStandardResponse(ShareLinkLookupResultDto, {
description: '링크마다 상태 (요청 차례 그대로)',
})
@ApiErrorResponse('VAL_001', '1~20개 · 겹침 · 한 개 300자 초과')
async lookup(
@Body() dto: ShareLinkLookupRequestDto,
): Promise<ShareLinkLookupResultDto> {
return { links: await this.sharing.lookup(dto.urls) };
}
}
@@ -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;
}
@@ -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);
});
});
@@ -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}$/;
/** 공개 화면 경로 — `<SHARE_PUBLIC_URL>/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}`;
}
/**
* 주소가 우리 공개 화면(`<publicOrigin>/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;
}
@@ -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> = {}): 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<Parameters<typeof sharedFileStatus>[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);
}
});
});
@@ -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';
}
@@ -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');
});
});
@@ -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=:<base64>:` 를 읽는다 — 여러 알고리즘이 콤마로 이어질 수 있다(`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<Buffer> {
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<string, number>();
private readonly chunks = new Set<string>();
private readonly perFile = new Map<string, number>();
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);
}
}
@@ -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<void>((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> = {},
): 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<unknown>,
): Promise<ClamavFailure | 'NOT_CLAMAV' | null> {
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<unknown>,
): Promise<ClamavUnavailableError> {
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);
});
});
@@ -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<ClamavVerdict> {
return new Promise<ClamavVerdict>((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);
});
});
}
@@ -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;
}
@@ -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<void>;
/**
* 조각 하나를 offset 부터 쓴다 — source 를 끝까지 읽으며 SHA-256 을 함께 계산하고, 다 쓴 뒤 디스크에 내려쓴다(datasync).
* 길이가 expectedBytes 와 다르면 ChunkLengthMismatchError, 디스크가 차면 ENOSPC 오류 그대로
*/
writeChunk(
key: string,
offset: number,
expectedBytes: number,
source: Readable,
): Promise<WrittenChunk>;
/** 올리기를 끝낸다 — 읽을 수 있는 파일로 옮긴다 (원자적) */
complete(key: string): Promise<void>;
/** 다 올린 파일이 있는가 (끊긴 끝내기를 되살릴 때) */
isStored(key: string): Promise<boolean>;
/** 올리는 중의 자리가 있는가 */
isIncoming(key: string): Promise<boolean>;
/**
* 다 올린 파일을 읽는다 — 파일을 연 뒤에 돌아온다(없으면 ENOENT 오류 그대로 — 머리글을 쓰기 전에 알 수 있게).
* range 가 있으면 그 범위만(끝 포함)
*/
openRead(key: string, range?: BlobRange): Promise<Readable>;
/** 올리는 중의 자리와 다 올린 파일을 모두 지운다 — 이미 없으면 그대로 */
remove(key: string): Promise<void>;
/** 저장소의 모든 항목 (정리 작업 — 표에 없는 파일 찾기) */
list(): Promise<StoredBlob[]>;
/** 남은 공간(바이트) — 알 수 없으면 null */
freeBytes(): Promise<number | null>;
}
/** 디스크가 찼다 (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';
}
@@ -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');
}
});
});
@@ -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<unknown, string> {
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<unknown, number> {
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' },
});
}
}
@@ -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> = {}): 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);
});
});
@@ -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<RetentionInput, 'linkedUntil'>,
now: Date,
): boolean {
return file.linkedUntil !== null && file.linkedUntil > now;
}
@@ -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> = {},
): 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<string, StoredFileEntity>();
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<StoredFileEntity>,
) {
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<StoredFileEntity>) {
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<string, ClamavVerdict | Error>;
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([]);
});
});
@@ -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<ClamavVerdict>;
/** 위험 판정 소식을 받는 쪽 (메일 모듈의 실시간 알림) — 던지지 않아야 한다 */
export type FileScanListener = (event: FileScanVerdictEvent) => void;
interface ActiveScan {
readonly controller: AbortController;
readonly done: Promise<void>;
}
/**
* 악성 검사 차례 (계약 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<string, ActiveScan>();
private readonly listeners = new Set<FileScanListener>();
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}
@@ -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<StoredFileEntity> = {}) =>
({
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<string, StoredFileEntity>();
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<StoredFileEntity>,
) {
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<StoredFileEntity>) {
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<Promise<StoredBlob[]>>;
isStored: jest.Mock;
};
let cleanup: FileStorageCleanup;
beforeEach(() => {
repository = new FakeRepository();
blobs = {
remove: jest.fn().mockResolvedValue(undefined),
list: jest.fn<Promise<StoredBlob[]>, []>().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();
});
});
@@ -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<FileCleanupResult> {
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<number> {
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<number> {
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<boolean> {
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<number> {
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<number> {
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;
}
}
@@ -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);
});
});
@@ -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;
@@ -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<string>(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 {}
@@ -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<StoredFileEntity>,
@InjectRepository(StoredFileChunkEntity)
private readonly chunks: Repository<StoredFileChunkEntity>,
private readonly dataSource: DataSource,
) {}
/**
* 만들기 — 잠근 뒤 셈을 보고(`build` 가 한도를 넘으면 던진다) 넣는다. 한 트랜잭션이다
*/
createLocked(
owner: string,
now: Date,
build: (stats: CreationStats) => Promise<StoredFileEntity>,
): Promise<StoredFileEntity> {
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<StoredFileEntity | null> {
return this.files.findOneBy({ id });
}
findByIds(ids: readonly string[]): Promise<StoredFileEntity[]> {
if (ids.length === 0) return Promise.resolve([]);
return this.files.findBy({ id: In([...ids]) });
}
/** 받은 조각 — 파일마다 번호 차례 */
async receivedChunks(fileId: string): Promise<ReceivedChunk[]> {
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<Map<string, number[]>> {
const result = new Map<string, number[]>();
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<boolean> {
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<StoredFileEntity>,
): Promise<boolean> {
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<boolean> {
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<StoredFileEntity>): Promise<void> {
await this.files.update({ id }, changes);
}
/** 그 직원의 보관량 — 지금 상태로 */
async usedBytes(owner: string, now: Date): Promise<number> {
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<StoredFileEntity[]> {
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<StoredFileEntity[]> {
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<StoredFileEntity[]> {
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<StoredFileEntity[]> {
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<void> {
await this.chunks.delete({ fileId });
}
/**
* 지운 지 오래된 기록을 지운다 (정리 작업 — 조각은 함께 지워진다).
* @returns 지운 수
*/
async deleteRemovedBefore(before: Date): Promise<number> {
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<Set<string>> {
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<CreationStats> {
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),
};
}
}
@@ -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<string, StoredFileEntity>();
readonly chunks = new Map<string, ReceivedChunk[]>();
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<StoredFileEntity>,
): Promise<StoredFileEntity> {
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<StoredFileEntity>,
) {
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<StoredFileEntity>) {
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<number | null> {
return Promise.resolve(this.free);
}
}
const settings = (
overrides: Partial<FileStorageSettings> = {},
): 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> = {},
): 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<unknown>): Promise<string | null> {
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<FileStorageSettings> = {}) => {
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<ChunkUpload>[]) {
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');
});
});
@@ -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<LargeFile> {
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<StoredFileEntity>);
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<LargeFile> {
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<LargeFile> {
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<LargeFile> {
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<PaginatedResult<LargeFile>> {
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<void> {
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<FileQuota> {
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<Map<string, StoredFileView>> {
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<string, FileUseProblem>;
}> {
const found = await this.views(ids);
const usable: StoredFileView[] = [];
const problems = new Map<string, FileUseProblem>();
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<void> {
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<void> {
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<Readable> {
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<Buffer> {
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<Buffer> {
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<void> {
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<StoredFileEntity> {
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<LargeFile> {
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,
};
}
}
@@ -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 `<PROJECT_NAME>_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,
};
}
@@ -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;
}
@@ -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=:<base64>: (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=:<base64>: — 웹은 늘 보낸다',
})
@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<void> {
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);
}
}
@@ -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));
}
@@ -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<Buffer> {
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);
});
});
@@ -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<void> {
try {
await unlink(path);
} catch (error) {
if (!isMissingFile(error)) throw error;
}
}
async function exists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch (error) {
if (isMissingFile(error)) return false;
throw error;
}
}
/**
* 로컬 디스크 저장 (FILE_BLOB_STORE 의 지금 구현) — `<dir>/incoming/<id>`(올리는 중 — 선언한 크기의 파일에 조각을 제자리에 쓴다) →
* 끝내면 같은 볼륨의 `<dir>/objects/<앞 두 글자>/<id>` 로 옮긴다(rename — 크기와 상관없이 바로 끝난다).
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 컨트롤러가 형식 · 머리글을 정한다 (.claude/rules/security.md "파일 업로드").
* 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다.
*/
export class LocalDiskBlobStore implements FileBlobStore {
constructor(private readonly root: string) {}
async begin(key: string, size: number): Promise<void> {
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<WrittenChunk> {
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<void> {
const target = this.storedPath(key);
await mkdir(dirname(target), { recursive: true });
await rename(this.incomingPath(key), target);
}
isStored(key: string): Promise<boolean> {
return exists(this.storedPath(key));
}
isIncoming(key: string): Promise<boolean> {
return exists(this.incomingPath(key));
}
async openRead(key: string, range?: BlobRange): Promise<Readable> {
const handle = await open(this.storedPath(key), 'r');
// 스트림이 끝나거나 망가지면 파일도 닫는다 (autoClose 기본값)
return handle.createReadStream(
range ? { start: range.start, end: range.end } : {},
);
}
async remove(key: string): Promise<void> {
await unlinkIfExists(this.incomingPath(key));
await unlinkIfExists(this.storedPath(key));
}
async list(): Promise<StoredBlob[]> {
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<number | null> {
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<string[]> {
try {
return await readdir(path);
} catch (error) {
if (isMissingFile(error)) return [];
throw error;
}
}
/** 고친 때 — 그사이 지워졌으면 null */
private async modifiedAt(path: string): Promise<Date | null> {
try {
return (await stat(path)).mtime;
} catch (error) {
if (isMissingFile(error)) return null;
throw error;
}
}
}
@@ -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;
}
@@ -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;
}
@@ -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('첨부파일');
});
});
+7 -43
View File
@@ -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<string, AttachmentTypeRule> = 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)
);
}
@@ -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:
'공개 화면 주소 <SHARE_PUBLIC_URL>/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 */
@@ -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. 없으면 덩이는 맨 끝이다. 서식 본문은 인용 바로 앞의 빈 표시 <div data-ara-quote-start></div> 로 알린다 (11단계)',
example: 1234,
})
@IsOptional()
@IsInt({ message: '인용 시작 자리 형식이 올바르지 않습니다.' })
@Min(0, { message: '인용 시작 자리 형식이 올바르지 않습니다.' })
@Max(MAX_BODY_LENGTH, { message: '인용 시작 자리 형식이 올바르지 않습니다.' })
bodyQuoteStart?: number;
}
/** POST /api/messages — 계약 2절 "발송 본문" */
@@ -459,3 +459,33 @@ describe('sanitizeMailHtml', () => {
});
});
});
describe('sanitizeMailHtml — 대용량 첨부 덩이 표시 (11단계)', () => {
it('서비스가 넘긴 표시 값의 div 에만 data-ara-large-files 를 붙인다 — 받은 HTML 의 같은 속성 · 다른 값은 지운다', () => {
const html =
'<div data-ara-large-files="1"><p>흉내</p></div>' +
'<div data-ara-lf="guess"><p>맞히려는 값</p></div>' +
'<div data-ara-lf="secret-marker" style="color:#101828"><table><tbody><tr><td>덩이</td></tr></tbody></table></div>';
const { html: sanitized } = sanitizeMailHtml(html, new Map(), {
largeFilesMarker: 'secret-marker',
});
expect(sanitized).toBe(
'<div><p>흉내</p></div><div><p>맞히려는 값</p></div>' +
'<div style="color:#101828" data-ara-large-files><table><tbody><tr><td>덩이</td></tr></tbody></table></div>',
);
});
it('표시 값을 주지 않으면 붙이지 않는다', () => {
const { html } = sanitizeMailHtml(
'<div data-ara-lf="x" data-ara-large-files><p>글</p></div>',
);
expect(html).toBe('<div><p>글</p></div>');
});
it('div 의 배경 그림(외부 주소)은 지금처럼 차단으로 알린다', () => {
const result = sanitizeMailHtml(
'<div style="background:url(https://track.example/p.gif)">x</div>',
);
expect(result.hasBlockedImages).toBe(true);
});
});
@@ -190,8 +190,17 @@ const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
del: ['datetime'],
ins: ['datetime'],
details: ['open'],
// 서버가 확인한 대용량 첨부 덩이의 바깥 요소 표시(11단계) — 받은 HTML 의 같은 속성은 transformTags.div 가 늘 지우고, 서비스가 넘긴 표시에만 붙인다
div: ['data-ara-large-files'],
};
/**
* 대용량 첨부 덩이 표시 (계약 4절 "우리 웹메일에서 읽기") — 서비스가 확인한 덩이를 `<div data-ara-lf="<요청마다 무작위 값>">` 로 감싸 넘기면
* 그 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-lf="<이 값>">` 로 감싸 넘긴다(요청마다 무작위 — 보낸 사람이 맞힐 수 없다).
* 이 값과 같은 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);
// 주소가 남지 않은 이미지는 태그째 지운다 (깨진 이미지 아이콘 방지) — 이름을 '' 로 바꾸면 허용 목록에 없어
@@ -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<string>;
/**
* 쓰기 화면의 인용 경계 표시(`<div data-ara-quote-start></div>` — 11단계 대용량 첨부의 덩이 자리)를 남긴다 — 발송 · 임시 저장의 준비만.
* div 에만, 속성 하나짜리 여는 태그(`<div data-ara-quote-start>`)로 — 그 밖의 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,
@@ -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 =
'<p>보냅니다.</p><!--ara-large-files:v1--><table><tr><td>대용량 첨부</td></tr></table><!--/ara-large-files-->';
// 임시 저장 본문이 섞여 들어와도(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<string, unknown>)
.draft as Record<string, unknown>;
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<string, Record<string, unknown>>)
.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<string, unknown> = {}) => ({
...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<string, unknown>[]): 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([]);
});
});
@@ -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>…` (올리는 중인 올리기 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<Map<string, string>> {
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<JmapGetResult<JmapEmail>>(
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<string, unknown>)[LARGE_FILES_PROPERTY],
),
};
}
@@ -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',
`<a href="${URL_}">${URL_}</a>`,
'내려받을 때 로그인이나 비밀번호를 묻지 않습니다.',
'Large files: download by Nov 29, 2026 (KST) — no login or password needed.',
]) {
expect(block.html).toContain(text);
}
// 그림 · 아이콘 없이 테두리 표와 글자
expect(block.html).not.toMatch(/<img|border-radius/);
});
it('텍스트 — 같은 내용을 대시 줄로 가른 줄로 (파일 "- 이름 (크기)", 주소 "내려받기: 주소")', () => {
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: '<img src=x onerror=1>&"\'.zip', size: 10 }],
expiresAt: EXPIRES,
url: URL_,
});
expect(tricky.html).toContain(
'&lt;img src=x onerror=1&gt;&amp;&quot;&#39;.zip',
);
expect(tricky.html).not.toContain('<img');
});
it('보내는 정화의 허용 목록 안이다 — 정화해도 주석만 빠지고 그대로 (답장 · 전달 인용 · 되돌린 초안에서도 모양이 남는다)', () => {
expect(sanitizeOutgoingHtml(block.html)).toBe(withoutMarkers(block.html));
});
it('다시 읽을 때(받은 메일 정화)도 표 · 칸 · 글자 모양이 남는다 — 링크는 새 창으로', () => {
const reread = sanitizeMailHtml(block.html).html;
expect(reread).toContain(
'<table style="border-collapse:collapse;border:1px solid #d0d5dd',
);
expect(reread).toContain('font-size:14px;font-weight:bold');
expect(reread).toContain(
`<a href="${URL_}" target="_blank" rel="noopener noreferrer">${URL_}</a>`,
);
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 =
'<div><p>내 글</p><div data-ara-quote-start></div><p>인용</p><div data-ara-quote-start><p>둘째</p></div></div>';
const { html: stripped, position } = extractQuoteStart(html);
expect(stripped).toBe(
'<div><p>내 글</p><p>인용</p><div><p>둘째</p></div></div>',
);
expect(position).toBe('<div><p>내 글</p>'.length);
expect(extractQuoteStart('<p>없음</p>')).toEqual({
html: '<p>없음</p>',
position: null,
});
});
it('보내는 정화는 keepQuoteStart 일 때만, div 에만 표시를 남긴다 — 그 밖의 data-* 는 지운다', () => {
const input =
'<p>글</p><div data-ara-quote-start="" style="color:#000"></div><p data-ara-quote-start>p</p><div data-x="1">x</div>';
expect(sanitizeOutgoingHtml(input, { keepQuoteStart: true })).toBe(
'<p>글</p><div data-ara-quote-start></div><p>p</p><div>x</div>',
);
// 남기지 않을 때는 표시 속성만 빠지고 div 는 지금처럼 (style 도 그대로)
expect(sanitizeOutgoingHtml(input)).toBe(
'<p>글</p><div style="color:#000"></div><p>p</p><div>x</div>',
);
});
it('HTML 덩이는 그 자리(없으면 맨 끝)에 · 원문에서 표시 사이를 찾아 뗀다', () => {
const html = '<div><p>내 글</p><p>인용</p></div>';
const at = '<div><p>내 글</p>'.length;
const inserted = insertHtmlBlock(html, at, block.html);
expect(inserted).toBe(`<div><p>내 글</p>${block.html}<p>인용</p></div>`);
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(
`<a href="${URL_}">x</a><a HREF = '${URL_}'>y</a><a href="mailto:a@b">z</a>`,
10,
),
).toEqual([URL_, 'mailto:a@b']);
expect(
hrefsIn('<a href="1"></a><a href="2"></a><a href="3"></a>', 2),
).toEqual(['1', '2']);
const cpuMs = startCpuTimer();
hrefsIn(`href${' '.repeat(200_000)}`, 10);
hrefsIn('href="'.repeat(100_000), 10);
expect(cpuMs()).toBeLessThan(2_000);
});
});
@@ -0,0 +1,239 @@
import { formatFileSize } from '@/shared/utils/format';
import { formatKstDate, formatKstDateEnglish } from '@/shared/utils/kst-date';
/**
* 대용량 첨부 덩이 (순수 함수) — 서버가 보내는 메일 본문에 넣는 내려받기 안내 (계약 4절 "덩이 — 서버가 본문에 넣는 안내", 디자인 README "11단계 › 본문 덩이").
*
* - **자리**: 내 글 · 서명 아래, 인용 위 — 서식 본문은 쓰기 화면이 둔 첫 경계 표시 `<div data-ara-quote-start></div>` 자리, 텍스트는 `bodyQuoteStart`. 없으면 맨 끝
* - **HTML 모양**: 보내는 정화의 허용 목록 안의 태그 · style 값으로만 — 다시 읽을 때 · 답장 · 전달의 인용 · 보내기 취소로 되돌릴 때도 모양이 남게
* (sanitize-outgoing-html.spec.ts 가 이 덩이를 정화해도 그대로인지 본다). 그림 · 뱃지 · 알약 없이 테두리 표와 글자. 링크에는 style 을 두지 않는다(정화가 지운다)
* - **표시**: 앞뒤 주석 `<!--ara-large-files:v1-->` · `<!--/ara-large-files-->` — 보내는 정화는 HTML 주석을 지우므로 사용자가 쓴 본문(인용 포함)에는
* 이 표시가 들어갈 수 없다(서버만 넣는다). 엔진은 저장한 메일의 주석을 그대로 둔다(2026-10-30 개발 엔진으로 확인 — 되돌린 초안의 rawHtmlBody 에 남는다)
* - **텍스트**: 같은 내용을 줄로 — 대시 줄로 가르고 앞뒤에 빈 줄
*
* ⚠️ 덩이를 고치면 그 전에 보낸 메일의 텍스트 덩이를 보내기 취소로 되돌릴 때 떼지 못한다(글 그대로일 때만 뗀다) — 표시의 판(v1)을 올리고 옛 판도 알아보게 한다
*/
/** 덩이의 앞뒤 표시 (HTML 주석) */
export const LARGE_FILES_BLOCK_START = '<!--ara-large-files:v1-->';
export const LARGE_FILES_BLOCK_END = '<!--/ara-large-files-->';
/** 쓰기 화면이 인용("원본 메일" 머리) 바로 앞에 두는 빈 표시 — 보내는 정화가 이 속성만 div 에 남긴다(keepQuoteStart) */
export const QUOTE_START_ATTRIBUTE = 'data-ara-quote-start';
/** 정화한 HTML 의 표시 — 정화가 속성 하나짜리 여는 태그로 다시 쓴다(빈 값은 이름만) */
const QUOTE_START_OPEN = `<div ${QUOTE_START_ATTRIBUTE}>`;
const DIV_CLOSE = '</div>';
/** 텍스트 덩이를 가르는 줄 */
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;
/** 공개 화면 주소 (`<SHARE_PUBLIC_URL>/s/<토큰>`) */
readonly url: string;
}
export interface LargeFileBlock {
readonly html: string;
readonly text: string;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** 덩이를 만든다 — 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) =>
`<tr><td colspan="2" style="${style}">${content}</td></tr>`;
const fileRows = input.files
.map(
(file) =>
`<tr><td style="padding:2px 8px 2px 16px;font-size:13px;color:#101828">${escapeHtml(file.name)}</td>` +
`<td style="padding:2px 16px 2px 8px;font-size:13px;color:#667085;text-align:right;white-space:nowrap">${formatFileSize(file.size)}</td></tr>`,
)
.join('');
const html =
LARGE_FILES_BLOCK_START +
`<table style="border-collapse:collapse;border:1px solid #d0d5dd;margin:16px 0;font-family:${BLOCK_FONT}"><tbody>` +
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',
`<span style="padding-right:8px">내려받기</span><a href="${url}">${url}</a>`,
) +
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),
) +
'</tbody></table>' +
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')
);
}
/**
* 정화한 서식 본문의 인용 경계 표시를 **모두** 지운다 — 받는 사람에게 가지 않고, 임시 저장본 · 지문에도 들지 않는다.
* 빈 표시는 태그째, 안에 내용이 있는 표시는 속성만 뗀다. 정화가 표시를 속성 하나짜리 여는 태그로 다시 쓰므로 글자 그대로 찾는다
* (사용자 글의 `<` 는 정화가 `&lt;` 로 바꿔 이 모양이 될 수 없다).
* @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 += '<div>';
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<string>();
for (const match of html.matchAll(HREF)) {
const value = (match[1] ?? match[2] ?? '').replace(/&amp;/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;
}
@@ -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;
@@ -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<ShareLinkEntity> = {}): 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> = {},
): OutgoingMessage => ({
to: [{ name: null, email: 'partner@example.test' }],
cc: [],
bcc: [],
subject: '도면 보내드립니다',
body: '내 글\n\n----- 원본 메일 -----\n> 인용',
htmlBody: '<div><p>내 글</p><p>인용</p></div>',
attachments: [],
...overrides,
});
const PLAN: LargeFilePlan = {
fileIds: [FILE_A, FILE_B],
htmlQuoteStart: '<div><p>내 글</p>'.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<FileScanListener>[0]) => listener?.(event),
};
}
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
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<string, FileUseProblem>([[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<Promise<SentMessage>, [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 = `<div><p>내 글</p>${sentBlock.html}<p>인용</p></div>`;
it('표시가 없으면 DB 를 보지 않는다', async () => {
const { service, sharing } = setup();
expect(
await service.recognize(ARA, '<p>그냥 글</p>', 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(
/^<div><p>내 글<\/p><div data-ara-large-files><table/,
);
// 사내 받는 사람(링크 주인이 아님)에게는 받은 수가 없다
const theirs = await service.recognize(DOYUN, html, ARA.email);
expect(theirs?.largeFiles[0].downloads).toBeNull();
// 끊은 링크 — 끊은 때를 함께 준다 (화면의 "…에 끊었습니다")
const revokedAt = new Date('2026-11-02T03:04:05.000Z');
sharing.findByTokens.mockResolvedValue(
new Map([[TOKEN, record({ revokedAt, revokedBy: 'owner' })]]),
);
const revoked = await service.recognize(ARA, html, ARA.email);
expect(revoked?.largeFiles[0].revokedAt).toBe(revokedAt.toISOString());
});
it('링크 주인이 보낸 사람이 아니면(바깥 사람의 인용 · 전달) 알아보지 않는다', async () => {
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> = {}): 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 = '<div><p>내 글</p><p>인용</p></div>';
const originalText = '내 글\n\n----- 원본 메일 -----\n> 인용';
const result = await service.draftView(
ARA,
draft({
isCanceledSend: true,
rawHtmlBody: insertHtmlBlock(
originalHtml,
'<div><p>내 글</p>'.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 = `<p>글</p>${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' },
});
});
});
});
@@ -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<string> = 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<SentMessage>;
/** 읽기 화면이 알아본 덩이 — 표시를 붙일 원문과 파일 */
export interface RecognizedLargeFiles {
readonly largeFiles: MessageLargeFile[];
/** 덩이를 `<div data-ara-lf="<marker>">` 로 감싼 원문 HTML (정화 전) */
readonly html: string;
readonly marker: string;
}
/** 보낸 사람의 링크 하나 (계약 4절 ShareLink) — 메일 제목을 더한다 */
export type ShareLinkView = ShareLinkSummary & {
readonly subject: string | null;
};
/** 쓸 수 없는 대용량 파일 — 400 MAIL_016 (메일은 나가지 않았다 · 저장하지 않았다) */
function unusableFiles(
problems: ReadonlyMap<string, FileUseProblem>,
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<SentMessage> {
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<void> {
await this.sharing
.attachRetry(user.email, messageId, sentId)
.catch((error: unknown) =>
this.warn(
'같은 키 재시도의 링크에 보낸 메일 id 를 잇지 못했습니다',
user,
error,
),
);
}
/**
* 붙잡은 발송을 취소했다(9단계 — 엔진에서 확인된 뒤) — 그 발송이 만든 링크를 취소한 링크로 두고, 파일은 임시 저장 기간으로 돌린다
* (되돌린 초안이 쓴다 — 보내지 않은 파일 목록에 다시 나온다). 실패해도 취소는 된 것이다 — 경고만
*/
async onPendingSendCanceled(
user: AuthenticatedUser,
sentId: string,
): Promise<void> {
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<void> {
if (fileIds.length === 0) return;
await this.requireUsable(user, fileIds, 'draft');
}
/** 저장했다 — 그 파일들을 "임시 저장에 실린 파일"로 (남는 기간이 는다). 실패해도 저장은 된 것이다 — 경고만 */
async afterDraftSaved(
user: AuthenticatedUser,
fileIds: readonly string[],
): Promise<void> {
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 가 `<SHARE_PUBLIC_URL>/s/<토큰>` 이고 토큰이 우리 기록에 있으며(취소한 링크는 빼고 · 10개까지),
* 그 링크를 만든 직원이 그 메일의 보낸 사람(From — 대소문자 무시)이다. 아니면 null(본문 글 그대로)
*/
async recognize(
user: AuthenticatedUser,
rawHtmlBody: string,
from: string | null,
): Promise<RecognizedLargeFiles | null> {
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) +
`<div ${LARGE_FILES_MARKER_ATTRIBUTE}="${marker}">` +
rawHtmlBody.slice(start, end) +
'</div>' +
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<void> {
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<PaginatedResult<ShareLinkView>> {
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<ShareLinkView> {
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<StoredFileView[]> {
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<Map<string, string>> {
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)}`,
);
}
}
+2 -2
View File
@@ -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';
+9
View File
@@ -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<string>(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,
],
+17
View File
@@ -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<MessageSource>;
/**
* 메일 제목 (11단계 — 보낸 대용량 첨부 링크 목록이 보낸 메일 제목을 함께 보인다) — 한 번에 읽는다.
* 없는 메일(지움)은 결과에 들지 않는다. 엔진을 읽지 못하면 그 실패(MAIL_001 등)를 던진다 — 부르는 쪽이 제목 없이 답한다
*/
getMessageSubjects(
user: string,
ids: readonly string[],
): Promise<Map<string, string>>;
/** 그 메일에 실제로 있는 첨부만 내려준다 (스트리밍) */
openAttachment(
user: string,
@@ -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> = {}): 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<void, [readonly string[]]>;
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<void, [readonly string[]]>(),
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<void, [string]> },
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<SentMessage>,
) => deliver(message),
);
service = createService(
provider,
settings,
fakeSignatures(),
{ invalidate: jest.fn<void, [string]>() },
fakeNinthStep(),
largeFiles,
);
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
const htmlWithQuote =
'<div><p>내 글</p><div data-ara-quote-start></div><p>----- 원본 메일 -----</p></div>';
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(
'<div><p>내 글</p><p>----- 원본 메일 -----</p></div>',
);
expect(plan).toEqual({
fileIds: [FILE_A, FILE_B],
htmlQuoteStart: '<div><p>내 글</p>'.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: '<p>덩이 있음</p>', textBody: '덩이 있음' }),
);
largeFiles.draftView.mockResolvedValue({
rawHtmlBody: '<p>뗌</p>',
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('<p>뗌</p>');
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: '<p>글</p><table><tr><td>덩이</td></tr></table>' }),
);
largeFiles.recognize.mockResolvedValue({
html: '<p>글</p><div data-ara-lf="m1"><table><tr><td>덩이</td></tr></table></div>',
marker: 'm1',
largeFiles: [{ fileId: FILE_A }],
});
const detail = await service.getMessage(user, 'm1');
expect(detail.htmlBody).toContain('<div data-ara-large-files><table>');
expect(detail.largeFiles).toEqual([{ fileId: FILE_A }]);
expect(largeFiles.recognize).toHaveBeenCalledWith(
user,
'<p>글</p><table><tr><td>덩이</td></tr></table>',
'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');
});
});
+194 -39
View File
@@ -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<RecognizedLargeFiles | null> {
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<SentMessage> {
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<SendHold | null> {
const { undoSendSeconds } = await this.userSettingsOf(user);
@@ -917,8 +992,7 @@ export class MailService {
user: AuthenticatedUser,
dto: DraftBodyDto,
): Promise<SavedDraft> {
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<SavedDraft> {
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<SavedDraft> {
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<Draft> {
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<SentMessage> {
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<OutgoingMessage> {
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<PreparedCompose> {
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 를 보지 않는다) */
+48 -1
View File
@@ -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)[];
}
/** 주고받은 주소 하나 — 보낸 메일의 받는 사람·참조·숨은참조, 받은 메일의 보낸 사람 */
+58 -2
View File
@@ -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<void> {
await this.largeFiles.serveMessageFile(user, id, fileId, req, res);
}
@Get(':id/source')
@SkipTransform()
@ApiOperation({
@@ -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();
});
});
@@ -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 };
}
}
@@ -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);
});
});
@@ -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)
@@ -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);
}
}
@@ -19,6 +19,8 @@ const prod = (extra: Record<string, string> = {}) => ({
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<string, string>) => ({
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 가 설정되지 않았습니다/,
+201 -2
View File
@@ -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<string, unknown>,
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<string, unknown>,
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) {
@@ -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);
});
});
+11
View File
@@ -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),
};
@@ -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);
});
});
+56
View File
@@ -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();
}
+30 -1
View File
@@ -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');
});
});
+29
View File
@@ -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]}`;
}
@@ -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);
});
});
+106
View File
@@ -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 그대로 (따옴표 포함 — `"<id>"`) */
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) };
}
+48
View File
@@ -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');
});
});
+63
View File
@@ -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}`;
}
+47
View File
@@ -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', () => {
+47 -14
View File
@@ -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/<id>' → '/api/public-links/<43자>/files/<id>'
*/
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}`;
}
@@ -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);
});
});
+22
View File
@@ -0,0 +1,22 @@
/**
* 상대가 연결을 끊어 흐름이 멈췄을 때의 오류인가 (순수 함수) — 요청 본문을 읽다(클라이언트가 올리기를 끊음) · 응답을 쓰다(받는 쪽이 내려받기를 끊음).
* 이런 오류는 서버 장애가 아니라 로그를 error 로 남기지 않는다. 디스크 · 그 밖의 오류(EIO · EACCES 등)는 여기에 들지 않는다 — 그대로 500 · error 로 남긴다.
*
* ⚠️ "스트림이 destroyed 인가"로 가르지 않는다 — `for await` · pipeline 은 어느 쪽이 실패해도 원본 스트림을 destroy 하므로,
* 디스크 오류까지 "끊김"으로 보게 된다 (2026-10-30 — 조각 올리기의 디스크 오류가 400 으로 숨을 뻔했다)
*/
const STREAM_ABORT_CODES: ReadonlySet<string> = 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);
}