diff --git a/packs-lab/app/main.py b/packs-lab/app/main.py new file mode 100644 index 0000000..2d1621b --- /dev/null +++ b/packs-lab/app/main.py @@ -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) diff --git a/packs-lab/app/models.py b/packs-lab/app/models.py new file mode 100644 index 0000000..0a7a186 --- /dev/null +++ b/packs-lab/app/models.py @@ -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 diff --git a/packs-lab/app/routes.py b/packs-lab/app/routes.py new file mode 100644 index 0000000..c7e2c73 --- /dev/null +++ b/packs-lab/app/routes.py @@ -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}