generated from ttipo/project-base
docs: 11단계 대용량 첨부 — 계약 4절 · 디자인 보드 다섯 · README
일반 첨부 한도를 넘는 파일을 로그인 없이 받는 링크로 보내는 기능의 계약과 화면 원본이다. 계약 4절은 2026-10-30 확정한 한도(파일 2GB · 메일 10개 · 직원 10GB · 30일)와 조각 올리기 · 악성 검사(ClamAV 실측 판정) · 공개 호스트 분리 · 받기 동시 연결 상한 · 운영 확인을 담는다. 리뷰에서 바꾼 것(경로 대소문자 · 공개 호스트 경로 해석 · 판정 대기 30분 + 1MB 마다 1초 · 전체 상한을 공개 400 · 앱 안 200 으로 나눔)과 알려진 한계도 함께 적었다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+684
-3
@@ -9,10 +9,13 @@
|
||||
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
|
||||
- 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }`
|
||||
- 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001`
|
||||
(11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다)
|
||||
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
|
||||
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
|
||||
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
|
||||
개발은 vite 프록시가 넘긴다. 그래서 CORS 가 필요 없고 CSP `connect-src 'self'` 로 충분하다.
|
||||
- **경로는 대소문자를 가린다** — 이 문서의 경로는 모두 소문자이고, 대소문자 변형(`/API/…` · `/api/Messages/…` · `…/LARGE-FILES/…` · `/api/public-links/…/FILES/…`)은
|
||||
처리기에 닿지 않고 404 `RES_001` 이다(2026-10-30 — 앞단 nginx 의 받기 location 이 놓친 변형이 동시 연결 상한을 비켜 갔다, 4절 "운영 확인").
|
||||
- **출처 검사(CSRF)**: 상태를 바꾸는 요청(POST·PATCH·PUT·DELETE)은 브라우저가 붙이는 `Sec-Fetch-Site`·`Origin` 을 검사한다.
|
||||
다른 사이트에서 온 요청은 403 `AUTH_003`. 출처 정보가 아예 없는 요청(브라우저가 아닌 클라이언트)은 통과한다.
|
||||
- **프록시가 직접 내는 오류**: 프론트 nginx 가 백엔드에 닿기 전에 끝내는 요청도 표준 실패 형식으로 온다 —
|
||||
@@ -169,6 +172,7 @@ interface MessageDetail extends MessageSummary {
|
||||
isHtmlBodyOmitted: boolean // HTML 이 있었지만 정화 한도를 넘어 htmlBody 를 비웠으면 true (아래 "읽기 한도")
|
||||
isBodyTruncated: boolean // 보여 줄 본문(htmlBody — null 이거나 공백뿐이면 textBody)이 읽기 한도에서 잘렸으면 true
|
||||
attachments: MessageAttachment[]
|
||||
largeFiles: MessageLargeFile[] // 우리 서버가 넣은 대용량 첨부 덩이의 파일 — 링크 주인이 이 메일의 보낸 사람일 때만, 없으면 빈 배열 (11단계 — 4절 "우리 웹메일에서 읽기")
|
||||
}
|
||||
|
||||
interface Attachment { id: string; name: string; type: string; size: number }
|
||||
@@ -224,12 +228,13 @@ HTML 은 정화하기 **전에** 길이(HTML 부분이 여럿이면 이어 붙
|
||||
| `PATCH /api/messages` | `{ ids, isRead?, isFlagged?, mailboxId?, isJunk? }` — 여러 통 (아래 "여러 통 한꺼번에") | 200 `MessagesPatched` — 붙잡힌 메일(9단계)의 `mailboxId` · `isJunk` 는 바꾸지 않고 `failedIds` 로 | 400 `VAL_001`, 404 `RES_001`(없는 `mailboxId` — 아무것도 바꾸지 않음), 503 `MAIL_001` |
|
||||
| `DELETE /api/messages/{id}` | — | 204 — 휴지통이 아니면 휴지통으로 이동, 휴지통에만 있으면 영구 삭제(확인한 상태 그대로일 때만 — 아래). 휴지통과 다른 메일함에 함께 있으면 휴지통에서만 뺀다. **웹 클라이언트는 쓰지 않는다**(3단계 — 아래 "여러 통 한꺼번에") | 404 `RES_001`, 409 `MAIL_015`(붙잡힌 메일 — 9단계), 503 `MAIL_001`(확인하는 사이 계속 바뀜 — 지우지 않았다) |
|
||||
| `POST /api/message-deletions` | `{ ids }` — 휴지통에서 영구 삭제, 여러 통 (아래 "여러 통 한꺼번에") — 붙잡힌 메일(9단계)은 건너뛴다(`skippedIds`) | 200 `MessageDeletionResult` | 400 `VAL_001`, 503 `MAIL_001`(확인하는 사이 계속 바뀜 · 휴지통 없음 — 지우지 않았다) |
|
||||
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 (예약은 `sendAt` — 9단계) | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과" + 붙잡아 둔 발송 `pendingSend` — 9단계) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) |
|
||||
| `POST /api/messages` | 아래 발송 본문 + `Idempotency-Key` 헤더 (예약은 `sendAt` — 9단계) | 201 `SentMessage` (보낸편지함의 메일 id + 받지 못한 사람 — 아래 "받는 사람별 결과" + 붙잡아 둔 발송 `pendingSend` — 9단계) | 400 `MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_010`(받는 사람 모두 거절 — 없는 주소 등)·`MAIL_016`(대용량 첨부 — 11단계, 4절)·`VAL_001`, 403 `AUTH_003`(보낼 권한 없음), 409 `MAIL_005`(같은 키·다른 내용), 413 `FILE_002`(첨부 합계 초과 포함), 429 `REQ_001`(엔진 발송 한도·같은 키 처리 중·엔진 장애 직후 30초), 503 `MAIL_001` · `MAIL_011`(받는 사람 모두 지금은 받을 수 없음) |
|
||||
| `POST /api/mail-attachments` | multipart `file` 1개 | 201 `Attachment` | 400 `FILE_001`, 413 `FILE_002` |
|
||||
| `GET /api/messages/{id}/attachments/{attachmentId}` | — | 파일 (표준 래퍼 없음, `Content-Disposition: attachment`) | 404 `RES_001` |
|
||||
| `GET`·`HEAD /api/messages/{id}/large-files/{fileId}` | `Range`(선택) | 그 메일의 대용량 첨부 파일 (표준 래퍼 없음, 200 · 206 · 416 — 11단계, 4절 "우리 웹메일에서 읽기") | 404 `RES_001`, 409 `FILE_008`(검사 중), 410 `FILE_007`(더 받을 수 없음), 503 `MAIL_001` |
|
||||
| `GET /api/messages/{id}/source` | — | 받은 그대로의 메일 `message/rfc822` (표준 래퍼 없음, `Content-Disposition: attachment; filename="<제목>.eml"`) — 4단계 | 404 `RES_001`, 503 `MAIL_001` |
|
||||
| `GET /api/drafts/{id}` | — | 200 `Draft` (아래 "임시 저장") | 404 `RES_001`(없음·초안이 아님), 503 `MAIL_001` |
|
||||
| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` |
|
||||
| `POST /api/drafts` | 초안 본문 | 201 `DraftSaved` | 400 `VAL_001`·`MAIL_002`·`MAIL_003`·`MAIL_004`·`MAIL_006`·`MAIL_007`·`MAIL_016`(대용량 첨부 — 11단계, 4절)·`FILE_001`, 413 `FILE_002`, 503 `MAIL_001` |
|
||||
| `PUT /api/drafts/{id}` | 초안 본문 (전체 교체) | 200 `DraftSaved` — **새 id** | 위와 같음 + 404 `RES_001`(초안이 아닌 id — 지우지 않음) + 409 `MAIL_008`(다른 곳에서 이미 바뀜 — 저장 안 함) |
|
||||
| `GET /api/mail-signature` | — | 200 `MailSignature` (아래 "서명") | — |
|
||||
| `PUT /api/mail-signature` | `{ bodyHtml, body, isForNew, isForReply }` | 200 `MailSignature` | 400 `VAL_001` |
|
||||
@@ -314,7 +319,9 @@ interface MailQuota {
|
||||
"attachments": [{ "id": "<업로드 응답 id>", "name": "체크리스트.xlsx" }], // 선택, 20개 이하 (넘으면 MAIL_006)
|
||||
"inReplyTo": "<답장할 메일의 id>", // 선택 — 스레드 헤더를 서버가 채운다
|
||||
"draftId": "<이어 쓴 임시 저장본 id>", // 선택 — 보낸 뒤 서버가 그 초안을 지운다 (아래 "임시 저장")
|
||||
"sendAt": "2026-10-30T00:00:00.000Z" // 선택 — 예약 발송: 지금부터 5분 뒤 ~ 7일 안 (9단계 "보내기 취소 · 예약 발송")
|
||||
"sendAt": "2026-10-30T00:00:00.000Z", // 선택 — 예약 발송: 지금부터 5분 뒤 ~ 7일 안 (9단계 "보내기 취소 · 예약 발송")
|
||||
"largeFileIds": ["<올리기 id>"], // 선택 — 이 메일에 새로 싣는 내 대용량 파일(링크로 보낸다) (11단계 — 4절 "발송 · 임시 저장과 함께")
|
||||
"bodyQuoteStart": 1234 // 선택 — body 에서 인용이 시작하는 자리 = 덩이를 넣을 자리 (4절)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -438,6 +445,7 @@ interface RejectedRecipient {
|
||||
|
||||
### 첨부 업로드 한도
|
||||
|
||||
- 한도를 넘는 파일은 일반 첨부가 아니라 **대용량 첨부(링크)** 로 보낸다 — 4절(11단계). 아래 일반 첨부의 한도 · 흐름은 그대로다.
|
||||
- 파일당 `MAIL_ATTACHMENT_MAX_SIZE_MB` (기본 20MB) — 넘으면 413 `FILE_002`
|
||||
- 메일 한 통의 첨부 합계(전달하는 원본 첨부 포함, 같은 첨부를 두 번 붙이면 두 번 센다) `attachmentTotalMaxBytes`
|
||||
(`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 기본 25MB) — 넘으면 발송이 413 `FILE_002`.
|
||||
@@ -465,6 +473,7 @@ interface Draft {
|
||||
attachments: Attachment[] // 이 판의 첨부 — id 가 저장할 때마다 바뀐다
|
||||
inReplyTo: string | null // 답장할 메일 id (없거나 원 메일이 사라졌으면 null)
|
||||
updatedAt: string // 마지막으로 저장한 시각 (ISO 8601 UTC)
|
||||
largeFiles: (LargeFile | MissingLargeFile)[] // 대용량 첨부 — 내 파일(올리는 중 포함). id 는 저장해도 바뀌지 않는다 (11단계 — 4절 "임시 저장")
|
||||
}
|
||||
|
||||
interface DraftSaved { id: string; attachments: Attachment[]; updatedAt: string }
|
||||
@@ -789,6 +798,7 @@ data: {"reason":"token-expiring"}
|
||||
| `new-mail` | 받은편지함에 새로 배달된 메일이 있을 때 — `$draft` 아님 · `$seen` 아님 · 보낸 사람이 나 아님. 새것부터 최대 5통(`count` 는 전체 수). 같은 변화의 `state` 다음에 온다 |
|
||||
| `ping` | 25초마다(직원마다 한 간격 — 새 연결의 첫 ping 은 더 빨리 올 수 있다), 그리고 `upstream` 이 바뀌면 그때 바로. `upstream: false` 면 서버와 엔진 사이가 끊겨 다시 잇는 중이다 — 그동안 클라이언트는 1분 확인으로 돌아간다. 다시 잇는 중에 새로 연결하면 마지막으로 알던 `state` 와 `ping {upstream:false}` 가 바로 온다 |
|
||||
| `end` | 서버가 곧 닫는다 — `token-expiring`(액세스 토큰이 끝나기 30초 전 — 남은 시간이 30초 이하인 토큰(세션이 끝나기 30초 안에 갱신한 토큰)이면 토큰이 실제로 끝날 때) · `server-shutdown`(서버 종료 · 재시작) |
|
||||
| `large-file` | 내가 올린 대용량 파일이 악성 검사에서 위험으로 판정됐을 때 — `{"fileId","name","scanStatus":"infected"}` (11단계 — 4절 "악성 검사"). 지금 클라이언트는 모르는 이벤트를 버린다 |
|
||||
|
||||
| 연결 전 실패 (표준 실패 형식 JSON) | 코드 |
|
||||
| --- | --- |
|
||||
@@ -1369,3 +1379,674 @@ interface DirectoryEntry {
|
||||
상태에는 연 직원의 주소를 함께 싣고, 지금 로그인한 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기로 돌아온 경우 — 2026-10-29 리뷰).
|
||||
- **읽기 화면 ⋯**: 보낸 사람 주소가 내 주소록에 없으면 「보낸 사람을 주소록에 추가」(이름 · 주소를 채운 연락처 추가 창), 있으면 「주소록에서 보기」.
|
||||
판단은 메뉴를 열 때 `GET /api/contacts?q=<보낸 사람 주소>` 로 알림 없이 하고, 주소가 같은(대소문자 무시) 연락처가 있는지 본다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 대용량 첨부 — 11단계 (2026-10-30 확정)
|
||||
|
||||
> 2026-10-30 에 확정한 제품 · 기술 결정(사용자 "추천대로")과 디자인 보드를 그리며 정한 것(같은 날 — 이쪽이 앞선다)을 계약으로 옮겼고, 같은 날 사용자가 캔버스와 함께 확정했다(아래 "정한 것"의 추천도 그대로).
|
||||
> 백엔드 · 프론트가 이 절로 함께 만들고, 구현하며 바뀌면 이 절부터 고친다. "구현하며 정함"은 구현 때 값을 정해 여기에 적을 곳이다.
|
||||
> 화면 모양 · 글은 디자인 README "11단계"가 정한다 — 이 절은 데이터와 규칙만 다룬다.
|
||||
|
||||
일반 첨부 한도(파일 20MB · 합계 25MB — 2절 "첨부 업로드 한도")를 넘는 파일은 **메일 엔진이 아니라 우리 서버에** 올려 두고, 메일 본문에는 내려받기 링크를 넣는다(이하 "덩이").
|
||||
받는 사람은 로그인 없이 공개 화면 `<SHARE_PUBLIC_URL>/s/{token}` 에서 받는다. 보관은 보낸 날(한국)부터 30일째 날 끝까지다.
|
||||
|
||||
- **엔진에 두지 않는다** — 엔진(Stalwart 0.16.27)은 올리기 본문을 통째로 메모리에 받고(한 번 50,000,000바이트), 파일이 그 직원의 메일함 용량 · 메일 백업과 묶이며, 로그인 없는 링크가 없다(2026-10-30 개발 엔진 · 소스 확인).
|
||||
파일은 백엔드의 볼륨(`<PROJECT_NAME>_files` → `/app/files`), 기록은 PostgreSQL 이다. 새 의존성은 없다(백엔드 · 프론트 모두).
|
||||
- **개념 셋** — 파일(내용 · 주인 · 이름 · 크기 · 검사 결과), 공유 링크(메일 한 통의 파일 묶음 하나 — 토큰 · 끝나는 때 · 끊김), 쓰임(임시 저장본 · 보낸 메일). 2부 파일 저장소가 같은 파일 · 링크 · 올리기 API 를 쓴다.
|
||||
- **링크는 메일 한 통에 하나다** — 그 메일에 새로 실은 내 파일의 묶음이다. 보낸 사본과 받는 사람의 사본은 같은 메일이라(어댑터가 메일 하나를 만들어 같은 요청에서 제출한다 — `jmap-mail.provider.ts:2042-2075`)
|
||||
받는 사람별 링크 · 기록은 없고, **끊기도 링크(메일) 단위**다.
|
||||
- **답장 · 전달의 인용 속 원래 링크는 글(링크)로 그대로 간다** — 서버가 다시 싣거나 우리 링크로 바꾸지 않는다. 전달하는 사람이 새 대용량 파일을 더하면 그 메일의 링크는 하나(전달하는 사람 것)다.
|
||||
- 공개 경로는 로그인이 없다 — 바깥 거래처의 브라우저 · 내려받기 관리자 · 받는 쪽 보안 장비가 부른다(머리말의 "웹 전용" · 출처 검사는 로그인한 API 의 규칙이다). 모바일 앱은 이 단계 범위 밖이다.
|
||||
|
||||
### 공유 계약을 바꾸는 곳 (한데 모음)
|
||||
|
||||
| 곳 | 바뀌는 것 | 호환 |
|
||||
| --- | --- | --- |
|
||||
| 발송 `POST /api/messages` 본문 (2절) | `largeFileIds` · `bodyQuoteStart` 추가, 서식 본문의 인용 경계 표시 `<div data-ara-quote-start></div>` | 필드 추가 — 쓰지 않으면 지금과 같다(지문도 같다) |
|
||||
| 발송 실패 | 400 `MAIL_016` 추가. `MAIL_007` 은 덩이를 넣은 뒤에도 잰다 | 새 코드 |
|
||||
| 임시 저장 `POST`·`PUT /api/drafts` 본문 · `GET /api/drafts/{id}` 응답 (2절) | 같은 두 필드(올리는 중인 파일도 된다), 초안 머리글 `X-Ara-Large-Files`, `Draft.largeFiles`, 실패 `MAIL_016` | 필드 추가 |
|
||||
| 읽기 `GET /api/messages/{id}` (2절) | `MessageDetail.largeFiles`(링크 주인이 그 메일의 보낸 사람일 때만), `htmlBody` 안 그 덩이의 바깥 요소에 `data-ara-large-files` | 필드 · 속성 추가 — 지금 클라이언트는 `data-*` 를 지우고 덩이를 보통 본문으로 그린다 |
|
||||
| 받기 `GET`·`HEAD /api/messages/{id}/large-files/{fileId}` (2절 표에 한 줄) | 새 경로 — 우리 웹메일에서 대용량 첨부 받기 | 새 경로 |
|
||||
| 실시간 알림 `GET /api/mail-events` (2절) | `large-file` 이벤트(위험 판정) | 지금 클라이언트는 모르는 이벤트를 버린다(`frontend/src/shared/utils/mail-events.ts:97-110`) |
|
||||
| 이 절의 새 API | `/api/file-uploads` · `/api/file-quota` · `/api/share-links` · `/api/share-link-lookups` · `/api/public-links`, 화면 `/s/{token}` | 새 경로 — `/api/files` 는 템플릿 업로드 예제가 쓰고 있어 피했다 |
|
||||
| 에러 코드 | `FILE_003` ~ `FILE_008` · `MAIL_016` (아래 "에러 코드") | 레지스트리에 더하고 `npm run sync:error-codes` |
|
||||
|
||||
### 한도
|
||||
|
||||
| 한도 | 값(기본) | 넘으면 | 바꾸는 곳 |
|
||||
| --- | --- | --- | --- |
|
||||
| 한 파일 | 2,047MB (2,146,435,072바이트 — ClamAV 가 검사할 수 있는 2GiB 아래) | 413 `FILE_002`(만들 때) | `MAIL_LARGE_FILE_MAX_SIZE_MB` |
|
||||
| 형식 | 일반 첨부 허용 목록 + mp4 · mov · dwg · dxf · psd · ai · 7z — 실행 형식(exe · msi · bat · js · vbs · scr 등) · 디스크 이미지(iso · img · vhd)를 비롯해 그 밖은 받지 않는다 | 400 `FILE_001` | 상수 — 백엔드 정책과 프론트 상수가 짝(한쪽을 고치면 둘 다) |
|
||||
| 메일 한 통 | 대용량 파일 10개 — 그 메일에 새로 싣는 내 파일(인용 속 원래 링크는 세지 않는다). 일반 첨부(20개)와 따로 센다 | 400 `VAL_001` | `MAIL_LARGE_FILE_MAX_COUNT` |
|
||||
| 직원당 보관량 | 10GB (10,737,418,240바이트) — 올리는 중(선언한 크기) + 보내지 않은 파일 + 기간 안의 보낸 파일 | 409 `FILE_005`(만들 때) | `MAIL_LARGE_FILE_USER_QUOTA_GB` |
|
||||
| 직원당 보내지 않은 파일 수 | 50개(올리는 중 포함) | 409 `FILE_005` | 상수 |
|
||||
| 서버 총량 | 없음(비움) — 모든 직원의 보관량 합 | 507 `FILE_006`(만들 때) | `FILE_STORAGE_MAX_TOTAL_GB` |
|
||||
| 디스크 여유 | 만든 뒤에도 10GB 가 남아야 한다(진행 중인 올리기의 아직 받지 않은 바이트를 빼고) | 507 `FILE_006`(만들 때 · 쓰다 `ENOSPC`) | `FILE_STORAGE_MIN_FREE_GB` |
|
||||
| 조각 | 16MiB (16,777,216바이트) 고정 — 2,047MB 면 128개 | 400 `VAL_001`(길이) · 413 `FILE_002`(앞단 17MB) | 계약 상수 |
|
||||
| 내려받기 | 파일당 500회(받기 시작한 수 — 아래 "파일 응답"의 횟수) | 410 `FILE_007` | `SHARE_DOWNLOAD_LIMIT` |
|
||||
| 보관 | 보낸 날(한국)부터 30일째 날 끝 | 410 `FILE_007` | `MAIL_LARGE_FILE_RETENTION_DAYS` |
|
||||
|
||||
- MB · GB 는 1,048,576 · 1,073,741,824바이트다(일반 첨부 한도와 같은 셈 — `mail.settings.ts:74`). 한 파일 한도는 env 로 2,047MB 를 넘길 수 없다(검사기 한도 — 아래 "악성 검사").
|
||||
- 보관량 · 총량 · 여유는 **만들 때 선언한 크기로 미리 잡는다** — 조각을 보내는 동안에는 한도를 다시 보지 않는다. 같은 직원의 만들기는 하나씩 처리한다(동시에 둘이 한도를 넘지 않게).
|
||||
구현(2026-10-30): 만들기는 **서버 전체에서 하나씩**이다 — 서버 총량 · 디스크 여유를 함께 보므로 DB 의 트랜잭션 잠금(`pg_advisory_xact_lock`) 안에서 세고 만든다(인스턴스를 늘려도 그대로 맞다).
|
||||
- 기간이 지나거나 링크를 끊거나 보내지 않은 파일을 지우면 보관량은 정리 작업을 기다리지 않고 바로 줄어든다(요청 때 상태로 계산한다).
|
||||
|
||||
### 타입
|
||||
|
||||
```ts
|
||||
type ScanStatus = 'pending' | 'clean' | 'infected' | 'unscannable' // 검사 중 · 깨끗함 · 위험 · 검사하지 못함
|
||||
|
||||
/** 내가 올린 대용량 파일 — 올리기 API 의 응답 · 보내지 않은 파일 목록 · Draft.largeFiles */
|
||||
interface LargeFile {
|
||||
id: string // 올리기 id = 다 올린 뒤의 파일 id (바뀌지 않는다) — 발송 · 임시 저장의 largeFileIds 에 쓴다
|
||||
name: string // 서버가 정리한 이름 — 일반 첨부와 같은 정리(경로 · 제어 · 방향 제어 문자를 지우고 255자, 확장자는 남긴다 — attachment.policy.ts:220-239)
|
||||
size: number // 바이트
|
||||
status: 'uploading' | 'ready' // 올리는 중 · 다 올림
|
||||
chunkSize: number // 16,777,216 — 마지막 조각만 짧다
|
||||
chunkCount: number // ceil(size / chunkSize)
|
||||
receivedChunks: number[] // 받은 조각 번호(0 부터, 오름차순) — ready 면 모두
|
||||
firstChunkSha256: string | null // 받은 맨 앞 조각(0번)의 SHA-256 (base64) — 받기 전이면 null. 새로고침 뒤 다시 고른 파일이 같은지 맞춰 본다
|
||||
scanStatus: ScanStatus | null // ready 일 때만 (uploading 은 null)
|
||||
createdAt: string
|
||||
expiresAt: string // 이대로 두면 지워지는 때 — 아래 "기간 · 보관 · 정리"
|
||||
lastModified: number | null // 만들 때 받은 File.lastModified (ms)
|
||||
}
|
||||
|
||||
/** 임시 저장본이 가리키는데 이제 없는 파일 */
|
||||
interface MissingLargeFile {
|
||||
id: string
|
||||
status: 'missing'
|
||||
reason: 'deleted' | 'expired' // 지운 파일(설정 · 쓰기 화면에서 지움) · 기간이 지남(올리다 멈춘 지 24시간 · 임시 저장 30일 등)
|
||||
name: string | null // 기록이 남아 있으면(지운 뒤 30일) 이름 · 크기
|
||||
size: number | null
|
||||
}
|
||||
|
||||
/** 링크 안 파일 하나의 상태 — 받는 쪽에서 본 것 */
|
||||
type SharedFileStatus =
|
||||
| 'available' // 받을 수 있다 (검사에서 깨끗함)
|
||||
| 'unscannable' // 받을 수 있다 — "검사하지 못함" (암호 압축 · 검사 한도 밖 · 검사기를 쓰지 않는 개발)
|
||||
| 'scanning' // 검사 중 — 끝나면 받는다 (그동안 받기는 409 FILE_008)
|
||||
| 'infected' // 위험 — 막았다
|
||||
| 'limit-reached' // 이 파일의 내려받기 상한에 닿았다
|
||||
| 'revoked' // 보낸 사람(또는 운영)이 링크를 끊었다
|
||||
| 'expired' // 기간이 지났다
|
||||
| 'missing' // 파일을 찾을 수 없다 (저장소 복원 등 — 드묾)
|
||||
|
||||
/** 메일 속 대용량 첨부 하나 — MessageDetail.largeFiles */
|
||||
interface MessageLargeFile {
|
||||
linkId: string
|
||||
fileId: string
|
||||
name: string // 서버 기록의 이름 — 메일 글이 아니다
|
||||
size: number
|
||||
status: SharedFileStatus
|
||||
expiresAt: string // 링크가 끝나는 때 (아래 "기간 · 보관 · 정리")
|
||||
revokedAt: string | null // 링크를 끊은 때 (보낸 사람 · 운영) — 끊지 않았으면 null (2026-10-30 더함 — 화면의 "…에 끊었습니다")
|
||||
url: string // 공개 화면 주소 `<SHARE_PUBLIC_URL>/s/<token>` — 본문 덩이의 그 주소. 같은 링크의 파일은 같은 주소
|
||||
downloads: { // 보는 사람이 그 링크를 만든 사람일 때만 — 아니면 null
|
||||
count: number // 받은 수 (받기 시작한 수)
|
||||
lastDownloadedAt: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
/** GET /api/file-quota */
|
||||
interface FileQuota {
|
||||
usedBytes: number // 지금 잡고 있는 양 — 위 "한도"의 보관량
|
||||
limitBytes: number // 직원당 보관량 한도
|
||||
maxFileBytes: number // 한 파일
|
||||
maxFilesPerMessage: number // 메일 한 통에 새로 싣는 대용량 파일 수
|
||||
chunkSize: number // 16,777,216 — 올리기 응답의 chunkSize 와 같다
|
||||
retentionDays: number // 보낸 날부터 받을 수 있는 날 수 (30)
|
||||
}
|
||||
|
||||
/** GET /api/share-links 의 항목 — 내가 만든 링크 (보낸 메일 한 통) */
|
||||
interface ShareLink {
|
||||
id: string
|
||||
sentEmailId: string | null // 보낸 사본의 메일 id — [보낸 메일 보기]. 보낸 결과를 모르면 null (아래 "링크를 만드는 때")
|
||||
subject: string | null // 보낸 메일 제목 — 엔진에서 읽는다 (지웠거나 읽지 못하면 null)
|
||||
sentAt: string // 보낸 때 (예약은 보낼 시각)
|
||||
expiresAt: string
|
||||
status: 'active' | 'expired' | 'revoked' // revoked = 보낸 사람(또는 운영)이 끊었다
|
||||
revokedAt: string | null // 끊은 때 — revoked 일 때만 값이 있다 (2026-10-30 더함 — 설정의 "10월 22일에 끊음")
|
||||
files: SharedFile[] // 링크의 파일 (보낸 차례)
|
||||
}
|
||||
interface SharedFile {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
status: SharedFileStatus
|
||||
downloadCount: number
|
||||
lastDownloadedAt: string | null
|
||||
}
|
||||
|
||||
/** POST /api/share-link-lookups — 인용 속 링크의 상태 (누구의 링크든 상태 · 끝나는 날만) */
|
||||
interface ShareLinkLookupRequest {
|
||||
urls: string[] // 1~20개, 겹치지 않게 — 본문(인용)에서 찾은 공개 화면 주소, 한 개 300자까지
|
||||
}
|
||||
interface ShareLinkState {
|
||||
url: string // 보낸 그대로
|
||||
status: 'active' | 'expired' | 'revoked' | 'unknown' // unknown = 우리 링크 모양이 아님 · 모르는 · 보내기를 취소한 · 기록을 지운 링크
|
||||
expiresAt: string | null // unknown 이면 null
|
||||
}
|
||||
|
||||
/** GET /api/public-links/{token} — 로그인 없음 */
|
||||
interface PublicLink {
|
||||
status: 'active' | 'expired' | 'revoked'
|
||||
sender: { name: string | null; email: string } // 보낼 때 로그인한 계정의 이름 · 주소 — 보낸 사람이 꾸밀 수 없다
|
||||
sentAt: string
|
||||
expiresAt: string
|
||||
files: PublicLinkFile[] // active 일 때만 — 끝난 링크는 빈 배열 (파일 이름도 보이지 않는다)
|
||||
reportAddress: string // 신고 · 문의 주소 (SHARE_REPORT_ADDRESS)
|
||||
}
|
||||
interface PublicLinkFile {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
status: Exclude<SharedFileStatus, 'revoked' | 'expired'>
|
||||
}
|
||||
```
|
||||
|
||||
### 엔드포인트
|
||||
|
||||
| 메서드 · 경로 | 요청 | 성공 | 주요 실패 |
|
||||
| --- | --- | --- | --- |
|
||||
| `POST /api/file-uploads` | `FileUploadCreate` (아래 "올리기") | 201 `LargeFile` | 400 `VAL_001` · `FILE_001`, 409 `FILE_005`, 413 `FILE_002`, 429 `REQ_001`, 507 `FILE_006` |
|
||||
| `GET /api/file-uploads` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: LargeFile[], pageInfo }` — **보내지 않은 내 파일**(살아 있는 링크에 실리지 않은 것 — 올리는 중 포함), 만든 때 최근부터, `total` 포함 | 400 `VAL_001` |
|
||||
| `GET /api/file-uploads/{id}` | — | 200 `LargeFile` | 404 `RES_001` |
|
||||
| `PUT /api/file-uploads/{id}/chunks/{index}` | 조각 바이트 (`application/octet-stream`) | 200 `LargeFile` | 400 `VAL_001` · `FILE_004`, 404 `RES_001`, 409 `FILE_003`, 413 `FILE_002`, 429 `REQ_001`, 507 `FILE_006` |
|
||||
| `POST /api/file-uploads/{id}/completion` | — | 200 `LargeFile` (`ready`) | 404 `RES_001`, 409 `FILE_003`(빠진 조각 · 진행 중) |
|
||||
| `DELETE /api/file-uploads/{id}` | — | 204 — 올리기를 그만두거나 보내지 않은 파일을 지운다 | 404 `RES_001`, 409 `FILE_003`(살아 있는 링크에 실린 파일) |
|
||||
| `GET /api/file-quota` | — | 200 `FileQuota` | — |
|
||||
| `GET /api/share-links` | `cursor` · `limit`(기본 20, 최대 100) | 200 `{ items: ShareLink[], pageInfo }` — 보낸 때 최근부터, `total` 포함 | 400 `VAL_001` |
|
||||
| `POST /api/share-links/{id}/revocation` | — | 200 `ShareLink` — 그 링크(메일)를 끊는다 | 404 `RES_001` |
|
||||
| `POST /api/share-link-lookups` | `ShareLinkLookupRequest` | 200 `{ links: ShareLinkState[] }` — 요청 차례 그대로 | 400 `VAL_001` |
|
||||
| `GET /api/public-links/{token}` | — (로그인 없음) | 200 `PublicLink` | 404 `RES_001`, 429 `REQ_001` |
|
||||
| `GET`·`HEAD /api/public-links/{token}/files/{fileId}` | `Range`(선택) — 로그인 없음 | 파일 200 · 206 · 416 (표준 래퍼 없음) | 404 `RES_001`, 409 `FILE_008`, 410 `FILE_007`, 429 `REQ_001` |
|
||||
| `GET`·`HEAD /api/messages/{id}/large-files/{fileId}` | `Range`(선택) | 파일 200 · 206 · 416 (표준 래퍼 없음) | 404 `RES_001`, 409 `FILE_008`, 410 `FILE_007`, 503 `MAIL_001` |
|
||||
|
||||
- `/api/public-links` 밖은 모두 로그인 필요(쿠키 — 없으면 401 `AUTH_001`)이고, 상태를 바꾸는 요청은 출처 검사를 받는다(머리말).
|
||||
- 경로의 id 는 UUID 모양이 아니면 404 `RES_001`. 남의 것 · 지운 것 · 없는 것을 가리지 않고 404 다.
|
||||
- 공개 경로 밖의 응답은 `Cache-Control: private, no-store`.
|
||||
|
||||
### 올리기 (`/api/file-uploads`)
|
||||
|
||||
```ts
|
||||
interface FileUploadCreate {
|
||||
name: string // 1~255자 (JS 문자열 길이) — 서버가 정리한 이름(응답의 name)으로 둔다
|
||||
size: number // 바이트 — 1 이상 · 한 파일 한도 이하의 정수
|
||||
purpose: 'mail' // 쓰임 — 11단계는 'mail' 만 (2부 파일 저장소가 더한다)
|
||||
lastModified?: number | null // 선택 — 브라우저 File.lastModified (ms, 0 이상 정수)
|
||||
}
|
||||
```
|
||||
|
||||
1. **만들기** `POST /api/file-uploads` — 형식은 정리한 이름의 확장자로만 본다(밖이면 400 `FILE_001`). 브라우저가 주는 형식(MIME)은 받지 않는다 — 이 경로에서는 믿을 근거가 없고,
|
||||
내려줄 때 늘 `octet-stream` + `attachment` + `nosniff` + sandbox 이며 내용은 악성 검사가 본다(보안 규칙 "확장자와 MIME 둘 다"의 예외 — 열린 질문).
|
||||
그다음 크기(413 `FILE_002`) → 직원 한도(409 `FILE_005`) → 서버 총량 · 디스크 여유(507 `FILE_006`) 차례로 본다. 201 `LargeFile`(`uploading`, `receivedChunks: []`).
|
||||
2. **조각** `PUT /api/file-uploads/{id}/chunks/{index}` — `index` 는 0 ~ `chunkCount − 1`, 본문은 그 조각의 바이트 그대로.
|
||||
- `Content-Type: application/octet-stream` 만 받는다(JSON 이면 본문 파서의 1MB 한도에 먼저 걸려 413, 그 밖은 400 `VAL_001`).
|
||||
조각은 JSON 본문 파서 · 모양 한도를 지나지 않고(두 파서 모두 `application/json` 만 읽는다 — `app.setup.ts:164-170, 208-220`) 처리기가 요청 스트림을 직접 읽는다.
|
||||
- `Content-Length` 필수 = `chunkSize`(마지막 조각만 `size − chunkSize × (chunkCount − 1)`). 없거나(청크 전송) 다르면 읽기 전에 400 `VAL_001`. 압축(`Content-Encoding`)도 400 `VAL_001`.
|
||||
- 선택 `Content-Digest: sha-256=:<base64>:`(RFC 9530) — 웹은 늘 보낸다. 받은 바이트와 다르면 400 `FILE_004`(그 조각을 받지 않았다 — 같은 조각을 다시 보낸다). sha-256 이 없으면(다른 알고리즘만) 없는 것으로 본다.
|
||||
sha-256 값의 모양이 틀리면(base64 로 32바이트가 아니면) 읽기 전에 400 `VAL_001`(구현 — 2026-10-30).
|
||||
- 조각 번호는 앞자리 0 없는 10진수(0 ~ 999,999)다 — `01` · `+1` · ` 1` · `1.0` · `1e1` · `0x1` · `0b1` 등은 400 `VAL_001`(숫자로 바꿔 보지 않는다 — 2026-10-30 리뷰). 본문을 다 받지 못했으면(길이가 다름 · 클라이언트가 끊음) 400 `VAL_001`, 디스크 등 그 밖의 오류는 500 `SYS_001`(error 로그).
|
||||
- 다 받아 디스크에 쓰고(`datasync`) 나서야 받은 것으로 적는다 — 중간에 끊기면 적지 않는다. 서버는 조각마다 SHA-256 을 계산해 둔다(맨 앞 조각의 값이 `firstChunkSha256`).
|
||||
- **다시 보내기**: 받지 않은 조각은 쓴다. 이미 받은 조각은 쓰지 않고 내용을 견준다 — 같으면 200 그대로, 다르면 409 `FILE_003`(아무것도 바꾸지 않는다).
|
||||
같은 조각을 동시에 보내면 뒤의 것, 이미 끝낸 올리기에 보내면 409 `FILE_003`.
|
||||
- 차례는 자유다(빠진 조각만 다시). 웹은 파일마다 한 번에 한 조각.
|
||||
- 디스크가 차면(`ENOSPC`) 507 `FILE_006`(error 로그) — 그 조각은 받지 않았다.
|
||||
- 200 `LargeFile`(받은 조각 반영).
|
||||
3. **끝내기** `POST /api/file-uploads/{id}/completion`(본문 없음) — 조각이 모두 있으면 파일로 만들고(`ready`) 곧바로 악성 검사 차례에 넣는다 → 200 `LargeFile`(`scanStatus: 'pending'` — 검사기를 쓰지 않으면 `'unscannable'`).
|
||||
빠진 조각이 있으면 409 `FILE_003`(클라이언트는 `GET` 으로 빠진 번호를 보고 보낸다), 끝내기가 진행 중이어도 409 `FILE_003`. 이미 끝냈으면 그대로 200(응답을 못 받은 재시도).
|
||||
파일 전체의 해시는 따로 보지 않는다(조각마다 봤다).
|
||||
4. **상태** `GET /api/file-uploads/{id}` — 끊긴 뒤 무엇을 받았는지 본다. 올리는 중은 마지막 조각 뒤 24시간 지나면 404 다(아래 "기간 · 보관 · 정리").
|
||||
5. **보내지 않은 파일 목록** `GET /api/file-uploads` — 살아 있는 링크(보낸 메일)에 실리지 않은 내 파일(올리는 중 · 다 올림)이다. 보내기를 취소해 되돌린 파일도 여기 든다. 설정의 "보내지 않은 파일"과 [지우기]가 쓴다.
|
||||
6. **그만두기 · 지우기** `DELETE /api/file-uploads/{id}` — 올리는 중이면 그만두고, 다 올렸어도 살아 있는 링크에 실리지 않았으면 지운다 — 보관량에서 바로 빠진다.
|
||||
그 파일을 싣고 있던 임시 저장본을 열면 `MissingLargeFile`(`reason: 'deleted'` — "지운 파일")로 온다. 살아 있는 링크에 실린 파일은 409 `FILE_003`(링크 끊기로 — 아래 "보낸 사람 관리").
|
||||
|
||||
**요청 수 · 동시 수**
|
||||
|
||||
| 대상 | 한도 | 넘으면 |
|
||||
| --- | --- | --- |
|
||||
| 조각 `PUT` | IP 마다 1분 1,200회 (이 경로만 `@Throttle`) · 직원마다 동시 4개 · 서버 전체 동시 32개 | 429 `REQ_001` |
|
||||
| 만들기 · 끝내기 · 상태 · 목록 · 지우기 | 전역 한도 (경로마다 IP 당 — `THROTTLE_*`, 기본 1분 100회) | 429 `REQ_001` |
|
||||
|
||||
- 조각 경로만 크게 둔다 — 16MiB 조각은 한 사람이 1분에 수십 번 보내고 사무실은 IP 하나로 나간다(전역 한도에 걸리면 큰 파일이 429 로 멈춘다).
|
||||
두 번째 limiter 가 아니라 라우트의 `@Throttle` 이다(외부 이미지 중계와 같은 결 — `mail-image.controller.ts:79`).
|
||||
- 동시 수는 기다리지 않는다 — 넘으면 429, 클라이언트가 잠시 뒤 같은 조각을 다시 보낸다. 잠금 · 셈은 서버 메모리다(한 인스턴스 전제).
|
||||
- 앞단 nginx 는 이 경로만 본문 17MB · 읽기 90초이고, 요청 본문을 다 받아 둔 뒤 백엔드에 넘긴다(느린 연결은 nginx 가 받는다 — 아래 "운영 확인").
|
||||
|
||||
**클라이언트**
|
||||
- 조각 요청은 `silent` 로 보내고 실패는 그 파일 줄에 보인다(첨부의 `FILE_001` · `FILE_002` 처럼).
|
||||
네트워크 오류 · 시간 초과 · 429 · 502 · 503 · 504 는 같은 조각을 저절로 다시 보내고(간격을 늘리며 — 구현하며 정함), 넘으면 멈추고 [다시 시도](받은 조각은 남는다). 401 은 지금 규칙(갱신 한 번 → 같은 조각을 다시).
|
||||
`FILE_004` 는 그 조각을 다시, `FILE_003` 은 `GET` 으로 상태를 보고 잇는다. `FILE_001` · `FILE_002` · `FILE_005` · `FILE_006` · 404 는 멈추고 알린다.
|
||||
구현하며 정함(2026-10-30 — 웹):
|
||||
- 만들기는 파일을 고르자마자 모두 보낸다(임시 저장본에 바로 실린다). 조각은 파일 3개까지만 함께 보내고 나머지는 차례를 기다린다(위 직원당 동시 4개는 조각 `PUT` 만 센다).
|
||||
- 저절로 다시 보내는 간격은 2 · 5 · 15 · 30초, 1분 넘게 잇지 못하면 멈추고 [다시 시도]. 요청 시간 한도는 조각 5분 · 그 밖 30초.
|
||||
- 조각이 `FILE_003` 인데 상태가 "받음"이면 그 조각을 한 번 더 보내 200 을 확인한다(같은 내용이면 200) — 또 `FILE_003` 이면 멈춘다(내용이 다른 조각).
|
||||
- 끝내기가 진행 중(`FILE_003`)이면 2 · 5 · 15 · 30초 간격으로 다시 묻고 1분 넘게 그대로면 멈추고 [다시 시도](끝내기는 거듭해도 같다).
|
||||
- 404(서버가 올리기를 잊음)는 멈추고 [다시 시도]를 누를 때만 새로 만들어 처음부터 올린다 — 저절로 다시 올리지 않는다.
|
||||
- 검사 상태 다시 묻기: 쓰기 화면 · 읽기 화면은 15 · 30 · 60초 간격 최대 30번, 공개 화면은 20번(공개 경로의 IP 한도 안).
|
||||
- 진행은 받은 바이트로 보인다("420MB / 1.2GB · 약 3분"), 멈춤 · 빼기. 빼면 `DELETE`(알림 없이 — 404 는 지운 것으로 본다). 쓰기 화면을 버리면(휴지통) 지우지 않는다 — 되돌리면 다시 쓴다.
|
||||
- **새로고침 · 창 닫힘 뒤 이어 올리기(처음 판에 넣는다)**: 올리는 중인 파일도 임시 저장본 머리글에 실린다(아래 "임시 저장") — 다시 열면 `uploading` 으로 오고
|
||||
"올리다 멈춘 파일 — 같은 파일을 다시 고르면 이어서 올립니다"(브라우저는 고른 파일을 스스로 다시 읽지 못한다).
|
||||
다시 고른 파일은 **이름 · 크기 · `lastModified` 가 같고 그 파일의 맨 앞 조각 SHA-256 이 `firstChunkSha256` 과 같을 때** 같은 파일로 보고 빠진 조각부터 잇는다.
|
||||
다르면 새로 만든다(앞 올리기는 `DELETE`). 맨 앞 조각을 받기 전(`firstChunkSha256: null`)이면 이을 것이 없다 — 새로 만든다.
|
||||
올리는 중은 마지막 조각 뒤 24시간 남는다 — 그 뒤에 열면 `MissingLargeFile`(`reason: 'expired'`)이라 다시 올린다.
|
||||
- 올리는 동안은 보내기를 막는다(아래 "보내기를 막는 까닭"). 나가기 확인에 올리는 중인 파일을 넣는다(2절 "임시 저장"의 나가기 규칙).
|
||||
|
||||
### 쓰기 화면이 고르는 것 (클라이언트)
|
||||
|
||||
- **링크로 바뀌는 때**: 파일 하나가 `attachmentMaxBytes`(20MB — `GET /api/mail-quota`)를 넘거나, 일반 첨부 합계가 `attachmentTotalMaxBytes`(25MB)를 넘게 만드는 파일부터 자동으로 대용량(링크)이다.
|
||||
일반 첨부와 한 메일에 섞는다. 서버는 바꾸지 않는다 — 보낸 대로 다룬다.
|
||||
- **손으로 「링크로 보내기」**는 이 쓰기 화면에서 고른 파일(브라우저에 내용이 있는 것)에만 있다. 전달하는 원본 메일의 첨부 · 임시 저장본의 첨부(엔진에 있는 것)는 처음 판에서 바꾸지 않는다. 링크 → 일반 첨부로 되돌리기도 처음 판에는 없다.
|
||||
- **올리기 전에 막는다**(지금 첨부 사전 검사와 같은 결): 형식(대용량 허용 목록 — 짝 상수), 크기(`maxFileBytes`), 한 통의 개수(`maxFilesPerMessage`), 보관량(`usedBytes` + 이 파일 > `limitBytes`).
|
||||
값은 `GET /api/file-quota` 로 받는다(못 받으면 위 기본값).
|
||||
- 받는 사람이 사내뿐이어도 같은 규칙이다.
|
||||
|
||||
**보내기를 막는 까닭** — 일반 첨부의 까닭(올리는 중 · 찾지 못함)에 더한다. 단추는 `aria-disabled` + 까닭 글로 막는다(글 · 모양은 디자인 README). 서버도 같은 것을 막는다.
|
||||
|
||||
| 까닭 코드 (클라이언트) | 상태 | 서버가 받으면 |
|
||||
| --- | --- | --- |
|
||||
| `large-file-uploading` — 올리는 중 · 멈춘 올리기 · 다시 골라야 이어지는 파일 | `LargeFile.status: 'uploading'` | 400 `MAIL_016` |
|
||||
| `large-file-missing` — 지운 파일 · 기간이 지난 파일 | `MissingLargeFile`(`reason`) | 400 `MAIL_016` |
|
||||
| `large-file-infected` — 위험 판정 | `scanStatus: 'infected'` | 400 `MAIL_016` |
|
||||
| `large-file-count` — 한 통의 개수 초과 | `largeFileIds` 가 `maxFilesPerMessage` 보다 많음 | 400 `VAL_001` |
|
||||
|
||||
- 검사 중(`pending`) · 검사하지 못함(`unscannable`)은 막지 않는다 — 받는 쪽이 "검사 중"을 보고 끝나면 받는다. 보관량은 보내기가 아니라 올리기 전에 막는다(만들기의 409 `FILE_005`).
|
||||
|
||||
### 발송 · 임시 저장과 함께
|
||||
|
||||
**본문 필드** (발송 · 임시 저장 같다 — 2절 "발송 본문"에 더한다)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// … 2절 그대로 …
|
||||
"largeFileIds": ["<올리기 id>"], // 선택 — 이 메일에 새로 싣는 내 대용량 파일, 겹치지 않게, 20개까지 (남용 방지 — 실제 한도는 maxFilesPerMessage)
|
||||
"bodyQuoteStart": 1234 // 선택 — body(텍스트)에서 인용이 시작하는 자리 = 덩이를 넣을 자리
|
||||
}
|
||||
```
|
||||
|
||||
- **인용 경계 — 서식 본문**: 쓰기 화면은 인용("원본 메일" 머리) 바로 앞에 빈 표시 `<div data-ara-quote-start></div>` 하나를 둔다.
|
||||
보내는 정화는 이 속성을 `div` 에만 남기고(그 밖의 `data-*` 는 지금처럼 지운다 — `sanitize-outgoing-html.ts:75-82`), 서버는 첫 표시 자리를 덩이 자리로 쓴 뒤 표시를 **모두 지운다** —
|
||||
받는 사람에게 가지 않고, 임시 저장본에도 남지 않고, 지문에도 들지 않는다. 지금 쓰기 화면이 보내기 전에 지우는 인용 머리 표시(`data-quote-header` — `mail-serialize.ts:202-206`)와는 따로다.
|
||||
- **인용 경계 — 텍스트**: `bodyQuoteStart` — 0 ~ `body.length`(JS 문자열 위치)이고 줄의 처음이어야 한다(0 이거나 바로 앞이 줄바꿈). 아니면 400 `VAL_001`.
|
||||
서버는 텍스트 본문을 고치지 않고 넘기므로(`mail.service.ts:1192`) 클라이언트가 센 자리 그대로다. 지문에 넣지 않는다.
|
||||
- 표시 · `bodyQuoteStart` 가 없으면 덩이는 맨 끝(서명 아래)이다. 대용량 파일이 없으면 둘 다 쓰지 않는다(표시는 지운다).
|
||||
|
||||
**보내기 전 확인** (`POST /api/messages` — 엔진에 묻기 전이라 메일은 나가지 않는다)
|
||||
- `largeFileIds` 의 파일은 있고 · 내 것이고 · 다 올렸고(`ready`) · 위험이 아니어야 한다. 아니면(없음 · 남의 것 · 지움 · 기간 지남 · 올리는 중 · 위험) 400 `MAIL_016` — 다 받았는지 서버가 확인한 뒤에만 보낸다.
|
||||
검사 중(`pending`) · 검사하지 못함은 보낸다(보내는 사람을 기다리게 하지 않는다).
|
||||
- 개수가 `maxFilesPerMessage` 를 넘으면 400 `VAL_001`.
|
||||
- 클라이언트는 `MAIL_016` 이면 파일마다 `GET /api/file-uploads/{id}` 로 다시 물어(404 면 없는 파일) 쓸 수 없는 것을 보이고 빼게 한다 — `MAIL_004` 와 같은 흐름. 쓰던 메일 · 키는 그대로다.
|
||||
|
||||
**덩이 — 서버가 본문에 넣는 안내**
|
||||
- 보낼 때만 만든다(임시 저장본에는 넣지 않는다). 한 통에 하나 — 이번 발송의 새 링크(내 파일) 묶음이다.
|
||||
- **자리**: 서식 본문은 첫 경계 표시 자리, 텍스트는 `bodyQuoteStart` — 없으면 맨 끝. 그래서 **내 글 · 서명 아래, 인용 위**다.
|
||||
- **내용** — HTML 과 텍스트에 같은 내용(글 · 모양은 디자인 README "11단계", 구현하며 맞춘다):
|
||||
개수 · 합계 · 끝나는 날("2026년 11월 29일(일)까지 내려받을 수 있습니다 (한국 시간)"), 파일마다 이름 · 크기, 내려받기 주소(**주소 글자가 보이는 링크** — 단축 주소 없음),
|
||||
"내려받을 때 로그인이나 비밀번호를 묻지 않습니다", 영어 한 줄("Large files — download by Nov 29, 2026 (KST)"). 받는 사람 · 제목은 넣지 않는다.
|
||||
크기는 화면과 같은 표기(1024 단위 — `1.2 GB` · `612 MB`, 짝: frontend `shared/utils/format.ts` 의 `formatFileSize`), 날짜는 한국 날짜.
|
||||
- **HTML 모양**: 보내는 정화의 허용 목록 안의 태그 · style 값으로만 만든다(2절 "서식 본문" — div · p · span · a · br · table, 색 hex · 여백 px · 글자 8~99px …) —
|
||||
다시 읽을 때 · 답장 · 전달의 인용 · 보내기 취소로 되돌릴 때도 모양이 남게. 그림 · 뱃지 · 알약 없이 테두리 상자와 글자.
|
||||
- **표시**: HTML 덩이의 앞뒤에 주석 `<!--ara-large-files:v1-->` · `<!--/ara-large-files-->` 를 둔다. 보내는 정화는 HTML 주석을 지우므로(sanitize-html 2.18 — `<div>a<!--x-->b</div>` → `<div>ab</div>`, 2026-10-30 확인)
|
||||
사용자가 쓴 본문(인용 포함)에는 이 표시가 들어갈 수 없다 — 서버만 넣는다. 받는 쪽 메일 프로그램에는 보이지 않는다.
|
||||
- **텍스트 덩이**: 같은 내용을 줄로 — 앞뒤에 빈 줄.
|
||||
- **한도**: 넣은 뒤 다시 잰다 — 텍스트 100,000자 · 서식 본문 UTF-8 2,000,000바이트. 넘으면 400 `MAIL_007`(메일도 링크도 만들지 않는다).
|
||||
덩이는 파일 10개 · 이름 255자여도 HTML 수십 KB 안이다 — 클라이언트는 지금처럼 보내기 직전에 재고 넘으면 인용을 줄인다(2절 "답장·전달 초안의 길이"). 인용 위라 다시 읽기 한도에서 잘리는 일도 드물다.
|
||||
|
||||
**지문 · 같은 키 재시도**
|
||||
- 덩이는 **정화 · 서명 그림 · 지문 · 중복 확인을 마친 뒤**, 엔진에 보내기 바로 앞에 넣는다(`mail.service.ts:1036-1117` 의 `deliverOnce` — 지문 `:1056` · 이미 보냈는지 `:1058` · 보내기 `:1099`).
|
||||
앞에 넣으면 시도마다 새 토큰이 지문에 들어가 같은 키의 재시도가 `MAIL_005` 가 된다.
|
||||
- 지문에는 덩이 대신 `largeFileIds`(정렬)를 **있을 때만** 넣는다 — 대용량 파일이 없는 발송의 지문은 지금과 같다(`send-idempotency.ts:109-128` — 서식 본문을 있을 때만 붙이는 것과 같은 결).
|
||||
경계 표시 · `bodyQuoteStart` 는 넣지 않는다.
|
||||
- 같은 키의 재시도가 처음 메일을 찾으면 처음 결과를 그대로 준다 — 링크를 새로 만들지 않는다. 대용량 파일을 바꾼 재시도는 409 `MAIL_005`.
|
||||
|
||||
**링크를 만드는 때 · 발송이 실패하면**
|
||||
- 이번 발송에 내 파일이 있으면 링크 하나를 만든다 — 중복 확인 뒤 · 엔진에 보내기 바로 앞. 토큰(아래 "공개 내려받기"), 보낸 사람 이름 · 주소(로그인한 계정의 그때 값), 보낸 때, 끝나는 때를 둔다.
|
||||
보내기에 성공하면 보낸 사본의 메일 id 를 잇는다. DB 쓰기 한 번이라 발송 55초 예산에 영향이 거의 없다.
|
||||
- **보내지 않은 것이 확실한 실패**(받는 사람 모두 거절 `MAIL_010` · `MAIL_011`, 엔진이 요청을 거절한 `VAL_001` · `MAIL_004` · `FILE_002` · `MAIL_003` · `AUTH_003` · `REQ_001`(구현 때 더함 — 엔진의 요청 수 한도 거절도 제출 전이다),
|
||||
시간이 모자라 시작하지 않은 `MAIL_001` — 어댑터가 "시작하지 않음"으로 표시한 것만(`SendNotStartedException` — 로그 전용인 context 의 글로 가르지 않는다))면
|
||||
그 링크를 **취소한 링크**로 둔다 — 공개 경로는 404(없는 링크)이고 보낸 사람 목록에 나오지 않는다.
|
||||
- **결과를 모르는 실패**(제출 중 시간 초과 · 연결 끊김 — `MAIL_001`)는 그대로 둔다(나갔을 수 있다). 같은 키의 재시도가 그 메일을 찾으면 그 링크에 보낸 사본을 잇는다.
|
||||
찾지 못해 새로 보내면 새 링크가 생기고, 앞 링크는 보낸 메일을 모르는 채(`sentEmailId: null`) 기간까지 산다 — 목록에서 끊을 수 있다(드묾 — 2절 중복 방지의 알려진 한계와 같은 경우).
|
||||
- 일부 받는 사람만 거절(201 `rejectedRecipients`)은 보낸 것이다 — 링크는 산다.
|
||||
|
||||
**임시 저장**
|
||||
- 저장 본문의 `largeFileIds` 는 내 파일이면 **올리는 중이어도** 된다(새로고침 뒤 이어 올리기). 없음 · 남의 것 · 지움 · 기간 지남은 400 `MAIL_016` —
|
||||
첨부의 `MAIL_004` 처럼 클라이언트는 그런 파일(`MissingLargeFile`)이 남은 동안 저장하지 않는다(사용자가 빼면 다시 저장한다). 위험 판정 파일도 저장은 된다(보낼 때 `MAIL_016`).
|
||||
- 서버는 초안에 머리글로 둔다 — `X-Ara-Large-Files: <파일 id>, …`(올리는 중인 올리기 id 도 — 같은 id 다. 답장 대상 머리글 `X-Ara-Reply-To-Id` 와 같은 길 — `jmap-mail.provider.ts:193, 2946`). 덩이는 넣지 않는다.
|
||||
파일 id 는 저장해도 바뀌지 않는다 — 일반 첨부처럼 응답으로 바꿔 쓸 일이 없다(`DraftSaved` 는 그대로다).
|
||||
- 저장하면 그 파일들을 "임시 저장에 실린 파일"로 적는다 — 다 올린 파일의 남는 기간이 24시간에서 다 올린 날부터 30일로 는다(아래 "기간 · 보관 · 정리"). 올리는 중인 파일은 그대로 마지막 조각 뒤 24시간이다.
|
||||
- `GET /api/drafts/{id}`: 머리글의 파일 가운데 **그 직원의 것만** `largeFiles` 로 준다 — 있으면 `LargeFile`, 지웠거나 기간이 지났으면 `MissingLargeFile`(`reason`).
|
||||
남의 id 와 기록까지 지워진 id 는 버린다(다른 프로그램이 만든 초안일 수 있다 — 둘은 가릴 수 없다).
|
||||
- 알려진 한계(답장 대상 머리글과 같다): 다른 메일 프로그램이 그 초안을 그대로 보내면 머리글만 나가고 덩이는 없다.
|
||||
|
||||
**보내기 취소 · 예약 발송** (2절, 9단계)
|
||||
- 덩이는 제출할 때 메일 안에 들어 있다 — 엔진이 붙잡았다가 그대로 보낸다.
|
||||
- 링크의 보낸 때: 예약은 `sendAt`(끝나는 날도 그 날짜로 센다 — 예약 7일 + 30일이면 37일까지 보관), 보내기 취소를 기다리는 발송은 요청한 때(몇 초 차이는 무시).
|
||||
예약 링크는 보낼 시각 전에도 열린다(주소를 아는 사람은 보낸 사람뿐이다).
|
||||
- 취소(`DELETE /api/scheduled-messages/{id}`)가 엔진에서 확인된 뒤 그 발송이 만든 링크를 취소한 링크로 둔다. 파일은 남는다(되돌린 초안이 쓴다 — 임시 저장 기간으로, 보내지 않은 파일 목록에 다시 나온다).
|
||||
- 되돌린 초안(취소 표시 `$ara-canceled`)을 열면 서버가 원문 HTML 의 표시 사이(덩이)를 정화 **전에** 떼고(`mail.service.ts:1282-1304` 의 `draftHtml` — 취소한 발송의 서명 그림을 되돌리는 그 자리),
|
||||
텍스트 본문의 덩이도 뗀다(서버가 넣은 글 그대로일 때 — 손대면 남는다). 그 링크의 파일을 `largeFiles` 로 돌려준다 → 쓰기 화면은 대용량 목록으로 보이고, 다시 보내면 새 링크다.
|
||||
덩이를 떼지 못하면 다시 보낼 때 덩이가 둘이 된다 — 테스트로 지킨다. 인용 속 남의 덩이(표시 없는 글)는 건드리지 않는다.
|
||||
- 붙잡힌 메일은 우리 API 로 옮기거나 지울 수 없다(`MAIL_015`) — 그동안 링크 · 파일도 그대로다.
|
||||
- ~~⚠️ 구현 전 확인: 엔진이 보낸 사본의 HTML 주석을 그대로 두는지~~ — 확인함(2026-10-30, 개발 엔진 Stalwart 0.16.27): 만든 메일을 다시 읽으면 HTML 주석 · 텍스트 본문(줄바꿈 그대로)과
|
||||
`X-Ara-Large-Files` 머리글(`header:X-Ara-Large-Files:asText`)이 그대로 돌아온다. e2e 가 취소 → 되돌린 초안 → 다시 보내기에서 덩이가 하나인지 본다.
|
||||
|
||||
**답장 · 전달**
|
||||
- 원본의 대용량 첨부를 다시 싣지 않는다 — 원래 메일의 덩이는 **인용 속 글(링크)로 그대로** 간다(편집기가 `data-ara-large-files` 를 버린다 — 링크는 보통 링크다).
|
||||
서버는 인용 속 링크를 다시 싣거나 우리 링크로 바꾸지 않는다. 받는 사람은 그 링크로 원래 공개 화면에서 받는다 — 원래 파일의 받은 수 · 상한 · 끝나는 날을 따른다.
|
||||
- 전달 · 답장하는 사람이 새 대용량 파일을 더하면 그 메일의 링크는 하나(그 사람 것)이고, 새 덩이는 인용 위에 들어간다.
|
||||
- 쓰기 화면은 인용 속 원래 링크의 상태(끝나는 날 · 기간 지남 · 공유 멈춤)를 첨부 칸 아래 **글자로만** 알린다 — 아래 `POST /api/share-link-lookups`.
|
||||
|
||||
### 인용 속 링크의 상태 (`POST /api/share-link-lookups`)
|
||||
|
||||
- 쓰기 화면(전달 · 답장)이 인용에서 찾은 공개 화면 주소를 보내면 링크마다 **상태 · 끝나는 날만** 준다 — 누구의 링크든 같다(이름 · 보낸 사람 · 받은 수는 주지 않는다. 주소를 가진 사람은 공개 화면에서 더 많이 본다).
|
||||
- 클라이언트는 주소의 경로가 `/s/` + 43자(`[A-Za-z0-9_-]`)인 링크를 고른다. 서버는 `<SHARE_PUBLIC_URL>/s/<토큰>` 모양이 아니거나 모르는 · 보내기를 취소한 · 기록을 지운 링크면 `unknown` 이다.
|
||||
이 길에서는 주소 뒤의 쿼리 · `#` 은 보지 않는다(받는 쪽 보안 장비가 붙이기도 한다 — 구현). 읽기 화면의 덩이 알아보기는 쿼리 · `#` 이 없는 주소만 받는다.
|
||||
- 상태: `active`(받을 수 있는 기간 — `expiresAt`), `expired`, `revoked`. 파일마다의 상태(검사 중 · 위험 등)는 주지 않는다 — 받는 사람이 공개 화면에서 본다.
|
||||
- 주소(토큰)를 쿼리에 싣지 않으려고 `POST` 본문으로 받는다(요청 주소가 로그에 남는다 — 아래 "로그"). 상태를 바꾸지 않는다. 본문 검사(1~20개 · 겹침 · 300자)에 걸리면 400 `VAL_001`.
|
||||
- **클라이언트**: 첨부 칸 아래에 "인용한 대용량 첨부 링크 — 11월 29일까지 받을 수 있습니다" · "기간이 지났습니다" · "보낸 사람이 공유를 멈췄습니다"(글은 디자인 README). `unknown` 은 알리지 않는다. 알림 없이(`silent`) 부르고 실패하면 글자를 두지 않는다.
|
||||
|
||||
### 우리 웹메일에서 읽기 (`MessageDetail.largeFiles` · `GET /api/messages/{id}/large-files/{fileId}`)
|
||||
|
||||
- **첨부처럼 올리는 것은 셋이 모두 맞을 때만이다**:
|
||||
1. 원문 HTML(정화 전)의 표시 주석 사이에 있는 링크다 — 표시가 없으면 DB 를 보지 않는다.
|
||||
2. `href` 가 `<SHARE_PUBLIC_URL>/s/<토큰>` 꼴이고 토큰이 우리 기록에 있다(보내기를 취소한 링크는 빼고 — 한 통에 10개까지 본다).
|
||||
3. 그 링크를 만든 직원이 **그 메일의 보낸 사람**(머리글 From 의 주소 — 대소문자 무시)이다.
|
||||
- 맞으면 그 링크의 파일을 `largeFiles` 로 준다 — 이름 · 크기 · 상태는 메일 글이 아니라 서버 기록이다. `htmlBody` 에서는 그 덩이의 바깥 요소에 `data-ara-large-files` 를 붙인다.
|
||||
받은 HTML 에 있던 같은 속성은 정화가 지운다(받은 메일 정화의 속성 허용 목록에 `data-*` 가 없다 — `sanitize-mail-html.ts:165-`) — 그래서 서버만 붙일 수 있다.
|
||||
- **맞지 않으면 본문 글로 둔다** — 바깥이 흉내 낸 덩이, 바깥 사람 답장의 인용 속 링크, 전달된 원래 링크(인용 속 덩이에는 표시가 없고 보낸 사람도 다르다), 다른 주소. 속성도 `largeFiles` 도 없고 링크는 다른 링크처럼 새 창이다.
|
||||
- 텍스트만 있는 메일과, HTML 이 정화 한도를 넘어 비운 메일(`isHtmlBodyOmitted`)의 덩이는 알아보지 않는다(알려진 한계).
|
||||
- `downloads`(받은 수 · 마지막으로 받은 때)는 보는 사람이 그 링크를 만든 사람일 때만 붙는다 — 보낸편지함의 사본, 나에게도 보낸 메일.
|
||||
- 목록의 `hasAttachment`(클립)는 지금처럼 일반 첨부만 본다 — 대용량 첨부만 있는 메일은 클립이 없다(처음 판에서 뺀다).
|
||||
- **받기** `GET`·`HEAD /api/messages/{id}/large-files/{fileId}` — 그 메일에서 첨부처럼 올린 파일만 준다(아니면 404 `RES_001`).
|
||||
머리글 · 범위 · 상태별 실패는 아래 "공개 내려받기"와 같고(`FILE_007` · `FILE_008`), 엔진을 읽지 못하면 503 `MAIL_001`. 요청 수는 IP 마다 1분 300회.
|
||||
보는 사람이 링크를 만든 사람이면 **세지 않는다**(보낸 사람 자신의 확인). 다른 직원(사내 받는 사람)은 공개 내려받기처럼 센다.
|
||||
공개 경로에서는 보낸 사람을 알아볼 수 없다 — 포털 쿠키는 호스트 한정(Domain 없음 — `auth-cookies.ts:29-36`)이라 공개 호스트로 가지 않는다.
|
||||
- **클라이언트**: 첨부 칸에 첨부처럼 — 이름 · 크기 · "11월 29일까지". 받을 수 없으면 흐리게 + 글자("검사 중" · "기간 지남" · "공유 멈춤" · "위험" · "횟수 초과" · "찾을 수 없음"), "검사하지 못함"은 받을 수 있는 채 글자만.
|
||||
덩이(`data-ara-large-files` 요소)는 접어 둔다 — 그리기 전에 떨어진 문서에서 처리한다(클라이언트 정화가 `data-*` 를 지우므로 그 전에 — `frontend/src/shared/utils/mail-html.ts:85`).
|
||||
받기는 첨부처럼 `<a href>` 이동이고, 이동 전에 세션을 확인한다(`confirmSession()`).
|
||||
보낸 사람 자신에게는 "3회 받음 · 11월 29일까지" · [링크 복사](덩이의 주소 — `url`) · [링크 끊기](그 메일의 링크 — 확인 대화상자, 처음 초점 「취소」).
|
||||
|
||||
### 보낸 사람 관리 (`/api/share-links` · `/api/file-uploads` · `/api/file-quota`)
|
||||
|
||||
- **보낸 링크 목록** `GET /api/share-links` — 내가 만든 링크(취소한 링크는 빼고), 보낸 때 최근부터. 기록을 지우기 전(끝나거나 끊긴 뒤 90일)까지 기간 지남 · 끊음도 나온다.
|
||||
`subject` 는 그 쪽의 보낸 사본을 엔진에서 한 번에 읽는다 — 지웠거나 엔진을 읽지 못하면 null 이고 목록은 그대로 준다(warn).
|
||||
- **링크 끊기** `POST /api/share-links/{id}/revocation` — **그 링크(메일)의 파일 모두**의 공유를 멈춘다(파일 하나만 끊지 않는다). 되돌릴 수 없다. 200 `ShareLink`(`revoked`).
|
||||
다음 요청부터 공개 화면은 "공유 멈춤"이고 받기는 410 `FILE_007` 이다 — 이미 시작한 내려받기는 끝까지 간다.
|
||||
그 파일들이 다른 살아 있는 링크 · 임시 저장에 없으면 보관량에서 바로 빠지고 정리 때 지운다. 이미 끊었거나 기간이 지났으면 바꾸지 않고 200(지금 상태). 남의 링크 · 없는 것은 404 `RES_001`.
|
||||
- **링크 복사는 목록에 두지 않는다** — 토큰은 SHA-256 만 두어 되살릴 수 없다. 보낸 사람은 보낸 메일 본문의 덩이에서 복사한다(읽기 화면 — `largeFiles[].url`).
|
||||
- **보내지 않은 파일** `GET /api/file-uploads` · [지우기] `DELETE /api/file-uploads/{id}` — 보관량이 바로 준다. 그 파일을 싣고 있던 임시 저장본을 열면 "지운 파일"(`MissingLargeFile` · `reason: 'deleted'`)로 보인다.
|
||||
- **사용량** `GET /api/file-quota`.
|
||||
- **클라이언트**: 설정 › 메일 › 대용량 첨부 —
|
||||
- 맨 위 사용량("6.2 / 10 GB" — `formatQuotaUsage`).
|
||||
- 보낸 링크는 **메일(링크)마다 한 덩이**: 메일 제목 · 보낸 날 · 끝나는 날("n일 남음") · 상태(사용 중 · 만료 · 회수), 파일마다 이름 · 크기 · 받은 수 · 마지막으로 받은 때 · 파일 상태(검사 중 · 위험 · 검사하지 못함 · 횟수 초과),
|
||||
[보낸 메일 보기](`sentEmailId` — null 이면 없다) · [링크 끊기](확인 — "이 메일로 보낸 대용량 파일 N개를 더는 받을 수 없게 됩니다", 처음 초점 「취소」).
|
||||
- 보내지 않은 파일: 이름 · 크기 · 상태(올리는 중 · 다 올림 · 검사 상태) · 지워지는 때 · [지우기].
|
||||
|
||||
### 공개 내려받기 (로그인 없음)
|
||||
|
||||
- 누구나(바깥 거래처 · 내려받기 관리자 · 받는 쪽 보안 장비) 부른다. 로그인 · 쿠키를 쓰지 않는다(읽지도 않는다). 상태를 바꾸지 않는 `GET` 이라 출처 검사도 해당 없다.
|
||||
- **응답에 실리는 메일 정보는 보낸 사람 이름 · 주소뿐이다** — 제목 · 본문 · 받는 사람 · 메일 id 는 싣지 않는다(링크가 전달되면 그 사람도 본다 — 숨은참조가 드러나지 않게). 링크에 받는 사람 정보를 넣지도 않는다.
|
||||
|
||||
**토큰**
|
||||
- 링크마다 `crypto.randomBytes(32)` 의 base64url(43자, `=` 없음 — 256비트). 서버 서명 키(`JWT_SECRET`)에서 만들지 않는다(보안 규칙).
|
||||
- DB 에는 **SHA-256 만** 두고 그것으로 찾는다 — 원문은 메일 본문에만 있고, 서버는 링크를 다시 만들 수 없다. 맞혀 볼 수 없는 값이라 느린 해시는 필요 없다.
|
||||
- 경로에 둔다(`/s/<토큰>` · `/api/public-links/<토큰>`) — 받는 쪽 보안 장비의 링크 바꿔 쓰기와 잘 맞는다. 로그에서는 가린다(아래 "로그").
|
||||
- 모양(`^[A-Za-z0-9_-]{43}$`)이 아니면 DB 에 묻지 않고 404.
|
||||
|
||||
**상태별 응답**
|
||||
|
||||
| 상황 | 링크 정보 `GET /api/public-links/{token}` | 파일 `GET /api/public-links/{token}/files/{fileId}` |
|
||||
| --- | --- | --- |
|
||||
| 모양이 틀린 · 모르는 토큰, 보내기를 취소한 링크, 기록을 지운 링크(끝난 뒤 90일), 그 링크에 없는 파일 | 404 `RES_001` | 404 `RES_001` |
|
||||
| 기간 지남 | 200 `status: 'expired'` (`files: []`) | 410 `FILE_007` |
|
||||
| 링크를 끊음(보낸 사람 · 운영) | 200 `status: 'revoked'` (`files: []`) | 410 `FILE_007` |
|
||||
| 검사 중 | 200 — `scanning` | 409 `FILE_008` |
|
||||
| 위험 · 횟수 초과 · 찾을 수 없음 | 200 — `infected` · `limit-reached` · `missing` | 410 `FILE_007` |
|
||||
| 받을 수 있음 · 검사하지 못함 | 200 — `available` · `unscannable` | 200 · 206 · 416 |
|
||||
| 요청 수 한도 | 429 `REQ_001` | 429 `REQ_001` |
|
||||
|
||||
- 없는 링크와 끝난 링크를 가려 알려도 된다 — 토큰을 가진 사람에게만 보이는 구분이다.
|
||||
- 404 · 409 · 410 은 표준 실패 형식이지만 전역 필터를 거치지 않고 컨트롤러가 쓴다 — 로그는 debug 만(끝난 링크를 누르는 받는 사람 · 봇이 잦아 warn 이면 알림이 쌓인다 —
|
||||
외부 이미지 중계와 같은 결 `mail-image.controller.ts:64-67`). 5xx 는 지금처럼 error.
|
||||
구현(2026-10-30): 공개 컨트롤러에만 건 같은 예외 필터(`HttpExceptionFilter` 의 `quietStatuses` — 404 · 409 · 410 은 debug)가 쓴다 — 형식 · traceId 는 다른 API 와 같다.
|
||||
- 링크 정보 응답도 `Cache-Control: no-store` · `X-Robots-Tag: noindex` · `Referrer-Policy: no-referrer`.
|
||||
|
||||
**파일 응답** (공개 받기 · 우리 웹메일 받기 공용)
|
||||
- **머리글**: `Content-Type: application/octet-stream`(늘 — 형식은 서버가 정한다) · `Content-Disposition: attachment; filename=…; filename*=UTF-8''…`(일반 첨부와 같은 `buildAttachmentDisposition` — `shared/utils/content-disposition.ts:39`) ·
|
||||
`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` · `Content-Length` · `ETag: "<fileId>"`(강한 — 파일 내용은 id 마다 바뀌지 않는다) · `Last-Modified`(다 올린 때) · `X-Accel-Buffering: no`.
|
||||
- **범위(Range)**:
|
||||
- 없으면 200 전체. `bytes=` 범위 하나면 206 + `Content-Range: bytes <처음>-<끝>/<크기>`(끝이 크기를 넘으면 맞춰 자르고, `bytes=-N` 은 끝 N 바이트).
|
||||
- 범위가 여럿이거나 형식이 틀리면 범위를 무시하고 200 전체(RFC 9110 이 허락한다). `If-Range` 가 ETag · Last-Modified 와 다르면 200 전체.
|
||||
- 처음이 크기 이상이면 416 + `Content-Range: bytes */<크기>` — 본문은 비운다(내려받기 관리자가 읽는 응답이라 표준 실패 형식이 아니다 — 2절 첨부 받기처럼 래핑 예외).
|
||||
- `HEAD` 는 같은 머리글만 준다(파일을 열지 않고 세지 않는다).
|
||||
- **횟수**:
|
||||
- 센다: `GET` 이고 0번째 바이트부터 보내는 응답(범위 없음 · 무시 · `bytes=0-…`). 머리글을 보내기 직전에 원자적으로 더한다 — 상한에 닿았으면 더하지 않고 410 `FILE_007`.
|
||||
- 세지 않는다: 이어 받기(범위의 처음 > 0), `HEAD`, 링크 정보(화면 열기), 실패 응답, 링크를 만든 사람이 우리 웹메일 경로로 받은 것.
|
||||
- 상한(`SHARE_DOWNLOAD_LIMIT`, 파일당 500)에 닿으면 그 파일은 이어 받기도 410 `FILE_007` 이다. 남은 횟수는 어디에도 내세우지 않는다.
|
||||
- 받은 수 · 마지막으로 받은 때는 보낸 사람만 본다(목록 · 읽기 화면의 `downloads`). 누가 · IP 는 남기지 않는다.
|
||||
- 내려받는 동안 링크를 끊거나 기간이 지나도 이미 시작한 응답은 끝까지 간다(다음 요청부터 막힌다). 서버 종료가 시작되면 진행 중인 내려받기를 바로 끊고(종료 유예 30초를 붙잡지 않게 — 실시간 알림의 종료와 같은 결) 그 뒤에 온 받기는 시작하지 않는다 — 브라우저가 범위로 이어 받는다.
|
||||
- ⚠️ Nest 의 `StreamableFile` 로는 206 · 416 을 보낼 수 없다(`@Res({ passthrough: true })` 면 라우트 상태로 덮어쓴다 — `@nestjs/platform-express` 11.2.5) —
|
||||
처리기가 `@Res()` 로 상태 · 머리글을 직접 쓰고 흘려보낸다(2절 첨부 받기 `message.controller.ts:357-381` 과 다른 길).
|
||||
|
||||
**요청 수 한도**: IP 마다(`clientIpKey()` — IPv6 는 /64) 링크 정보 1분 60회, 파일 1분 300회(`HEAD` 포함 — 내려받기 관리자가 범위를 나눠 여러 번 부른다). 넘으면 429 `REQ_001`.
|
||||
요청 수일 뿐 바이트는 세지 않는다 — 퍼진 링크의 대역폭은 횟수 상한 · 기간 · 끊기가 막는다(앞단 동시 연결 — IP 마다 공개 10 · 앱 안 30, 전체 400 · 200 — 아래 "운영 확인").
|
||||
|
||||
**화면 `/s/{token}`** — SPA 의 공개 라우트
|
||||
- 셸 · 로그인 없이 그린다 — 라우트의 공개 표시(`meta.public`)로 세션 복원(`/auth/me` · `/auth/refresh` — 지금 가드는 모든 이동 전에 부른다 `frontend/src/router/index.ts:26-28`)과 로그인 화면 보내기를 건너뛴다.
|
||||
API 는 세션 갱신 · 세션 만료 대화상자를 타지 않는 따로 된 요청(쿠키 없이)으로 부른다.
|
||||
- 보이는 것: 회사 표시, 보낸 사람 이름 · 주소, 보낸 날 · 끝나는 날(한국 시간), 파일 목록(이름 · 크기 · 검사 상태 · [내려받기]), "이 화면은 로그인이나 비밀번호를 묻지 않습니다", 신고 · 문의 주소, 영어 한 줄.
|
||||
상태: 정상 · 검사 중 · 위험 · 검사하지 못함 · 만료 · 회수 · 횟수 초과 · 없는 링크 · 요청이 너무 많음, 좁은 폭(휴대폰). 글 · 모양은 디자인 README "11단계".
|
||||
- [내려받기]는 `<a href>` 이동이다(큰 파일을 `fetch` 로 받으면 메모리에 다 오른다) — 누르기 직전에 링크 정보를 한 번 더 묻고, 받을 수 없게 됐으면 화면에서 알린다(이동 뒤 JSON 오류가 뜨지 않게).
|
||||
끊기면 브라우저의 다시 시도로 이어 받는다. 검사 중이면 화면이 잠시 뒤 다시 묻는다(간격은 구현하며 정함 — 위 요청 수 안에서).
|
||||
|
||||
**공개 호스트** (`SHARE_PUBLIC_URL`)
|
||||
- 운영은 `https://files.arasystem.it.kr` — 포털과 다른 호스트라 털린 계정이 퍼뜨린 악성 파일로 포털까지 차단 목록에 오르는 위험을 어느 정도 떼어 놓고, 포털 쿠키도 가지 않는다
|
||||
(외부 리버스 프록시가 포털과 같은 사설 주소:8080 으로 넘긴다).
|
||||
- 공개 호스트에서는 **화면 `/s/{token}`(과 그 자산) · `/api/public-links/…` 만** 연다 — 그 밖은 404. 포털 호스트에서는 `/s/…` · `/api/public-links/…` 를 열지 않는다(404). 둘이 같은 주소면(개발) 둘 다 연다.
|
||||
앞단 nginx(호스트마다 server)와 백엔드(요청의 `Host` — nginx 가 원래 값을 넘긴다, `frontend/nginx.conf` 의 `proxy_set_header Host $host`)가 함께 막는다(구현하며 정함).
|
||||
구현(2026-10-30 — 백엔드): 요청 추적 ID 바로 뒤의 미들웨어(`modules/file-sharing/public-host.ts`)가 `Host` 의 호스트 이름(포트 없이 · 소문자 · 끝의 점 없이)을 `SHARE_PUBLIC_URL` 의 호스트 이름과 견준다.
|
||||
공개 호스트이면 `/api/public-links/` 만, 아니면 그 경로만 404 `RES_001`(표준 실패 형식 · 로그 없음 — 경로의 대소문자는 가리지 않는다).
|
||||
경로는 스택이 읽을 수 있는 모든 모양 — 원문(`?` 앞) · 라우터가 쓰는 경로(Express 의 `req.path`: 절대 주소 꼴은 스킴 · 호스트를 떼고, `#` 이 섞이면 `\` 를 `/` 로 읽는다) ·
|
||||
WHATWG URL(`\` → `/`, `%2e` 조각) · 퍼센트 인코딩을 한 번 푼 원문(nginx 처럼 `%2F` 도) — 을 정규화(겹친 `/` · `.` · `..` 조각 · 대소문자)해 본다.
|
||||
공개 호스트는 모든 모양이 공개 경로일 때만 열고(그래서 절대 주소 꼴은 받지 않는다), 포털은 어느 모양이라도 공개 경로면 닫는다. 읽지 못한 모양이 있으면 어느 쪽도 열지 않는다
|
||||
(nginx 의 정규화에 기대지 않는다 — 2026-10-30, 처음 판은 포털 Host 의 절대 주소 꼴 · `\…#` 요청이 공개 컨트롤러에 닿았다). 공개 호스트 이름이 포털의 출처(`CORS_ORIGIN`, 비운영은 개발 기본 주소 포함)의
|
||||
호스트 이름과 같으면(개발) 가르지 않는다 — 포트만 다른 두 주소는 nginx 가 포트 없는 `Host` 를 넘겨 가를 수 없기 때문이다.
|
||||
- 공개 주소는 `CORS_ORIGIN` 에 넣지 않는다 — 공개 화면은 같은 호스트의 공개 API 만 부른다(`connect-src 'self'` 그대로). 포털 화면이 링크의 상태를 볼 때는 포털의 `POST /api/share-link-lookups` 를 쓴다.
|
||||
- 덩이 · `url` 은 늘 지금의 `SHARE_PUBLIC_URL` 로 만든다. 바꾸면 그 전 메일의 덩이를 알아보지 않고, 옛 주소는 옛 호스트가 남아 있어야 열린다 — 바꾸지 않는다.
|
||||
|
||||
### 기간 · 보관 · 정리
|
||||
|
||||
- **링크가 끝나는 때** = 보낸 때(예약은 `sendAt`)의 **한국 날짜에 30일을 더한 날의 23:59:59.999(한국 시간 — UTC+9, 일광 절약 없음)**.
|
||||
예: 2026-10-30 에 보내면 `expiresAt` 은 `2026-11-29T14:59:59.999Z`(11월 29일(일) 끝까지). 받는 사람 모두 같은 날짜이고, 덩이 · 화면은 그 한국 날짜 하나로 적는다.
|
||||
요청마다 `지금 ≤ expiresAt` 으로 본다 — 정리 작업이 늦어도 정확하다.
|
||||
|
||||
| 대상 | 남는 기간 | 그 뒤 |
|
||||
| --- | --- | --- |
|
||||
| 올리는 중 | 마지막 조각(없으면 만든 때) 뒤 24시간 — 임시 저장에 실려도 같다 | 지운다 — `GET` 404, 임시 저장본에서는 `MissingLargeFile`(`expired`) |
|
||||
| 다 올렸지만 어디에도 실리지 않은 파일 | 다 올린 뒤 24시간 | 지운다 |
|
||||
| 임시 저장에 한 번이라도 실린 파일 | 다 올린 날(한국)부터 위와 같은 셈으로 30일째 날 끝 | 지운다 — 임시 저장본에서는 `MissingLargeFile`(`expired` — "기간이 지나 다시 올려야 하는 파일") |
|
||||
| 사용자가 지운 보내지 않은 파일 | 곧(보관량에서는 그때 바로 빠진다) | 임시 저장본에서는 `MissingLargeFile`(`deleted` — "지운 파일") |
|
||||
| 보낸 파일 | 그 파일을 실은 살아 있는 링크가 끝나는 때 가운데 가장 늦은 것 (임시 저장 기간보다 늦으면) | 지운다 |
|
||||
| 끊은 링크의 파일 · 위험 판정 파일 | 다른 쓰임이 없으면 곧 | 지운다 — 보관량에서는 그때 바로 빠진다 |
|
||||
| 링크 기록 | 끝나거나 끊긴 뒤 90일 — 그동안 공개 화면은 "기간 지남" · "공유 멈춤"이고 목록에 남는다 | 지운다 → "없는 링크"(404), 목록에서 사라진다 |
|
||||
| 취소한 링크 (보내기 취소 · 보내지 못함) | 되돌린 초안이 쓰는 동안 — 90일 | 지운다 |
|
||||
| 파일 기록 (이름 · 크기) | 그 파일을 가리키는 링크 기록이 다 지워질 때까지. 보낸 적 없는 파일은 지운 뒤 30일 | 지운다 — 그 뒤 임시 저장본은 그 id 를 버린다 |
|
||||
|
||||
- 임시 저장본을 영구 삭제해도 파일은 위 기간까지 남는다 — 초안은 우리 API 밖(IMAP · 자동 비우기)에서도 지워져 서버가 다 알 수 없다(알려진 한계 — 보관량에 잡히고, 보내지 않은 파일 목록에서 지울 수 있다).
|
||||
- 받은 기록은 **받은 수 · 마지막으로 받은 때만** 둔다 — 누가 · IP · 브라우저는 남기지 않는다.
|
||||
- 정리 작업(cron)이 바이트를 지운다 — 기록을 먼저 바꾸고 실제로 바꾼 것만 지운다(서명 그림 정리와 같은 결). 기록에 없는 파일은 하루 지난 것만 지운다(구현하며 정함).
|
||||
구현(2026-10-30): 파일 정리는 **한 시간마다**(`file-storage.cleanup.ts`) — 기간이 지난 파일을 지움(기간 지남)으로 바꾸고 바이트를 지운다, 지웠거나 위험 판정인데 바이트가 남은 것을 지운다,
|
||||
끝내기를 시작한 지 10분 넘게 멈춘 올리기를 되살린다(시작한 때 `completion_started_at` 로 고른다 — 다 옮겼으면 다 올림, 아니면 올리는 중), 지운 지 30일 지난 파일 기록을 지운다, 기록에 없는 저장소 파일 가운데 하루 지난 것을 지운다.
|
||||
링크 정리는 **하루 한 번 05:00(서버 시각)**(`file-sharing.cleanup.ts`) — 끝나거나 끊기거나 취소한 지 90일 지난 링크 기록을 지운다. 링크 기록은 파일 이름 · 크기를 따로 두므로 파일 기록과 따로 지운다.
|
||||
- **백업**: 파일 바이트는 백업하지 않는다(30일짜리 전송물) — 기록은 DB 백업에 든다. 복원한 뒤 바이트가 없는 파일은 `missing` 이다.
|
||||
|
||||
### 악성 검사 (ClamAV)
|
||||
|
||||
- **언제**: 끝내기 직후 검사 차례에 넣는다 — 서버 전체에서 한 번에 1~2개, 먼저 끝낸 것부터(구현하며 정함). 2GB 는 분 단위가 걸릴 수 있다.
|
||||
구현(2026-10-30): 한 번에 **2개**, 먼저 끝낸 것부터. 차례는 DB(`scan_status = 'pending'` · `scan_next_at`)라 서버를 다시 띄워도 잇는다 — 다 올린 직후 · 30초마다 · 검사 하나가 끝날 때 깨운다.
|
||||
clamd 연결은 10초, 흘려보내는 동안 무소식 10분, 다 보낸 뒤 판정까지 30분 + 보낸 1MB 마다 1초(2,047MB 면 3,847초 ≈ 64분 — `file-storage.constants.ts` 의 `clamavVerdictTimeoutMs`)를 넘으면 끊고 미뤄서 다시 한다.
|
||||
30분은 크기와 상관없는 몫이다 — 7z · tar.gz 는 MaxScanTime 없이 MaxScanSize(4,094MB)까지 풀리고, 실측 약 7MB/s 로 10분 · 운영 CPU 가 2~3배 느리면 20~30분이다. 1MB 마다 1초는 파일 자체의 몫(실측의 약 7배)이다.
|
||||
판정이 끝내 오지 않아 같은 파일을 끝없이 다시 검사하는 것보다 한 번 오래 기다리는 편을 골랐다(2026-10-30).
|
||||
연결에는 TCP keepalive 를 켠다(60초 뒤부터 1초 간격 10번 — Node 24 · libuv 1.52): 컨테이너째 사라져 RST 도 오지 않는 clamd 를 약 70초 안에 `read ETIMEDOUT` 으로 끊고 다른 끊김처럼 미뤄서 다시 한다.
|
||||
살아 있는 채 멈춘 clamd 는 keepalive 로 알 수 없어 판정 대기까지 검사 자리를 잡는다.
|
||||
- **검사 중에도 보낸다** — 받는 쪽은 `scanning`(받기 409 `FILE_008`)이고, 끝나면 받는다.
|
||||
- **판정** — 백엔드가 clamd 에 `INSTREAM` 으로 흘려보낸다(규약을 직접 구현 — 새 의존성 없음):
|
||||
- `OK` → `clean`(받을 수 있음).
|
||||
- `FOUND` 가운데 암호 · 검사 한도 표시(`Heuristics.Encrypted.*` · `Heuristics.Limits.Exceeded.*`) → `unscannable`(받을 수 있음 · "검사하지 못함").
|
||||
ClamAV 1.4.6 실측(2026-10-30): 암호 zip(ZipCrypto · AES) `Heuristics.Encrypted.Zip` · 암호 7z `.7Zip` · 사용자 암호 pdf `.PDF`(소유자 암호만 있는 pdf 는 `OK`),
|
||||
20겹 zip `Heuristics.Limits.Exceeded.MaxRecursion` · 파일 12,000개 zip `.MaxFiles` · 풀면 수 GB 인 zip `.MaxScanTime` · 한 파일 한도를 넘는 파일 `.MaxFileSize`.
|
||||
같은 압축 안에 진짜 서명과 휴리스틱(암호 · 한도)이 함께 있으면 진짜 서명이 나온다(`infected`).
|
||||
- 그 밖의 `FOUND` → `infected`(EICAR 는 `Eicar-Test-Signature FOUND` — 압축 안에 있어도 같다).
|
||||
- 스트림 크기 한도 오류(`INSTREAM size limit exceeded. ERROR` — clamd 의 `StreamMaxLength` 가 파일보다 작다) → **`pending` 그대로 + error 로그**(설정 오류 — 아래 "닿지 못함"과 같이 다시 시도).
|
||||
처음 초안의 `unscannable` + warn 은 2026-10-30 리뷰로 바꿨다 — 운영에서 `StreamMaxLength` 를 올리는 것을 잊으면 20MB 를 넘는 모든 파일이 검사 없이 열리고 흔적은 warn 한 줄뿐이었다.
|
||||
clamd 는 이 글을 보낸 뒤 연결을 닫고(FIN), 백엔드가 계속 보내면 RST 다 — 백엔드가 글을 읽기 전에 RST 가 오면 판정 글 없이 `EPIPE` 로 끝나 아래 "끊음"으로 다룬다(세 번째부터 error).
|
||||
운영 설정은 `StreamMaxLength` = 한 파일 한도의 최대값(2047M)이라 정상에서는 일어나지 않는다.
|
||||
- clamd 가 판정 글을 보내기 전에 연결을 끊음(RST · 빈 응답) → 다시 시도(`pending`).
|
||||
- **검사기에 닿지 못함** · 시간 초과 · 그 밖의 오류 → `pending` 그대로 두고 간격을 늘려 다시 한다(1시간마다까지 — 구현하며 정함). 이어서 실패하면 error 로그(운영 알림).
|
||||
구현(2026-10-30): 1분부터 두 배씩 1시간까지, 세 번째 실패부터 error(그 전은 warn). 판정 이름은 `Heuristics.Encrypted…` · `Heuristics.Limits.Exceeded…` 로 시작하면 `unscannable` 이다 —
|
||||
판정 글 견주기는 `modules/file-storage/clamav-client.ts` 의 `CLAMAV_REPLY` · `classifyClamavReply` 한 곳이고, `\0` 까지 받은 글만 판정으로 본다(끝나지 않은 글은 끊김 — 다시 시도).
|
||||
그 로그에는 단계와 보낸 바이트 수가 남는다 — 시도마다 같은 "흘려보내는 중(보낸 N바이트)"이면 크기 한도를 의심한다. 판정 글이 없으면 크기 한도와 clamd 재시작을 가를 수 없어 설정 오류로 단정하지 않는다(2026-10-30 확인).
|
||||
2026-10-30 release-ops 가 실제 clamd(ClamAV 1.4.6 LTS · 운영 설정)로 위 이름들을 재어 이 견주기와 맞음을 확인했다 — 판 · 설정을 바꾸면 다시 잰다(실측표는 mail/README.md "악성 검사 (ClamAV)").
|
||||
**판정 없이 받을 수 있게 되는 일은 없다**(닫힌 쪽).
|
||||
- **위험**: 받기를 막고(410 `FILE_007`) · 보내기를 막고(`MAIL_016`) · 보낸 사람에게 알린다 — 목록 상태 + 실시간 알림 `large-file` 이벤트(아래). 바이트는 곧 지우고(보관량에서 바로 빠진다) 기록(이름 · 판정 이름)은 남긴다.
|
||||
- **검사하지 못함**: 막지 않는다 — 공개 화면 · 목록 · 읽기 화면에 "검사하지 못함" 글자.
|
||||
- 검사는 한 번이다 — 나중에 나온 서명으로 다시 보지 않는다(알려진 한계).
|
||||
- 검사기를 쓰지 않는 개발(`CLAMAV_HOST` 비움)은 끝내자마자 `unscannable` 이다. 운영은 `CLAMAV_HOST` 가 없으면 기동하지 않는다.
|
||||
- **실시간 알림**(2절 `GET /api/mail-events` 에 더한다) — 위험 판정 때 그 직원의 연결에 보낸다:
|
||||
|
||||
```text
|
||||
event: large-file
|
||||
data: {"fileId":"…","name":"견적서.zip","scanStatus":"infected"}
|
||||
```
|
||||
|
||||
클라이언트는 알림("올린 대용량 파일 ‘견적서.zip’에서 위험한 내용이 발견되어 막았습니다." + [대용량 첨부 보기])을 띄우고, 쓰기 화면에 그 파일이 있으면 "위험 — 보낼 수 없음"으로 바꾼다.
|
||||
연결이 없던 직원은 목록 상태로만 안다(정한 것 — 2026-10-30, 다음에 들어올 때 따로 알리지 않는다).
|
||||
- 운영(release-ops): clamd 의 `StreamMaxLength` · `MaxFileSize` · `MaxScanSize` 를 한 파일 한도 이상으로, `AlertEncrypted` · `AlertExceedsMax` 를 켠다. 메모리 3~4GiB(운영 메일 서버 8GB), 서명 갱신(freshclam)의 바깥 연결,
|
||||
**backend 와만 잇는 새 내부 네트워크**(`app-network` 가 아니다 — mail setup 의 구성원 검사에 걸리고 메일 엔진의 신뢰 대역에 든다).
|
||||
구현(2026-10-30): 컨테이너 둘 — `clamav`(clamd · `<PROJECT_NAME>_clamav` internal 네트워크에만 · 서명 볼륨 읽기 전용)과 `clamav-updater`(freshclam · 자기 바깥 길 `clamav-egress` 에만).
|
||||
`StreamMaxLength` = `MaxFileSize` = 2047M · `MaxScanSize` 4094M · `MaxScanTime` 5분(1.4 에서는 ZIP 에만 걸린다). clamd 메모리 1GB 안팎(한도 2g — 서명을 다시 읽을 때 두 벌을 들지 않게 `ConcurrentDatabaseReload no`, 그동안 30초 안팎 검사가 기다린다).
|
||||
2,047MB 무작위 파일도 끝까지 검사한다(`OK` — 다 보낸 뒤 296초, 코어 하나 · 개발 PC). 설정 `mail/clamav/`, 운영 절차 · 메모리 예산 mail/README.md "대용량 첨부 (11단계 — 인프라)".
|
||||
|
||||
### 환경 변수 (루트 env — 넣는 일은 release-ops, 검증 · 테스트는 backend `env.validation.ts`)
|
||||
|
||||
| 키 | 기본값 | 범위 · 검사 | 뜻 |
|
||||
| --- | --- | --- | --- |
|
||||
| `SHARE_PUBLIC_URL` | 비운영에서 비우면 `CORS_ORIGIN` 의 첫 주소 | 출처만(스킴 · 호스트 · 포트 — 경로 · 끝 `/` 없음). **운영 필수** · `https` · 로컬 주소 · 예시 도메인 금지(`ALLOW_LOCALHOST_URL` 예외 — `CORS_ORIGIN` · `BACKEND_API_URL` 과 같은 검사). 모든 환경: 앞단 nginx(`frontend/nginx-env.envsh` 의 `nginx_env_origin_host`)가 받는 모양만 — 스킴 소문자, 호스트는 영문 · 숫자 · `.` · `-`(점 · 하이픈으로 시작하거나 끝나지 않고 점이 이어지지 않게 — 끝 점 없이), 포트는 0 으로 시작하지 않는 숫자, 사용자 정보 · 대괄호(IPv6) · 앞뒤 공백 없이(다르면 백엔드만 뜨는 반쪽 배포) | 링크 · 덩이 · 덩이 알아보기 · 공개 호스트 막기. 운영 `https://files.arasystem.it.kr` |
|
||||
| `SHARE_REPORT_ADDRESS` | `postmaster@<MAIL_DOMAIN>` | 메일 주소 | 공개 화면의 신고 · 문의 |
|
||||
| `SHARE_DOWNLOAD_LIMIT` | 500 | 1 ~ 100,000 | 파일당 내려받기 상한(받기 시작한 수) |
|
||||
| `MAIL_LARGE_FILE_MAX_SIZE_MB` | 2047 | 1 ~ 2047, `MAIL_ATTACHMENT_MAX_SIZE_MB` 이상 | 한 파일 |
|
||||
| `MAIL_LARGE_FILE_MAX_COUNT` | 10 | 1 ~ 20 | 메일 한 통 |
|
||||
| `MAIL_LARGE_FILE_USER_QUOTA_GB` | 10 | 1 ~ 1,000 | 직원당 보관량 |
|
||||
| `MAIL_LARGE_FILE_RETENTION_DAYS` | 30 | 1 ~ 90 | 보낸 뒤 · 임시 저장의 보관 날 수 |
|
||||
| `FILE_STORAGE_MAX_TOTAL_GB` | 비움 = 한도 없음(여유만 본다) | 1 이상 | 서버 전체 보관량 |
|
||||
| `FILE_STORAGE_MIN_FREE_GB` | 10 | 1 이상 | 만든 뒤에도 남길 디스크 여유 |
|
||||
| `CLAMAV_HOST` | 비움 = 검사하지 않음(개발) | **운영 필수** | clamd 주소 |
|
||||
| `CLAMAV_PORT` | 3310 | 1 ~ 65,535 | clamd 포트 |
|
||||
| `FILE_STORAGE_DIR` | `/app/files` | compose 가 고정해 넣는다(루트 env 아님) | 저장 위치 — named volume `<PROJECT_NAME>_files` |
|
||||
|
||||
- 숫자는 앞자리 0 없는 10진수다(`isPositiveIntString` — 지금 규칙). compose 가 넣는 빈 문자열은 기본값이다(`parsePositiveInt`). nginx 는 이 값들을 읽지 않는다(조각 17MB 는 계약 상수).
|
||||
- 구현 때 더한 검사(2026-10-30 — `env.validation.ts`): `MAIL_LARGE_FILE_USER_QUOTA_GB` × 1,024 는 `MAIL_LARGE_FILE_MAX_SIZE_MB` 이상이어야 한다(한도까지 큰 파일 하나는 올릴 수 있게).
|
||||
`MAIL_LARGE_FILE_MAX_SIZE_MB` 와 `MAIL_ATTACHMENT_MAX_SIZE_MB` 는 앞의 값을 정했을 때만 견준다. `CLAMAV_HOST` 는 호스트 이름만(스킴 · 포트 없이). `SHARE_REPORT_ADDRESS` 는 메일 주소.
|
||||
운영에서 `SHARE_PUBLIC_URL` 의 호스트 이름이 `CORS_ORIGIN` 의 어느 호스트 이름과 같으면(포트만 다르거나 대소문자 · 끝의 점만 달라도) 기동하지 않는다 — 공개 호스트 막기가 소리 없이 꺼지므로(2026-10-30 리뷰).
|
||||
공개 주소는 `CORS_ORIGIN` 에 넣지 않는다. 로컬에서 운영 이미지를 검증할 때도 이름을 나눈다(예: `localhost` 와 `127.0.0.1`).
|
||||
`SHARE_PUBLIC_URL` 의 모양은 모든 환경에서 앞단 nginx 와 같은 규칙으로 본다(위 표 — 2026-10-30, 대문자 스킴 · 끝 점 · 앞뒤 공백도 거절).
|
||||
`FILE_STORAGE_DIR` 를 compose 가 넣지 않으면 작업 폴더의 `files`(컨테이너에서는 `/app/files`, 호스트 개발에서는 `backend/files` — gitignore)다.
|
||||
|
||||
### 로그
|
||||
|
||||
- **토큰 · 링크 주소는 남기지 않는다**: 백엔드는 요청 주소의 `/api/public-links/<토큰>` 을 길이만 남긴다(`maskUrlForLog` 에 더한다 — 지금은 `/api/mail-images/<표>` 만, `mask.ts:26`). 경로의 대소문자는 가리지 않는다(라우팅이 가리지 않는다).
|
||||
구현(2026-10-30): 경로 어디서든(`//api/…` · `/api//…` · `/api/./…` 도) `public-links` 조각 뒤의 조각을 길이만 남긴다 — `files` 와 파일 id(UUID)는 남긴다.
|
||||
`POST /api/share-link-lookups` 의 본문(주소)도 남기지 않는다 — 개수만.
|
||||
앞단 nginx 접근 로그도 요청 줄 · Referer 의 토큰을 가린다(쿼리는 원래 빼고 — `frontend/nginx.conf` 의 `log_format main_rid` 앞 map 들. 2026-10-30 리뷰로 경로 모양이 아니라 토큰 모양(43자 base64url)으로도 가린다 — `//s/` · `%73` 같은 변형. 요청 줄에서는 둘까지 가리고, 43자가 아닌 값(UUID · 메일 id)은 그대로다. 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴은 가리지 못한다 — 한계).
|
||||
nginx 오류 로그(502 · 504 등)는 지금 정책대로 요청 줄을 그대로 남긴다 — 공개 내려받기의 그 줄에는 토큰이 남는다(운영 담당만 읽는다). 외부 리버스 프록시도 같게(운영 확인).
|
||||
- 파일 이름은 남기지 않는다(직원의 글이다) — 파일 · 링크 id 와 크기만. 직원 주소는 가린다(`maskEmail`).
|
||||
- 받는 쪽 IP 는 남기지 않는다 — 요청 수 한도의 셈(메모리)에만 쓴다. 앞단 nginx 접근 로그의 IP 는 지금 운영 로그 정책 그대로 — 바깥 사람의 개인 정보일 수 있어 보관 기간을 운영에서 정한다.
|
||||
- 레벨: 링크 만들기 · 끊기 · 다 올림 · 보내지 않은 파일 지우기는 log, 위험 판정은 warn(판정 이름 · 파일 id), 조각 · 내려받기 성공은 남기지 않는다(반복되는 정상 흐름), 공개 경로의 404 · 409 · 410 은 debug,
|
||||
검사기 장애가 이어지거나 디스크가 모자라면 error.
|
||||
|
||||
### 에러 코드 (11단계에 더함 — 제안, 문구는 레지스트리에서 정한다)
|
||||
|
||||
| 코드 | HTTP | 뜻 | 쓰는 곳 | 문구 (제안) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `FILE_003` | 409 | 올리기 · 파일의 지금 상태와 맞지 않는 요청 — 끝낸 올리기에 조각, 같은 조각을 동시에, 받은 조각과 내용이 다른 조각, 빠진 조각이 있는데 끝내기, 끝내기가 진행 중, 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 | 조각 · 끝내기 · 지우기 | "파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요." |
|
||||
| `FILE_004` | 400 | 조각의 `Content-Digest` 가 받은 바이트와 다르다 — 그 조각을 받지 않았다(같은 조각을 다시) | 조각 | "파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요." |
|
||||
| `FILE_005` | 409 | 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다 | 만들기 | "대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다." |
|
||||
| `FILE_006` | 507 | 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 `ENOSPC` | 만들기 · 조각 | "서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요." |
|
||||
| `FILE_007` | 410 | 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 | 공개 받기 · 우리 웹메일 받기 | "이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요." |
|
||||
| `FILE_008` | 409 | 아직 보안 검사 중인 파일 — 끝나면 받는다 | 공개 받기 · 우리 웹메일 받기 | "보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요." |
|
||||
| `MAIL_016` | 400 | 보내거나 저장할 대용량 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험. 메일은 나가지 않았다 | 발송 · 임시 저장 | "대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요." |
|
||||
|
||||
- 문구에 숫자를 넣지 않는다 — 한도가 env 로 바뀐다(`MAIL_006` · `CONTACT_002` 처럼 상수와 견주는 테스트를 둘 수 없다).
|
||||
- 그 밖은 지금 코드를 쓴다 — 형식 400 `FILE_001`, 한 파일 · 조각 본문 413 `FILE_002`, 개수 · 모양 400 `VAL_001`, 없음 404 `RES_001`, 요청 수 · 동시 수 429 `REQ_001`, 엔진 503 `MAIL_001`.
|
||||
- 410 · 507 은 상태 역매핑(`error-codes.ts` 의 `STATUS_TO_ERROR_CODE`)에 넣지 않는다 — 이 코드들은 `BusinessException` 으로만 나온다. 앞단 nginx 는 백엔드의 4xx · 5xx 를 손대지 않는다(`proxy_intercept_errors` 꺼짐).
|
||||
- 세 클라이언트의 목록 테스트에는 클라이언트가 실제로 분기하는 코드만 더한다(웹: `FILE_003` · `FILE_004` · `FILE_005` · `FILE_006` · `MAIL_016` — 2026-10-30. `FILE_008` 은 웹이 분기하지 않는다 — 받기는 `<a href>` 이동이고 검사 중이면 화면이 먼저 막는다).
|
||||
|
||||
### 운영 확인 (이 계약이 기대하는 것 — 고치는 일은 release-ops)
|
||||
|
||||
- 앞단 nginx: `/api/file-uploads` 본문 17MB · 읽기 90초. `/api/public-links/` · `/api/messages/*/large-files/` 는 응답 버퍼링을 끄고 `X-Accel-Buffering` 을 외부로 넘긴다
|
||||
(기본값이면 느린 받는 사람마다 임시 파일을 1GB 까지 만든다 — 실시간 알림과 같은 방법). `/s/` 는 `index.html` + `Cache-Control: no-cache` · `X-Robots-Tag: noindex, nofollow` · `Referrer-Policy: no-referrer`.
|
||||
접근 로그의 경로 토큰 가리기, 공개 호스트의 server(위 "공개 호스트").
|
||||
**내려받기 동시 연결 상한**(2026-10-30 리뷰 — 버퍼링을 끈 느린 받기는 연결 하나가 nginx 연결 둘과 백엔드 fd 를 잡아, 토큰 하나로 프론트 nginx 를 바닥낼 수 있다): `limit_conn` 으로
|
||||
공개 받기 IP 마다 10 · 앱 안 받기 IP 마다 30(사무실 NAT) · 서버 전체 공개 받기 400 · 앱 안 받기 200, 넘으면 429 `REQ_001`(nginx 오류 JSON). `worker_connections` 는 이 수와 SSE · 포털을 함께 버티게.
|
||||
전체 상한을 경로마다 따로 둔다(2026-10-30 리뷰 — 처음 판은 두 경로가 500 을 함께 써, 바깥 IP 50개(IPv6 면 /64 하나)로 직원의 앱 안 받기가 모두 429 가 됐다).
|
||||
경로의 대소문자 변형(`/FILES/` · `/api/Messages/…`)도 같은 상한 · 같은 흘려보내기를 탄다(2026-10-30 리뷰 — 처음 판은 글자 하나로 상한 · `X-Accel-Buffering` 넘김을 비켜 갔다).
|
||||
구현(2026-10-30): 클라이언트 주소는 `X-Forwarded-For` 의 마지막 칸(외부 프록시가 이어 붙인 값 — 없으면 접속 주소). 워커마다 `worker_connections` 4096 · 열린 파일 8192.
|
||||
받기 location 은 `/api/` 블록 안의 `~*` 정규식(id · 토큰 칸 `.+`) — 600개를 붙잡아 401번째 공개 · 201번째 앱 안이 429 이고 그때도 다른 쪽 · SSE 는 200 임을 확인했다.
|
||||
받기 머리글 버퍼 16k(한글 255자 이름이면 3.5KB). 공개 server 의 그 밖 `/api` · 포털이 닫은 공개 API(대소문자 변형도)는 nginx 의 404 `RES_001` JSON, 1m 넘는 본문은 413 `FILE_002` JSON.
|
||||
조각 경로는 오류 로그를 `error` 부터 남긴다(본문을 임시 파일에 받는다는 warn 이 조각마다 남았다).
|
||||
백엔드는 종료할 때 진행 중인 파일 응답을 먼저 끊는다(`BeforeApplicationShutdown` — 실시간 알림과 같은 결, 브라우저는 Range 로 잇는다).
|
||||
백엔드의 공개 호스트 판정은 nginx 가 넘기는 `Host`(`$host` — 소문자 · 포트 · 끝 점을 뗀 값)와 origin-form 요청 줄에 기댄다 — files 호스트의 server 도 `proxy_set_header Host $host` 를 둔다.
|
||||
- 외부 리버스 프록시: `files.` 호스트(같은 사설 주소:8080 · TLS), 조각 경로 본문 17MB 이상 · 읽기 150초, 내려받기 응답의 버퍼링 · 임시 파일 끔(또는 `X-Accel-Buffering` 따르기), 공개 경로는 **경로까지** 접근 로그에 남기지 않기, 받기 응답 머리글 버퍼 16k(NPM 은 `proxy_buffer_size` · `proxy_busy_buffers_size` 두 줄).
|
||||
- 볼륨 `<PROJECT_NAME>_files` — 크기 · 디스크(DB · 엔진과 다른 디스크 검토) · 감시, 백업에서 빼기, `down -v` 가 지운다는 경고. Dockerfile 이 `/app/files` 를 node 소유로 만든다.
|
||||
- ClamAV 컨테이너(위 "악성 검사" — clamd 는 띄울 때 `/tmp/clamav-*` 를 지운다: 검사 중에 죽으면 임시 파일이 쓰기 계층에 쌓였다). 사내 도메인을 꾸민 바깥 메일(From)은 DMARC 로 막는다(읽기 화면의 "보낸 사람 = 링크 주인" 판정이 기댄다 — 아래 "알려진 한계").
|
||||
- 확인(2026-10-30 — 운영 nginx 설정): 버퍼링을 끈 내려받기에서 `proxy_read_timeout` 은 돌지 않는다. 받는 쪽이 느려도 조금씩 받으면 이어지고(초당 120KB 로 80초),
|
||||
60초 동안 하나도 받지 않으면 `send_timeout`(기본 60초)이 끊고 동시 연결 수를 돌려준다.
|
||||
- 남은 확인(시험): 브라우저(Chrome · Edge · Firefox · Safari)가 우리 머리글로 끊긴 내려받기를 이어 받는지.
|
||||
|
||||
### 알려진 한계
|
||||
|
||||
- 같은 사본이라 받는 사람별 링크 · 기록 · 끊기가 없다 — 받은 수는 "링크를 가진 누군가"의 수다.
|
||||
- 횟수는 0번째 바이트부터 받기 시작한 수다 — 범위 요청만으로 받으면 세지 않는다(열린 질문 1).
|
||||
거꾸로 토큰을 가진 사람이 `Range: bytes=0-0` 같은 작은 요청으로 상한을 빨리 써 버리면 같은 링크의 다른 받는 사람이 410 이 된다(IP 하나로 몇 분) — 닫힌 쪽이라 새는 것은 없고, 보낸 사람이 다시 보내면 된다(2026-10-30 리뷰 — 보낸 바이트 기준 상한은 처음 판에 넣지 않는다).
|
||||
- 끊어도 이미 시작한 내려받기는 끝까지 간다. 받아 간 파일은 되돌릴 수 없다.
|
||||
- 인용 속 원래 링크는 글이다 — 우리 웹메일도 첨부처럼 보이지 않고, 원래 링크의 받은 수 · 상한 · 기간을 따른다(전달받은 사람이 받아도 원래 보낸 사람의 받은 수로 센다).
|
||||
- 읽기 화면의 "보낸 사람 = 링크 주인" 판정은 머리글 From 을 믿는다 — 사내 도메인을 꾸민 메일이 그 직원의 진짜 토큰을 담으면 첨부처럼 보인다(파일은 진짜다. DMARC 가 막는다 — 운영 확인).
|
||||
- 다른 메일 프로그램이 우리 임시 저장본을 그대로 보내면 머리글만 나가고 덩이는 없다. 우리 API 밖에서 지운 임시 저장본의 파일은 기간까지 보관량을 잡는다(보내지 않은 파일 목록에서 지울 수 있다).
|
||||
- 텍스트만 있는 메일 · HTML 이 정화 한도를 넘은 메일의 덩이는 알아보지 않는다.
|
||||
- `SHARE_PUBLIC_URL` 을 바꾸면 그 전 메일의 덩이를 알아보지 않는다.
|
||||
- 경로 토큰이 nginx 오류 로그 · 외부 프록시 로그에 남을 수 있다.
|
||||
- 검사는 한 번이다 — 나중에 나온 서명으로 다시 보지 않는다.
|
||||
- ClamAV 1.4.6 은 zip **안** 항목이 크기 한도(`MaxFileSize` · `MaxScanSize`)를 넘을 때 `AlertExceedsMax` 를 켜도 `OK` 로 줄 때가 있다(한도를 작게 둔 시험 clamd 에서 확인 —
|
||||
운영 값에서는 `MaxScanTime` 이 먼저 걸렸다). 7z · tar.gz 처럼 zip 이 아닌 압축은 시간 상한 없이 `MaxScanSize` 까지 풀어 검사가 오래 걸릴 수 있다.
|
||||
- 내려받기 동시 연결 상한은 `X-Forwarded-For` 의 마지막 칸으로 센다 — 외부 프록시를 건너뛰어 8080 에 바로 닿는 길이 있으면 주소마다 상한을 피한다(경로마다 서버 전체 상한은 그대로).
|
||||
IPv6 는 주소 그대로 센다(/64 로 묶지 않는다).
|
||||
- 한 인스턴스 전제 — 올리기의 동시 수 · 만들기 차례 · 같은 조각 잠금은 서버 메모리, 파일은 로컬 볼륨이다. 인스턴스를 늘리면 공유 저장(S3 호환 — 같은 저장 포트의 새 어댑터)과 잠금으로 옮긴다
|
||||
(nestjs 스킬의 "확장 때 옮길 것"에 더한다).
|
||||
|
||||
### 이 단계에서 하지 않는 것
|
||||
|
||||
기간 고르기 · 연장, 다시 보내기(다시 올리지 않고 새 링크), 받는 사람 확인 코드 · 비밀번호, 사내만(로그인해야 받기), 받는 사람별 링크 · 기록, 처음 받음 · 만료 전 알림, 미리 보기, 모두 받기(zip), 폴더 올리기,
|
||||
파일 하나만 끊기(끊기는 링크 단위), 설정 목록의 [링크 복사](토큰을 되살릴 수 없다 — 보낸 메일의 덩이에서), 대용량 첨부만 있는 메일의 목록 클립, 링크 → 일반 첨부로 되돌리기,
|
||||
전달 원본 · 임시 저장본의 엔진 첨부를 링크로 바꾸기, 인용 속 원래 링크를 다시 싣기,
|
||||
관리자 화면 · 퇴사자 처리(신고를 받은 링크는 그때까지 운영 스크립트로 끊는다 — 구현하며 정함), 보낸 메일을 지울 때 파일도 지우기, 모바일 앱, 파일 저장소 연동,
|
||||
다른 PC · 다른 브라우저에서 이어 올리기(같은 브라우저에서 같은 파일을 다시 고르면 이어진다).
|
||||
|
||||
### 정한 것 (열린 질문이었던 것 — 2026-10-30 추천대로)
|
||||
|
||||
1. **횟수 상한의 범위 요청 우회** — 0번째 바이트를 받지 않는 범위 요청은 세지 않아, 도구를 쓰면 상한을 피할 수 있다. 추천: 처음 판은 그대로(브라우저 · 내려받기 관리자는 0번부터 받는다)이고 한계로 적는다.
|
||||
남용이 보이면 "보낸 바이트 ÷ 파일 크기" 상한을 함께 둔다.
|
||||
2. **위험 판정 알림** — 추천: 실시간 이벤트 + 목록 상태(처음 판). 연결이 없던 직원까지 알리는 받은편지함 시스템 메일은 털린 계정을 알아채는 데 좋지만 엔진에 메일을 넣는 길이 새로 든다 — 13단계(관리자 절차)와 함께 다시 본다.
|
||||
3. **검사기 장애가 길 때** — 추천: 받기를 막은 채(검사 중) 두고 error 로그로 운영이 고친다(닫힌 쪽). 대안 — 몇 시간 뒤 "검사하지 못함"으로 연다(받는 쪽이 오래 기다리지 않지만 검사 없이 퍼진다).
|
||||
4. **ClamAV 크기 한도** — 기본 2,047MB 가 운영에 쓸 clamd 판에서 끝까지 검사되는지 실측한다(자료는 "2GB 넘는 파일은 검사하지 못함" — 판마다 다를 수 있다). 안 되면 기본값을 낮춘다.
|
||||
실측(2026-10-30, ClamAV 1.4.6): 2,047MB 는 끝까지 검사된다(`OK`) — 기본값 그대로 둔다. 1.4.6 은 2GiB − 1 바이트 넘게는 검사하지 못한다.
|
||||
5. **위험 파일의 바이트** — 추천: 곧 지운다(디스크에 악성 파일을 두지 않고 보관량에서 바로 뺀다). 오탐을 다시 보려면 서명 갱신 뒤 다시 올린다.
|
||||
6. **일반 첨부 목록 밖 · 대용량 목록 안 형식의 작은 파일**(5MB 짜리 mp4 · dwg 등) — 지금 일반 첨부로는 보낼 수 없는 형식이다. 추천: 크기와 상관없이 링크로(쓰기 화면이 자동으로 — 이 쓰기 화면에서 고른 파일만이라 위 "손으로 「링크로 보내기」" 규칙과 맞는다).
|
||||
7. **보내지 않은 파일 목록에 올리는 중인 것도 넣을지** — 추천: 넣는다(이 계약). 보관량을 잡고 있어, 창을 닫아 멈춘 올리기를 24시간 전에 지울 길이 된다.
|
||||
8. **공개 호스트의 화면 묶음** — 추천: 처음 판은 같은 SPA 묶음 + nginx 가 `/s/` · 자산 · `/api/public-links/` 만 연다. 공개 화면에 앱 코드를 싣지 않으려면 Vite 다중 입구(`share.html`)를 쓴다.
|
||||
9. **`FILE_006` 의 HTTP 상태** — 추천: 507(저장 공간을 정확히 말하고, 클라이언트의 자동 다시 시도 대상(502 · 503 · 504)과 상태로 갈린다). 대안 503.
|
||||
10. **형식 검사의 MIME** — 추천: 확장자 허용 목록만(위 "올리기" 1). 보안 규칙(`.claude/rules/security.md` "확장자와 MIME 을 둘 다")의 예외라 규칙 문서에 까닭과 함께 적는다(rules 담당).
|
||||
11. **인용 속 링크 상태를 묻는 길** — 추천: 주소 목록을 보내는 `POST /api/share-link-lookups`(이 계약 — 저장하지 않은 쓰기 화면의 인용에도 쓴다). 대안 — 원본 메일 id 로 서버가 본문에서 찾기(`GET /api/messages/{id}/…`) — 클라이언트가 주소를 고르지 않아도 되지만 임시 저장본 · 고친 인용은 따로 다뤄야 한다.
|
||||
|
||||
Reference in New Issue
Block a user