diff --git a/packs-lab/Dockerfile b/packs-lab/Dockerfile new file mode 100644 index 0000000..98f817e --- /dev/null +++ b/packs-lab/Dockerfile @@ -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"] diff --git a/packs-lab/app/__init__.py b/packs-lab/app/__init__.py new file mode 100644 index 0000000..f770a30 --- /dev/null +++ b/packs-lab/app/__init__.py @@ -0,0 +1 @@ +# packs-lab package diff --git a/packs-lab/app/auth.py b/packs-lab/app/auth.py new file mode 100644 index 0000000..ef07a71 --- /dev/null +++ b/packs-lab/app/auth.py @@ -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 . + 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 diff --git a/packs-lab/app/dsm_client.py b/packs-lab/app/dsm_client.py new file mode 100644 index 0000000..eef6763 --- /dev/null +++ b/packs-lab/app/dsm_client.py @@ -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) diff --git a/packs-lab/app/requirements.txt b/packs-lab/app/requirements.txt new file mode 100644 index 0000000..99df2fb --- /dev/null +++ b/packs-lab/app/requirements.txt @@ -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 diff --git a/packs-lab/pytest.ini b/packs-lab/pytest.ini new file mode 100644 index 0000000..4584de7 --- /dev/null +++ b/packs-lab/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/packs-lab/tests/__init__.py b/packs-lab/tests/__init__.py new file mode 100644 index 0000000..6e40fc4 --- /dev/null +++ b/packs-lab/tests/__init__.py @@ -0,0 +1 @@ +# packs-lab tests diff --git a/packs-lab/tests/test_auth.py b/packs-lab/tests/test_auth.py new file mode 100644 index 0000000..f82c71e --- /dev/null +++ b/packs-lab/tests/test_auth.py @@ -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)