From 7d2e678ea190249e3a7e000212d3f43c8a10c160 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 17 Apr 2026 08:03:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(upload):=20=EC=8A=A4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EB=B0=8D=20size=20=EA=B2=80=EC=A6=9D=20+=200=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20reject=20+=20=EA=B3=A0=EC=95=84=20=EB=A0=88?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 `await file.read()` 는 임의 크기 파일을 메모리에 전부 적재한 후 저장해 디스크 고갈 / OOM 공격 벡터 였음. Caddy/home-caddy 프록시 한도에만 의존했고 FastAPI 측 policy enforcement 가 전무했음. 이 커밋으로 서버가 authoritative 으로 강제 집행. 변경: - `Request` DI 추가 → Content-Length 사전 차단 (max_bytes * slack_ratio 초과 시 413) - `await file.read()` → 청크 루프 스트리밍 (stream_chunk_bytes 단위) - 누적 size > max_bytes 시 스트리밍 중 413 (Content-Length 위조 방어) - 0바이트 파일 → 400 reject (정책: 유의미한 문서 ingest 대상 아님) - 파일 저장 완료 + close 이후 에만 file_hash 및 DB 레코드 생성 - Document 레코드 와 processing_queue 는 단일 트랜잭션으로 묶고, DB 예외 시 session rollback + partial file unlink 로 원자적 정리 - 예외 시 `except Exception` 으로 cleanup (BaseException 계열은 의도적으로 패스) 설정 값: config.yaml `upload.{max_bytes, content_length_slack_ratio, stream_chunk_bytes}`. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/documents.py | 101 +++++++++++++++++++++++++++++++------------ 1 file changed, 74 insertions(+), 27 deletions(-) diff --git a/app/api/documents.py b/app/api/documents.py index 2c2acf8..d1f4599 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -17,6 +17,7 @@ from fastapi import ( Header, HTTPException, Query, + Request, UploadFile, status, ) @@ -434,6 +435,7 @@ async def get_document_file( @router.post("/", response_model=DocumentResponse, status_code=201) async def upload_document( + request: Request, file: UploadFile, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], @@ -444,9 +446,33 @@ async def upload_document( facet_year: int | None = Form(None), facet_doctype: str | None = Form(None), ): - """파일 업로드 → Inbox 저장 + DB 등록 + 처리 큐 등록""" + """파일 업로드 → Inbox 저장 + DB 등록 + 처리 큐 등록. + + Size 한도: `settings.upload.max_bytes` (authoritative). + - Content-Length 사전 차단 (slack_ratio 여유) → 413 + - 스트리밍 누적 검사 (Content-Length 위조 방어) → 413 + - 0바이트 파일은 400 reject + - 파일 저장 완료 후에만 DB 레코드 생성 (고아 레코드 방지) + - 예외 발생 시 partial file cleanup + """ from core.library import DEFAULT_LIBRARY_PATH, LIBRARY_PREFIX, normalize_library_path + max_bytes = settings.upload.max_bytes + slack_ratio = settings.upload.content_length_slack_ratio + chunk_size = settings.upload.stream_chunk_bytes + + # ── 사전 검증 (스트리밍 IO 시작 전) ── + + # Content-Length 사전 차단 (multipart 오버헤드 여유 반영) + content_length_header = request.headers.get("content-length") + if content_length_header: + try: + cl = int(content_length_header) + if cl > int(max_bytes * slack_ratio): + raise HTTPException(status_code=413, detail="파일이 너무 큽니다") + except ValueError: + pass # 잘못된 헤더는 스트리밍 단계에서 max_bytes 로 차단 + # doc_purpose 검증 if doc_purpose is not None: doc_purpose = doc_purpose.strip().lower() @@ -476,7 +502,8 @@ async def upload_document( if not safe_name or safe_name.startswith("."): raise HTTPException(status_code=400, detail="유효하지 않은 파일명") - # Inbox에 파일 저장 + # ── 대상 경로 결정 ── + inbox_dir = Path(settings.nas_mount_path) / "PKM" / "Inbox" inbox_dir.mkdir(parents=True, exist_ok=True) target = (inbox_dir / safe_name).resolve() @@ -492,36 +519,56 @@ async def upload_document( target = inbox_dir.resolve() / f"{stem}_{counter}{suffix}" counter += 1 - content = await file.read() - target.write_bytes(content) + # ── 스트리밍 저장 + 누적 size 검사 ── + + written = 0 + try: + with target.open("wb") as f: + while chunk := await file.read(chunk_size): + written += len(chunk) + if written > max_bytes: + raise HTTPException(status_code=413, detail="파일이 너무 큽니다") + f.write(chunk) + # with 블록 종료 시 자동 flush + close + if written == 0: + raise HTTPException(status_code=400, detail="빈 파일은 업로드할 수 없습니다") + except Exception: + # partial file cleanup. KeyboardInterrupt/SystemExit 등 BaseException 계열은 잡지 않음. + target.unlink(missing_ok=True) + raise + + # ── 파일 저장 완료 후에만 hash + DB 레코드 ── - # 상대 경로 (NAS 루트 기준) rel_path = str(target.relative_to(Path(settings.nas_mount_path))) fhash = file_hash(target) ext = target.suffix.lstrip(".").lower() or "unknown" - # DB 등록 - doc = Document( - file_path=rel_path, - file_hash=fhash, - file_format=ext, - file_size=len(content), - file_type="immutable", - title=target.stem, - source_channel="manual", - doc_purpose=doc_purpose, - user_tags=[library_tag] if library_tag else [], - facet_company=facet_company or None, - facet_topic=facet_topic or None, - facet_year=facet_year, - facet_doctype=facet_doctype or None, - ) - session.add(doc) - await session.flush() - - # 처리 큐 등록 - await enqueue_stage(session, doc.id, "extract") - await session.commit() + try: + doc = Document( + file_path=rel_path, + file_hash=fhash, + file_format=ext, + file_size=written, + file_type="immutable", + title=target.stem, + source_channel="manual", + doc_purpose=doc_purpose, + user_tags=[library_tag] if library_tag else [], + facet_company=facet_company or None, + facet_topic=facet_topic or None, + facet_year=facet_year, + facet_doctype=facet_doctype or None, + ) + session.add(doc) + await session.flush() + # document + processing_queue 는 단일 트랜잭션으로 묶어 원자적 정리 + await enqueue_stage(session, doc.id, "extract") + await session.commit() + except Exception: + # DB 예외 시 session 은 get_session 컨텍스트 종료로 자동 rollback. + # 파일시스템 자원은 DB 와 분리된 자원이므로 명시적 unlink. + target.unlink(missing_ok=True) + raise return DocumentResponse.model_validate(doc)