diff --git a/app/api/documents.py b/app/api/documents.py index 31b4722..55dda6c 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -69,6 +69,19 @@ def _upload_error(status_code: int, error_code: str, message: str) -> HTTPExcept ) +async def get_live_document(session: AsyncSession, doc_id: int) -> Document: + """soft-delete(deleted_at) 가드 포함 문서 조회 — 없거나 삭제됐으면 404 (R7). + + 조회/수정 경로는 deleted_at 을 일관 가드하나 파일/콘텐츠 서빙 엔드포인트가 누락 → + 삭제 문서의 원본/preview/전문이 doc_id(+유효 토큰)만으로 노출되던 비대칭. '경로마다 + deleted_at 기억'에 의존하지 않게 헬퍼로 구조 강제(추가될 서빙 경로도 자동 보호). + """ + doc = await session.get(Document, doc_id) + if not doc or doc.deleted_at is not None: + raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + return doc + + async def _near_dup_scan_bg(doc_id: int) -> None: """B-3: post-upload near_duplicate 스캔 (BackgroundTask). 자체 세션, best-effort. @@ -838,9 +851,7 @@ async def get_document_file( # 일반 Bearer 헤더 인증 시도 raise HTTPException(status_code=401, detail="토큰이 필요합니다") - doc = await session.get(Document, doc_id) - if not doc: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + doc = await get_live_document(session, doc_id) # note(메모)는 물리 파일이 없음 if not doc.file_path: @@ -943,10 +954,8 @@ async def get_document_image_raw( if not payload or payload.get("type") != "access": raise HTTPException(status_code=401, detail="유효하지 않은 토큰") - # 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단) - doc = await session.get(Document, doc_id) - if doc is None: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + # 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단 + soft-delete 가드) + doc = await get_live_document(session, doc_id) img = await session.scalar( select(DocumentImage).where( @@ -1357,9 +1366,8 @@ async def save_document_content( body: dict = None, ): """Markdown 원본 파일 저장 + extracted_text 갱신""" - doc = await session.get(Document, doc_id) - if not doc: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + # soft-delete 문서엔 쓰기 차단 (R7 — 삭제 문서 resurrect / NAS 재기록 방지) + doc = await get_live_document(session, doc_id) if doc.file_format not in ("md", "txt"): raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)") @@ -1399,9 +1407,7 @@ async def get_document_preview( else: raise HTTPException(status_code=401, detail="토큰이 필요합니다") - doc = await session.get(Document, doc_id) - if not doc: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + doc = await get_live_document(session, doc_id) preview_path = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf" if not preview_path.exists(): @@ -1448,9 +1454,7 @@ async def get_document_content( session: Annotated[AsyncSession, Depends(get_session)], ): """문서 전문 텍스트 반환 (서비스 호출용).""" - doc = await session.get(Document, doc_id) - if not doc: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + doc = await get_live_document(session, doc_id) raw_text = doc.extracted_text or "" content = raw_text[:15000] diff --git a/app/services/papers/holder.py b/app/services/papers/holder.py index 2455dc5..1bc601a 100644 --- a/app/services/papers/holder.py +++ b/app/services/papers/holder.py @@ -32,7 +32,8 @@ async def find_paper_holder(session, raw_or_normalized_doi): return None result = await session.execute( select(Document) - .where(Document.material_type == "paper", _DOI_EXPR == doi) + .where(Document.material_type == "paper", _DOI_EXPR == doi, + Document.deleted_at.is_(None)) .limit(1) ) return result.scalars().first()