2 Commits

Author SHA1 Message Date
7a7e3d1ce0 fix(packs-lab): 누락된 A1-A3 파일 복구 (Dockerfile + auth + DSM client + tests)
Tasks A1-A3 의 파일이 working tree에 있었으나 git에 stage되지 않은 상태에서
A4 commit이 진행되어 routes.py 의 import (auth, dsm_client)가 깨진 상태였음.
복구: 8 files 일괄 commit.

- packs-lab/Dockerfile
- packs-lab/app/__init__.py
- packs-lab/app/requirements.txt
- packs-lab/app/auth.py (HMAC verify_request_hmac + verify_upload_token)
- packs-lab/app/dsm_client.py (DSM 7.x Sharing.create wrapper)
- packs-lab/tests/{__init__.py, test_auth.py}
- packs-lab/pytest.ini
2026-05-02 08:53:26 +09:00
eb547a0367 feat(packs-lab): 4 라우트 — sign-link, upload, list, delete (HMAC + supabase) 2026-05-02 08:52:24 +09:00
11 changed files with 554 additions and 0 deletions

18
packs-lab/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
COPY app/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY app /app/app
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1 @@
# packs-lab package

84
packs-lab/app/auth.py Normal file
View File

@@ -0,0 +1,84 @@
"""HMAC 인증.
- verify_request_hmac: Vercel ↔ backend 요청 검증.
Vercel이 X-Timestamp + X-Signature 헤더로 보냄. signature = HMAC(timestamp.body, secret).
요청은 5분 이내여야 함 (replay 방어).
- mint_upload_token / verify_upload_token: admin 5GB 업로드 일회성 토큰.
Vercel이 발급, browser가 web-backend에 직접 multipart POST 시 Authorization: Bearer <token>.
JTI 단발성으로 재사용 차단.
"""
import base64
import hashlib
import hmac
import json
import os
import time
from threading import Lock
from fastapi import HTTPException
_SECRET = os.getenv("BACKEND_HMAC_SECRET", "")
REQUEST_MAX_AGE_SEC = 300 # 5분
# JTI 단발성 set (in-memory, 단일 컨테이너 가정)
_used_jti: set[str] = set()
_jti_lock = Lock()
def _sign(payload: bytes) -> str:
if not _SECRET:
raise HTTPException(status_code=503, detail="BACKEND_HMAC_SECRET 미설정")
return hmac.new(_SECRET.encode(), payload, hashlib.sha256).hexdigest()
def verify_request_hmac(body: bytes, timestamp: str, signature: str) -> None:
"""Vercel → backend 요청 시그니처 검증."""
if not timestamp or not signature:
raise HTTPException(status_code=401, detail="HMAC 헤더 누락")
try:
ts = int(timestamp)
except ValueError:
raise HTTPException(status_code=401, detail="잘못된 timestamp")
age = abs(int(time.time()) - ts)
if age > REQUEST_MAX_AGE_SEC:
raise HTTPException(status_code=401, detail="요청이 만료됨")
expected = _sign(timestamp.encode() + b"." + body)
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="HMAC 시그니처 불일치")
def mint_upload_token(payload: dict) -> str:
"""일회성 업로드 토큰 발급. payload는 expires_at + jti 포함해야 함."""
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
sig = _sign(body)
return base64.urlsafe_b64encode(body).decode() + "." + sig
def verify_upload_token(token: str) -> dict:
"""업로드 토큰 검증 + jti 사용 마킹."""
try:
b64, sig = token.split(".", 1)
body = base64.urlsafe_b64decode(b64.encode())
except Exception:
raise HTTPException(status_code=401, detail="잘못된 토큰 포맷")
expected = _sign(body)
if not hmac.compare_digest(expected, sig):
raise HTTPException(status_code=401, detail="토큰 시그니처 불일치")
payload = json.loads(body)
expires_at = payload.get("expires_at", 0)
if int(time.time()) > expires_at:
raise HTTPException(status_code=401, detail="토큰 만료")
jti = payload.get("jti")
if not jti:
raise HTTPException(status_code=401, detail="jti 누락")
with _jti_lock:
if jti in _used_jti:
raise HTTPException(status_code=409, detail="이미 사용된 토큰")
_used_jti.add(jti)
return payload

102
packs-lab/app/dsm_client.py Normal file
View File

