Files
web-page-backend/packs-lab/app/routes.py
gahusb aec0fdcd31 fix(packs-lab): tier 디렉토리 제거(평면 구조) + deployer SERVICES에 packs-lab 추가
문제 1: deploy-nas.sh의 SERVICES 화이트리스트에 packs-lab이 빠져 있어
NAS 운영 디렉토리에 소스 sync가 안 됐고 docker compose가 packs-lab을
빌드 못해 컨테이너가 안 떠 있었다.

문제 2: routes.py가 PACK_BASE_DIR/{tier}/{filename} 트리 구조로 저장 →
사용자 요청에 따라 평면 구조(PACK_BASE_DIR/{filename})로 변경. tier 구분은
filename 규칙(prefix 등)으로 admin이 관리.

- scripts/deploy-nas.sh: SERVICES에 packs-lab 추가 (10개 → 11개)
- routes.py: tier 디렉토리 제거 (target = PACK_BASE_DIR / filename, host_path = PACK_HOST_DIR / filename)
- tests: tier 분기 사용처 평면 구조로 보정 (size_mismatch / host_path_check)
- 25/25 passing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:54:25 +09:00

222 lines
8.0 KiB
Python

"""packs-lab API 엔드포인트.
- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
- POST /api/packs/admin/mint-token — Vercel HMAC 인증 → 일회성 upload 토큰
- POST /api/packs/upload — 일회성 토큰 인증 → multipart 저장 + supabase INSERT
- GET /api/packs/list — Vercel HMAC 인증 → pack_files 전체 조회
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
"""
import logging
import os
import re
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile
from supabase import Client, create_client
from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
from .dsm_client import DSMError, create_share_link
from .models import (
MintTokenRequest,
MintTokenResponse,
PackFileItem,
SignLinkRequest,
SignLinkResponse,
UploadResponse,
)
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]+$")
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
def _supabase() -> Client:
url = os.getenv("SUPABASE_URL", "")
key = os.getenv("SUPABASE_SERVICE_KEY", "")
if not url or not key:
raise HTTPException(status_code=503, detail="Supabase 설정 미완료")
return create_client(url, key)
def _check_filename(filename: str) -> str:
if not SAFE_FILENAME.match(filename):
raise HTTPException(status_code=400, detail="파일명에 허용되지 않은 문자가 포함되어 있습니다")
if "/" in filename or "\\" in filename or filename.startswith("."):
raise HTTPException(status_code=400, detail="잘못된 파일명")
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in ALLOWED_EXT:
raise HTTPException(status_code=400, detail=f"허용되지 않은 확장자: {ext}")
return filename
@router.post("/sign-link", response_model=SignLinkResponse)
async def sign_link(
request: Request,
x_timestamp: str = Header(""),
x_signature: str = Header(""),
):
body = await request.body()
verify_request_hmac(body, x_timestamp, x_signature)
payload = SignLinkRequest.model_validate_json(body)
# 경로 안전: 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_HOST_DIR)):
raise HTTPException(status_code=400, detail="허용된 경로 외부")
try:
url, expires_at = await create_share_link(str(abs_path), payload.expires_in_seconds)
except DSMError as e:
logger.error("DSM 오류: %s", e)
raise HTTPException(status_code=502, detail=f"DSM 오류: {e}")
return SignLinkResponse(url=url, expires_at=expires_at)
@router.post("/admin/mint-token", response_model=MintTokenResponse)
async def mint_token(
request: Request,
x_timestamp: str = Header(""),
x_signature: str = Header(""),
):
body = await request.body()
verify_request_hmac(body, x_timestamp, x_signature)
payload = MintTokenRequest.model_validate_json(body)
_check_filename(payload.filename)
jti = str(uuid.uuid4())
expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC
token = mint_upload_token({
"tier": payload.tier,
"label": payload.label,
"filename": payload.filename,
"size_bytes": payload.size_bytes,
"jti": jti,
"expires_at": expires_ts,
})
return MintTokenResponse(
token=token,
expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc),
jti=jti,
)
@router.post("/upload", response_model=UploadResponse)
async def upload(
file: UploadFile = File(...),
authorization: str = Header(""),
):
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Authorization 헤더 누락")
token = authorization[len("Bearer "):]
payload = verify_upload_token(token)
tier = payload["tier"]
label = payload["label"]
filename = _check_filename(payload["filename"])
expected_size = int(payload["size_bytes"])
# tier 디렉토리는 만들지 않고 PACK_BASE_DIR 평면 구조에 저장. tier 구분은 filename 규칙으로.
PACK_BASE_DIR.mkdir(parents=True, exist_ok=True)
target = PACK_BASE_DIR / filename
if target.exists():
raise HTTPException(status_code=409, detail="이미 존재하는 파일명입니다. 다른 이름으로 업로드하거나 기존 파일을 먼저 삭제하세요")
# multipart 스트림 저장 + 크기 검증
written = 0
with target.open("wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
written += len(chunk)
if written > MAX_BYTES:
f.close()
target.unlink(missing_ok=True)
raise HTTPException(status_code=413, detail="파일 크기 5GB 초과")
f.write(chunk)
if written != expected_size:
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 / filename
# supabase INSERT
sb = _supabase()
file_id = str(uuid.uuid4())
res = sb.table("pack_files").insert({
"id": file_id,
"min_tier": tier,
"label": label,
"file_path": str(host_path),
"filename": filename,
"size_bytes": written,
}).execute()
if not res.data:
target.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail="DB INSERT 실패")
return UploadResponse(
file_id=file_id,
file_path=str(host_path),
filename=filename,
size_bytes=written,
min_tier=tier,
label=label,
uploaded_at=res.data[0]["uploaded_at"],
)
@router.get("/list", response_model=list[PackFileItem])
async def list_files(
request: Request,
x_timestamp: str = Header(""),
x_signature: str = Header(""),
):
body = await request.body()
verify_request_hmac(body, x_timestamp, x_signature)
sb = _supabase()
res = (
sb.table("pack_files")
.select("*")
.is_("deleted_at", "null")
.order("min_tier")
.order("sort_order")
.execute()
)
return [PackFileItem(**r) for r in (res.data or [])]
@router.delete("/{file_id}")
async def delete_file(
file_id: str,
request: Request,
x_timestamp: str = Header(""),
x_signature: str = Header(""),
):
body = await request.body()
verify_request_hmac(body, x_timestamp, x_signature)
sb = _supabase()
res = sb.table("pack_files").update({
"deleted_at": datetime.now(timezone.utc).isoformat(),
}).eq("id", file_id).execute()
if not res.data:
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
return {"ok": True}