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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 00:37:36 +09:00
parent 93147b88b7
commit 93154ad9a5
102 changed files with 15058 additions and 156 deletions
@@ -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"`,
);
}
}