@@ -0,0 +1,102 @@
"""Synology DSM 7.x API 클라이언트.
각 호출 = login → 작업 → logout (세션 풀링은 v1.1+에서). 단일 컨테이너 + 동시성 낮음 가정.
- create_share_link(file_path, expires_in_sec) -> share URL
"""
import logging
import os
from datetime import datetime, timedelta, timezone
import httpx
logger = logging.getLogger("packs-lab.dsm")
DSM_HOST = os.getenv("DSM_HOST", "") # 예: https://gahusb.synology.me:5001
DSM_USER = os.getenv("DSM_USER", "")
DSM_PASS = os.getenv("DSM_PASS", "")
API_AUTH = "/webapi/auth.cgi"
API_SHARE = "/webapi/entry.cgi"
class DSMError(RuntimeError):
pass
async def _login(client: httpx.AsyncClient) -> str:
"""DSM 세션 sid 반환."""
if not all([DSM_HOST, DSM_USER, DSM_PASS]):
raise DSMError("DSM 환경변수 미설정")
r = await client.get(
f"{DSM_HOST}{API_AUTH}",
params={
"api": "SYNO.API.Auth",
"version": "7",
"method": "login",
"account": DSM_USER,
"passwd": DSM_PASS,
"session": "FileStation",
"format": "sid",
},
timeout=15.0,
)
r.raise_for_status()
data = r.json()
if not data.get("success"):
raise DSMError(f"DSM login 실패: {data.get('error')}")
return data["data"]["sid"]
async def _logout(client: httpx.AsyncClient, sid: str) -> None:
try:
await client.get(
f"{DSM_HOST}{API_AUTH}",
params={
"api": "SYNO.API.Auth",
"version": "7",
"method": "logout",
"session": "FileStation",
"_sid": sid,
},
timeout=10.0,
)
except Exception as e:
logger.warning("DSM logout 실패: %s", e)
async def create_share_link(file_path: str, expires_in_sec: int = 14400) -> tuple[str, datetime]:
"""파일 공유 링크 생성. 반환: (URL, expires_at).
file_path: NAS 절대경로 (예: /volume1/docker/webpage/media/packs/master/x.mp4)
expires_in_sec: 만료 (기본 4시간)
"""
expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in_sec)
expire_time_ms = int(expires_at.timestamp() * 1000)
async with httpx.AsyncClient(verify=True) as client:
sid = await _login(client)
try:
r = await client.get(
f"{DSM_HOST}{API_SHARE}",
params={
"api": "SYNO.FileStation.Sharing",
"version": "3",
"method": "create",
"path": file_path,
"date_expired": expire_time_ms,
"_sid": sid,
},
timeout=15.0,
)
r.raise_for_status()
data = r.json()
if not data.get("success"):
raise DSMError(f"DSM Sharing.create 실패: {data.get('error')}")
links = data["data"]["links"]
if not links:
raise DSMError("Sharing 응답에 링크 없음")
url = links[0]["url"]
return url, expires_at
finally:
await _logout(client, sid)

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

View File

@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
httpx==0.28.1
python-multipart==0.0.20
supabase==2.12.0
pydantic==2.10.4

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}

3
packs-lab/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
pythonpath = .

View File

@@ -0,0 +1 @@
# packs-lab tests

View File

@@ -0,0 +1,85 @@
"""HMAC 인증 단위 테스트.
- verify_request_hmac: Vercel ↔ backend 요청 시그니처 검증
- verify_upload_token: 일회성 업로드 토큰 검증 (jti 단발성)
"""
import json
import os
import time
import uuid
import pytest
from fastapi import HTTPException
# 환경변수 먼저 세팅 (auth import 전)
os.environ["BACKEND_HMAC_SECRET"] = "test-secret-32-bytes-XXXXXXXXXXXX"
from app import auth # noqa: E402
def test_verify_request_hmac_valid():
body = b'{"file_path":"/x"}'
ts = str(int(time.time()))
sig = auth._sign(ts.encode() + b"." + body)
auth.verify_request_hmac(body, ts, sig) # 예외 X
def test_verify_request_hmac_expired():
body = b'{}'
old_ts = str(int(time.time()) - 600) # 10분 전
sig = auth._sign(old_ts.encode() + b"." + body)
with pytest.raises(HTTPException) as exc:
auth.verify_request_hmac(body, old_ts, sig)
assert exc.value.status_code == 401
def test_verify_request_hmac_wrong_sig():
body = b'{}'
ts = str(int(time.time()))
with pytest.raises(HTTPException):
auth.verify_request_hmac(body, ts, "deadbeef")
def test_upload_token_roundtrip():
payload = {
"tier": "master",
"label": "샘플 영상",
"filename": "sample.mp4",
"size_bytes": 1024,
"expires_at": int(time.time()) + 600,
"jti": uuid.uuid4().hex,
}
token = auth.mint_upload_token(payload)
decoded = auth.verify_upload_token(token)
assert decoded["filename"] == "sample.mp4"
assert decoded["jti"] == payload["jti"]
def test_upload_token_replay_rejected():
payload = {
"tier": "starter",
"label": "x",
"filename": "y.pdf",
"size_bytes": 1,
"expires_at": int(time.time()) + 600,
"jti": uuid.uuid4().hex,
}
token = auth.mint_upload_token(payload)
auth.verify_upload_token(token) # 첫 사용 OK
with pytest.raises(HTTPException) as exc:
auth.verify_upload_token(token) # 재사용
assert "이미" in exc.value.detail or "used" in exc.value.detail.lower()
def test_upload_token_expired():
payload = {
"tier": "starter",
"label": "x",
"filename": "y.pdf",
"size_bytes": 1,
"expires_at": int(time.time()) - 100,
"jti": uuid.uuid4().hex,
}
token = auth.mint_upload_token(payload)
with pytest.raises(HTTPException):
auth.verify_upload_token(token)