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);
|
||||
}
|
||||
+1437
-8
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user