generated from ttipo/project-base
feat(backend): 대용량 첨부 — 조각 올리기 · 악성 검사 · 공개 링크 · 내려받기
- file-storage: /api/file-uploads(만들기 · 16MiB 조각 PUT — Content-Digest · datasync · 같은 조각은 하나만 · 끝내기 · 상태 · 지우기),
/api/file-quota. 보관량(직원 · 서버 · 디스크 여유)은 pg_advisory_xact_lock 안에서 센다. 저장 포트 FILE_BLOB_STORE(지금은 로컬 볼륨).
ClamAV INSTREAM 을 직접 구현했다 — 판정 글은 한 곳(암호 · 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error),
다 보낸 뒤 판정 대기 30분 + 1MB 마다 1초 · TCP keepalive. 검사 차례는 DB pending(동시 2), 정리 cron.
- file-sharing · mail: 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 본문에 덩이를 넣고(표시는 서버만 —
두 정화기가 사용자 HTML 의 data-ara-* · 주석을 지운다), 링크는 확실한 실패(SendNotStartedException)에서만 거둔다.
공개 /api/public-links/{token} · 파일 받기(Range · 조건부 UPDATE 로 받은 수), 링크 끊기 · 목록 · 읽기 화면의 largeFiles.
- 공개 호스트 미들웨어는 원문 · 라우터 경로 · WHATWG URL · 퍼센트를 푼 원문을 모두 보고(리뷰 — 절대 주소 꼴 · \ 요청이 막기를 지났다),
라우팅은 대소문자를 가린다(createHttpAdapter — /FILES/ 같은 변형이 앞단 상한을 비켜 갔다).
- env: SHARE_PUBLIC_URL(nginx 와 같은 모양 검사 · 운영에서 포털과 같은 호스트면 기동 중단) · MAIL_LARGE_FILE_* · FILE_STORAGE_* · CLAMAV_*.
- 에러 코드 FILE_003 ~ FILE_008 · MAIL_016 — 웹 · 앱 사전 다시 생성. 마이그레이션 둘.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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('첨부파일');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
'<img src=x onerror=1>&"'.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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 덩이를 만든다 — HTML(앞뒤 표시 포함)과 텍스트에 같은 내용 */
|
||||
export function buildLargeFileBlock(
|
||||
input: LargeFileBlockInput,
|
||||
): LargeFileBlock {
|
||||
const total = input.files.reduce((sum, file) => sum + file.size, 0);
|
||||
const title = `대용량 첨부 ${input.files.length}개 (합계 ${formatFileSize(total)})`;
|
||||
const until = `${formatKstDate(input.expiresAt)}까지 내려받을 수 있습니다 (한국 시간)`;
|
||||
const noLogin = '내려받을 때 로그인이나 비밀번호를 묻지 않습니다.';
|
||||
const english = `Large files: download by ${formatKstDateEnglish(input.expiresAt)} (KST) — no login or password needed.`;
|
||||
const url = escapeHtml(input.url);
|
||||
|
||||
const cell = (style: string, content: string) =>
|
||||
`<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')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정화한 서식 본문의 인용 경계 표시를 **모두** 지운다 — 받는 사람에게 가지 않고, 임시 저장본 · 지문에도 들지 않는다.
|
||||
* 빈 표시는 태그째, 안에 내용이 있는 표시는 속성만 뗀다. 정화가 표시를 속성 하나짜리 여는 태그로 다시 쓰므로 글자 그대로 찾는다
|
||||
* (사용자 글의 `<` 는 정화가 `<` 로 바꿔 이 모양이 될 수 없다).
|
||||
* @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(/&/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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 를 보지 않는다) */
|
||||
|
||||
@@ -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)[];
|
||||
}
|
||||
|
||||
/** 주고받은 주소 하나 — 보낸 메일의 받는 사람·참조·숨은참조, 받은 메일의 보낸 사람 */
|
||||
|
||||
@@ -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 가 설정되지 않았습니다/,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user