security: fix 5 review findings (2 high, 3 medium)

HIGH:
- Lock setup TOTP/NAS endpoints behind _require_setup() guard
  (prevented unauthenticated admin 2FA takeover after setup)
- Sanitize upload filename with Path().name + resolve() validation
  (prevented path traversal writing outside Inbox)

MEDIUM:
- Add score > 0.01 filter to hybrid search via subquery
  (prevented returning irrelevant documents with zero score)
- Implement Inbox → Knowledge file move after classification
  (classify_worker now moves files based on ai_domain)
- Add Anthropic Messages API support in _request()
  (premium/Claude path now sends correct format and parses
  content[0].text instead of choices[0].message.content)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-02 15:33:31 +09:00
parent 31d5498f8d
commit d93e50b55c
5 changed files with 114 additions and 37 deletions

View File

@@ -1,10 +1,13 @@
"""AI 분류 워커 — Qwen3.5로 도메인/태그/요약 생성"""
"""AI 분류 워커 — Qwen3.5로 도메인/태그/요약 생성 + Inbox→Knowledge 이동"""
import shutil
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from ai.client import AIClient, parse_json_response
from core.config import settings
from core.utils import setup_logger
from models.document import Document
@@ -67,6 +70,10 @@ async def process(document_id: int, session: AsyncSession) -> None:
doc.ai_model_version = "qwen3.5-35b-a3b"
doc.ai_processed_at = datetime.now(timezone.utc)
# ─── Inbox → Knowledge 폴더 이동 ───
if doc.file_path.startswith("PKM/Inbox/") and domain:
_move_to_knowledge(doc, domain)
logger.info(
f"[분류] document_id={document_id}: "
f"domain={domain}, tags={doc.ai_tags}, summary={len(summary)}"
@@ -74,3 +81,35 @@ async def process(document_id: int, session: AsyncSession) -> None:
finally:
await client.close()
def _move_to_knowledge(doc: Document, domain: str):
"""분류 완료 후 Inbox에서 Knowledge 폴더로 파일 이동"""
nas_root = Path(settings.nas_mount_path)
src = nas_root / doc.file_path
if not src.exists():
logger.warning(f"[이동] 원본 파일 없음: {src}")
return
# 대상 경로: PKM/{domain}/{파일명}
sub_group = doc.ai_sub_group
if sub_group:
new_rel = f"PKM/{domain}/{sub_group}/{src.name}"
else:
new_rel = f"PKM/{domain}/{src.name}"
dst = nas_root / new_rel
dst.parent.mkdir(parents=True, exist_ok=True)
# 중복 파일명 처리
counter = 1
stem, suffix = dst.stem, dst.suffix
while dst.exists():
dst = dst.parent / f"{stem}_{counter}{suffix}"
new_rel = str(dst.relative_to(nas_root))
counter += 1
shutil.move(str(src), str(dst))
doc.file_path = new_rel
logger.info(f"[이동] {doc.file_path}{new_rel}")