feat(packs-lab): 4 라우트 — sign-link, upload, list, delete (HMAC + supabase)

This commit is contained in:
2026-05-02 08:52:24 +09:00
parent 096e291ed8
commit eb547a0367
3 changed files with 254 additions and 0 deletions

36
packs-lab/app/main.py Normal file
View File

@@ -0,0 +1,36 @@
"""packs-lab FastAPI application.
NAS 자료 다운로드 자동화 — DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신.
모든 Vercel 호출은 HMAC 인증. 사용자 다운로드는 Vercel이 supabase 인증 후 프록시.
"""
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logger = logging.getLogger("packs-lab")
@asynccontextmanager
async def lifespan(app: FastAPI):
# DSM credentials presence check
for key in ("DSM_HOST", "DSM_USER", "DSM_PASS", "BACKEND_HMAC_SECRET"):
if not os.getenv(key):
logger.warning("환경변수 %s 미설정 — packs-lab 일부 기능 작동 안 함", key)
logger.info("packs-lab 시작")
yield
app = FastAPI(lifespan=lifespan, title="packs-lab", version="1.0.0")
@app.get("/health")
def health():
return {"status": "ok", "service": "packs-lab"}
from . import routes # noqa: E402
app.include_router(routes.router)

39
packs-lab/app/models.py Normal file
View File

@@ -0,0 +1,39 @@
"""Pydantic schemas for packs API."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
PackTier = Literal["starter", "pro", "master"]
class SignLinkRequest(BaseModel):
"""Vercel → backend: 사용자 다운로드 링크 발급 요청."""
file_path: str = Field(..., description="NAS 절대 경로 — pack_files.file_path 그대로")
expires_in_seconds: int = Field(default=14400, description="공유 링크 만료 (기본 4시간)")
class SignLinkResponse(BaseModel):
url: str
expires_at: datetime
class UploadResponse(BaseModel):
file_id: str # uuid
file_path: str
filename: str
size_bytes: int
min_tier: PackTier
label: str
uploaded_at: datetime
class PackFileItem(BaseModel):
id: str
min_tier: PackTier
label: str
file_path: str
filename: str
size_bytes: int
sort_order: int
uploaded_at: datetime

179
packs-lab/app/routes.py Normal file
View File

@@ -0,0 +1,179 @@
"""packs-lab API 엔드포인트.
- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
- 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 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 verify_request_hmac, verify_upload_token
from .dsm_client import DSMError, create_share_link
from .models import (
PackFileItem,
SignLinkRequest,
SignLinkResponse,
UploadResponse,
)
logger = logging.getLogger("packs-lab.routes")
router = APIRouter(prefix="/api/packs")
PACK_BASE_DIR = Path("/volume1/docker/webpage/media/packs")
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]+$")
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_BASE_DIR 하위인지 확인
abs_path = Path(payload.file_path).resolve()
if not str(abs_path).startswith(str(PACK_BASE_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("/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_dir = PACK_BASE_DIR / tier
tier_dir.mkdir(parents=True, exist_ok=True)
target = tier_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 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(target),
"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(target),
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}