fix(packs-lab): PACK_HOST_DIR 도입 — sign-link 시 DSM이 NAS 호스트경로 받도록

이전: upload가 컨테이너 경로(/app/data/packs/...)를 Supabase에 저장 →
sign-link 시 그 경로를 DSM에 전달 → DSM은 NAS 호스트 절대경로
(/volume1/.../media/packs/...) 기준이라 파일을 찾지 못함.

수정:
- routes.py: PACK_HOST_DIR 신규 (env, fallback=PACK_BASE_DIR)
  - upload 시 host_path = PACK_HOST_DIR/{tier}/{filename}을 Supabase에 INSERT
  - sign-link 시 PACK_HOST_DIR 기준 경로 검증
- docker-compose: PACK_HOST_DIR env 주입 (default=PACK_DATA_PATH)
- .env.example + CLAUDE.md: 환경변수 의미 분리 명시
- tests: 호스트경로 저장 검증 신규 (test_upload_stores_host_path_not_container_path)
- 25/25 passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 02:47:26 +09:00
parent 8b5cb2c16a
commit f1f1dc98a6
5 changed files with 76 additions and 8 deletions

View File

@@ -32,6 +32,10 @@ logger = logging.getLogger("packs-lab.routes")
router = APIRouter(prefix="/api/packs")
PACK_BASE_DIR = Path(os.getenv("PACK_BASE_DIR", "/app/data/packs"))
# DSM·Supabase에 노출되는 NAS 호스트 절대경로. 컨테이너 내부 PACK_BASE_DIR과 같은 디렉토리를
# 호스트 시점에서 가리켜야 한다 (docker volume 마운트의 호스트 측 경로). 미설정 시 PACK_BASE_DIR로
# fallback — 로컬 개발용. 운영 NAS에서는 반드시 PACK_HOST_DIR=/volume1/docker/webpage/media/packs.
PACK_HOST_DIR = Path(os.getenv("PACK_HOST_DIR", str(PACK_BASE_DIR)))
ALLOWED_EXT = {"pdf", "zip", "mp4", "mov", "mkv", "wav", "m4a", "mp3", "png", "jpg", "jpeg", "webp", "prj"}
MAX_BYTES = 5 * 1024 * 1024 * 1024 # 5GB
SAFE_FILENAME = re.compile(r"^[\w가-힣\-\.\(\)\s]+$")
@@ -67,9 +71,10 @@ async def sign_link(
verify_request_hmac(body, x_timestamp, x_signature)
payload = SignLinkRequest.model_validate_json(body)
# 경로 안전: PACK_BASE_DIR 하위인지 확인
# 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인.
# file_path는 upload 라우트가 Supabase에 저장한 호스트경로 그대로 전달되어 DSM API에 사용됨.
abs_path = Path(payload.file_path).resolve()
if not str(abs_path).startswith(str(PACK_BASE_DIR)):
if not str(abs_path).startswith(str(PACK_HOST_DIR)):
raise HTTPException(status_code=400, detail="허용된 경로 외부")
try:
@@ -148,6 +153,10 @@ async def upload(
target.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail=f"실제 크기({written})와 토큰 크기({expected_size}) 불일치")
# Supabase·DSM에 노출되는 file_path는 NAS 호스트 절대경로여야 한다.
# 컨테이너 경로(target)는 마운트된 호스트경로의 다른 시점일 뿐이라, 같은 디렉토리 구조를 보유.
host_path = PACK_HOST_DIR / tier / filename
# supabase INSERT
sb = _supabase()
file_id = str(uuid.uuid4())
@@ -155,7 +164,7 @@ async def upload(
"id": file_id,
"min_tier": tier,
"label": label,
"file_path": str(target),
"file_path": str(host_path),
"filename": filename,
"size_bytes": written,
}).execute()
@@ -165,7 +174,7 @@ async def upload(
return UploadResponse(
file_id=file_id,
file_path=str(target),
file_path=str(host_path),
filename=filename,
size_bytes=written,
min_tier=tier,