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
This commit is contained in:
18
packs-lab/Dockerfile
Normal file
18
packs-lab/Dockerfile
Normal 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"]
|
||||||
1
packs-lab/app/__init__.py
Normal file
1
packs-lab/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# packs-lab package
|
||||||
84
packs-lab/app/auth.py
Normal file
84
packs-lab/app/auth.py
Normal 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
102
packs-lab/app/dsm_client.py
Normal 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)
|
||||||
6
packs-lab/app/requirements.txt
Normal file
6
packs-lab/app/requirements.txt
Normal 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
|
||||||
3
packs-lab/pytest.ini
Normal file
3
packs-lab/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
pythonpath = .
|
||||||
1
packs-lab/tests/__init__.py
Normal file
1
packs-lab/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# packs-lab tests
|
||||||
85
packs-lab/tests/test_auth.py
Normal file
85
packs-lab/tests/test_auth.py
Normal 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)
|
||||||
Reference in New Issue
Block a user