diff --git a/packs-lab/app/models.py b/packs-lab/app/models.py index 0a7a186..c0fd9b8 100644 --- a/packs-lab/app/models.py +++ b/packs-lab/app/models.py @@ -37,3 +37,17 @@ class PackFileItem(BaseModel): size_bytes: int sort_order: int uploaded_at: datetime + + +class MintTokenRequest(BaseModel): + """Vercel → backend: admin upload 토큰 발급 요청.""" + tier: PackTier + label: str = Field(..., max_length=200) + filename: str = Field(..., max_length=255) + size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024) + + +class MintTokenResponse(BaseModel): + token: str + expires_at: datetime + jti: str diff --git a/packs-lab/app/routes.py b/packs-lab/app/routes.py index c7e2c73..57b4cbc 100644 --- a/packs-lab/app/routes.py +++ b/packs-lab/app/routes.py @@ -8,6 +8,7 @@ import logging import os import re +import time import uuid from datetime import datetime, timezone from pathlib import Path @@ -15,9 +16,11 @@ 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 .auth import mint_upload_token, verify_request_hmac, verify_upload_token from .dsm_client import DSMError, create_share_link from .models import ( + MintTokenRequest, + MintTokenResponse, PackFileItem, SignLinkRequest, SignLinkResponse, @@ -31,6 +34,7 @@ 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]+$") +UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default def _supabase() -> Client: @@ -76,6 +80,34 @@ async def sign_link( return SignLinkResponse(url=url, expires_at=expires_at) +@router.post("/admin/mint-token", response_model=MintTokenResponse) +async def mint_token( + request: Request, + x_timestamp: str = Header(""), + x_signature: str = Header(""), +): + body = await request.body() + verify_request_hmac(body, x_timestamp, x_signature) + payload = MintTokenRequest.model_validate_json(body) + _check_filename(payload.filename) + + jti = str(uuid.uuid4()) + expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC + token = mint_upload_token({ + "tier": payload.tier, + "label": payload.label, + "filename": payload.filename, + "size_bytes": payload.size_bytes, + "jti": jti, + "expires_at": expires_ts, + }) + return MintTokenResponse( + token=token, + expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc), + jti=jti, + ) + + @router.post("/upload", response_model=UploadResponse) async def upload( file: UploadFile = File(...), diff --git a/packs-lab/tests/test_routes.py b/packs-lab/tests/test_routes.py index f2d88fe..44e74e0 100644 --- a/packs-lab/tests/test_routes.py +++ b/packs-lab/tests/test_routes.py @@ -100,3 +100,39 @@ def test_list_success(mock_sb): r = client.get("/api/packs/list", headers=_signed(body)) assert r.status_code == 200 assert len(r.json()) == 1 + + +def test_mint_token_hmac_required(): + """HMAC 헤더 누락 → 401.""" + body = {"tier": "pro", "label": "샘플", "filename": "x.zip", "size_bytes": 1024} + resp = client.post("/api/packs/admin/mint-token", json=body) + assert resp.status_code == 401 + + +def test_mint_token_returns_valid_token(): + """발급된 token이 verify_upload_token으로 통과해야 한다.""" + from app.auth import verify_upload_token + + body = {"tier": "pro", "label": "샘플", "filename": "test.zip", "size_bytes": 2048} + import json as _json + body_bytes = _json.dumps(body).encode() + resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=_signed(body_bytes)) + assert resp.status_code == 200 + data = resp.json() + assert "token" in data and "expires_at" in data and "jti" in data + + payload = verify_upload_token(data["token"]) + assert payload["tier"] == "pro" + assert payload["label"] == "샘플" + assert payload["filename"] == "test.zip" + assert payload["size_bytes"] == 2048 + assert payload["jti"] == data["jti"] + + +def test_mint_token_invalid_filename(): + """허용 외 확장자 → 400.""" + body = {"tier": "pro", "label": "샘플", "filename": "x.exe", "size_bytes": 1024} + import json as _json + body_bytes = _json.dumps(body).encode() + resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=_signed(body_bytes)) + assert resp.status_code == 400