docs(spec/plan): packs-lab spec/plan 복구 + PACK_HOST_DIR/평면구조/SERVICES 화이트리스트 반영
dc92c3d에서 "완료된 spec/plan 제거"로 함께 정리됐던 두 파일을 복구하고,
이후 적용된 운영 변경사항을 반영해 문서-구현 추적성 회복:
- PACK_HOST_DIR 환경변수 도입 (NAS 호스트 절대경로, DSM·Supabase에 노출)
- 평면 저장 구조 (PACK_BASE_DIR/{filename}, tier 디렉토리 분기 제거 — tier는 filename 규칙으로)
- scripts/deploy-nas.sh의 SERVICES 화이트리스트에 packs-lab 추가 (누락 시 NAS 컨테이너 미등장)
- .env.example 환경변수 6+3 path (DSM 3 / HMAC / Supabase 2 / TTL / DATA_PATH / BASE_DIR / HOST_DIR)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
977
docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md
Normal file
977
docs/superpowers/plans/2026-05-05-packs-lab-infra-integration.md
Normal file
@@ -0,0 +1,977 @@
|
|||||||
|
# packs-lab 인프라 통합 + admin mint-token Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** packs-lab을 운영 가능 상태로 만든다 — admin upload 토큰 발급 endpoint + Supabase 스키마 + docker-compose/nginx/env 통합 + 통합 테스트 + 문서 갱신.
|
||||||
|
|
||||||
|
**Architecture:** 기존 코드(HMAC + DSM client + 4 라우트)는 그대로 유지하고, 신규 라우트 1개(`POST /api/packs/admin/mint-token`)를 routes.py에 추가한다. Supabase `pack_files` DDL 파일과 인프라(docker-compose 18950, nginx 5GB streaming, .env.example 6+1 환경변수)를 신설하고, 통합 테스트(routes + dsm_client mock)와 CLAUDE.md 5+1곳을 갱신한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.12 / FastAPI / pytest + unittest.mock / Supabase(PostgreSQL) / Synology DSM 7.x API / nginx / Docker Compose
|
||||||
|
|
||||||
|
**스펙 참조:** `docs/superpowers/specs/2026-05-05-packs-lab-infra-integration-design.md`
|
||||||
|
|
||||||
|
**작업 디렉토리:** `C:\Users\jaeoh\Desktop\workspace\web-backend` (기존 web-backend repo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 테스트 인프라 — `tests/conftest.py`
|
||||||
|
|
||||||
|
기존 `tests/test_auth.py`는 `BACKEND_HMAC_SECRET=secret` 같은 fixture가 없어 환경변수 의존. 모든 테스트가 동일한 secret으로 동작하도록 autouse fixture를 conftest에 정리.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packs-lab/tests/conftest.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: conftest.py 생성**
|
||||||
|
|
||||||
|
`packs-lab/tests/conftest.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""packs-lab 테스트 공통 fixture."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _hmac_secret(monkeypatch):
|
||||||
|
"""모든 테스트에서 동일한 HMAC secret 사용. auth._SECRET 모듈 캐시까지 갱신."""
|
||||||
|
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
|
||||||
|
# auth.py 모듈은 import 시점에 _SECRET을 캐시하므로 monkeypatch로 함께 갱신
|
||||||
|
from app import auth
|
||||||
|
monkeypatch.setattr(auth, "_SECRET", "test-secret-do-not-use-in-prod")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 기존 test_auth.py 회귀 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab
|
||||||
|
python -m pytest tests/test_auth.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 기존 테스트 모두 PASS (conftest 영향 없거나 PASS 그대로 유지). 만약 secret 인코딩 차이로 실패 시 해당 테스트의 secret 사용 부분을 conftest 값과 일치시킨다.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/tests/conftest.py
|
||||||
|
git commit -m "test(packs-lab): conftest로 HMAC secret 통일"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: admin mint-token 라우트 (스키마 + 구현 + 테스트)
|
||||||
|
|
||||||
|
`POST /api/packs/admin/mint-token` 신규. Pydantic 스키마 추가 + 라우트 구현 + 통합 테스트.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packs-lab/app/models.py` (스키마 2개 추가)
|
||||||
|
- Modify: `packs-lab/app/routes.py` (import 보강 + 라우트 추가)
|
||||||
|
- Create: `packs-lab/tests/test_routes.py` (mint-token 관련 테스트만 우선)
|
||||||
|
|
||||||
|
- [ ] **Step 1: failing 테스트 작성**
|
||||||
|
|
||||||
|
`packs-lab/tests/test_routes.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""packs-lab 라우트 통합 테스트.
|
||||||
|
|
||||||
|
DSM·Supabase는 mock. HMAC 검증·토큰 발급·검증은 실제 코드 사용.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
SECRET = "test-secret-do-not-use-in-prod"
|
||||||
|
|
||||||
|
|
||||||
|
def _hmac_headers(body_bytes: bytes) -> dict:
|
||||||
|
"""body에 대한 X-Timestamp + X-Signature 헤더 생성."""
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
sig = hmac.new(SECRET.encode(), ts.encode() + b"." + body_bytes, hashlib.sha256).hexdigest()
|
||||||
|
return {"X-Timestamp": ts, "X-Signature": sig}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mint_token_hmac_required():
|
||||||
|
"""HMAC 헤더 누락 → 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
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}
|
||||||
|
body_bytes = json.dumps(body).encode()
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
|
||||||
|
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}
|
||||||
|
body_bytes = json.dumps(body).encode()
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/test_routes.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 FAIL — `/api/packs/admin/mint-token` 라우트 없음 (404 또는 405).
|
||||||
|
|
||||||
|
- [ ] **Step 3: models.py에 스키마 추가**
|
||||||
|
|
||||||
|
`packs-lab/app/models.py` 끝부분에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: routes.py에 mint-token 라우트 추가**
|
||||||
|
|
||||||
|
`packs-lab/app/routes.py` 상단 import 블록에 다음을 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time
|
||||||
|
from datetime import timezone
|
||||||
|
```
|
||||||
|
|
||||||
|
(이미 `import uuid`, `from datetime import datetime`은 있음)
|
||||||
|
|
||||||
|
`from .auth import` 라인을 다음과 같이 확장:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .auth import mint_upload_token, verify_request_hmac, verify_upload_token
|
||||||
|
```
|
||||||
|
|
||||||
|
`from .models import` 라인을 다음과 같이 확장:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .models import (
|
||||||
|
MintTokenRequest,
|
||||||
|
MintTokenResponse,
|
||||||
|
PackFileItem,
|
||||||
|
SignLinkRequest,
|
||||||
|
SignLinkResponse,
|
||||||
|
UploadResponse,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
상수 추가 (`MAX_BYTES` 다음 줄에):
|
||||||
|
|
||||||
|
```python
|
||||||
|
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
|
||||||
|
```
|
||||||
|
|
||||||
|
라우트 추가 (`sign_link` 함수 다음, `upload` 함수 앞):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 테스트 통과 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/test_routes.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 3 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 6: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/app/models.py packs-lab/app/routes.py packs-lab/tests/test_routes.py
|
||||||
|
git commit -m "feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: 기존 4 라우트 통합 테스트 (sign-link / upload / list / delete)
|
||||||
|
|
||||||
|
기존 라우트는 변경 없음. 테스트만 추가해 회귀 안전망 확보.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packs-lab/tests/test_routes.py` (테스트 8개 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: sign-link 테스트 추가**
|
||||||
|
|
||||||
|
`tests/test_routes.py` 끝에 추가:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_sign_link_hmac_required():
|
||||||
|
"""HMAC 헤더 없으면 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/x.zip"}
|
||||||
|
resp = client.post("/api/packs/sign-link", json=body)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_link_outside_base_dir():
|
||||||
|
"""PACK_BASE_DIR 외부 경로 → 400."""
|
||||||
|
body = {"file_path": "/etc/passwd"}
|
||||||
|
body_bytes = json.dumps(body).encode()
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_link_calls_dsm():
|
||||||
|
"""DSM client 호출되고 응답 URL 반환."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
body = {"file_path": "/volume1/docker/webpage/media/packs/pro/sample.zip"}
|
||||||
|
body_bytes = json.dumps(body).encode()
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
fake_url = "https://gahusb.synology.me:5001/sharing/abc123"
|
||||||
|
fake_expires = datetime(2026, 5, 5, 13, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
with patch("app.routes.create_share_link", new=AsyncMock(return_value=(fake_url, fake_expires))) as mock:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/packs/sign-link", content=body_bytes, headers=headers)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["url"] == fake_url
|
||||||
|
mock.assert_awaited_once()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: upload 테스트 추가**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _make_upload_token(tier="pro", label="샘플", filename="test.zip", size_bytes=1024, jti=None, ttl=1800):
|
||||||
|
"""테스트용 upload token 생성. mint_token endpoint 거치지 않고 직접."""
|
||||||
|
import uuid
|
||||||
|
from app.auth import mint_upload_token
|
||||||
|
return mint_upload_token({
|
||||||
|
"tier": tier,
|
||||||
|
"label": label,
|
||||||
|
"filename": filename,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
"jti": jti or str(uuid.uuid4()),
|
||||||
|
"expires_at": int(time.time()) + ttl,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_token_required():
|
||||||
|
"""Authorization Bearer 누락 → 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/packs/upload", files={"file": ("x.zip", b"hello")})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_size_mismatch(tmp_path, monkeypatch):
|
||||||
|
"""토큰 size_bytes ≠ 실제 → 400 + 파일 정리됨."""
|
||||||
|
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||||
|
token = _make_upload_token(size_bytes=999) # 실제 5바이트지만 토큰엔 999
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/packs/upload",
|
||||||
|
files={"file": ("test.zip", b"hello")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "크기" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_jti_replay(tmp_path, monkeypatch):
|
||||||
|
"""같은 jti 토큰 두 번 → 두 번째 409."""
|
||||||
|
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
|
||||||
|
|
||||||
|
fake_supabase = MagicMock()
|
||||||
|
fake_supabase.table.return_value.insert.return_value.execute.return_value = MagicMock(
|
||||||
|
data=[{"uploaded_at": "2026-05-05T12:00:00+00:00"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
token = _make_upload_token(filename="replay.zip", size_bytes=5, jti="replay-jti-1")
|
||||||
|
|
||||||
|
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# 1차: 성공
|
||||||
|
resp1 = client.post(
|
||||||
|
"/api/packs/upload",
|
||||||
|
files={"file": ("replay.zip", b"hello")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200
|
||||||
|
|
||||||
|
# 2차: 동일 토큰 재사용 — 두 번째 파일은 다른 이름으로 보내 파일명 충돌 회피
|
||||||
|
resp2 = client.post(
|
||||||
|
"/api/packs/upload",
|
||||||
|
files={"file": ("replay.zip", b"world")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 409
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: list / delete 테스트 추가**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_list_returns_active_only():
|
||||||
|
"""mock supabase가 deleted_at IS NULL 행만 반환하는지 (쿼리 빌더 호출 검증)."""
|
||||||
|
fake_rows = [
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"min_tier": "pro",
|
||||||
|
"label": "샘플",
|
||||||
|
"file_path": "/volume1/docker/webpage/media/packs/pro/a.zip",
|
||||||
|
"filename": "a.zip",
|
||||||
|
"size_bytes": 1024,
|
||||||
|
"sort_order": 0,
|
||||||
|
"uploaded_at": "2026-05-05T12:00:00+00:00",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
fake_supabase = MagicMock()
|
||||||
|
chain = fake_supabase.table.return_value.select.return_value
|
||||||
|
chain.is_.return_value.order.return_value.order.return_value.execute.return_value = MagicMock(data=fake_rows)
|
||||||
|
|
||||||
|
body_bytes = b""
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
|
||||||
|
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/packs/list", headers=headers)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
items = resp.json()
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["filename"] == "a.zip"
|
||||||
|
fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_soft_deletes():
|
||||||
|
"""DELETE 시 supabase update에 deleted_at ISO timestamp가 들어가야 한다."""
|
||||||
|
fake_supabase = MagicMock()
|
||||||
|
fake_supabase.table.return_value.update.return_value.eq.return_value.execute.return_value = MagicMock(
|
||||||
|
data=[{"id": "abc"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
body_bytes = b""
|
||||||
|
headers = _hmac_headers(body_bytes)
|
||||||
|
|
||||||
|
with patch("app.routes._supabase", return_value=fake_supabase):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.delete("/api/packs/abc", headers=headers)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
update_call = fake_supabase.table.return_value.update.call_args
|
||||||
|
update_kwargs = update_call.args[0]
|
||||||
|
assert "deleted_at" in update_kwargs
|
||||||
|
# ISO 8601 timestamp 형식 검증 (예: 2026-05-05T12:00:00+00:00)
|
||||||
|
assert "T" in update_kwargs["deleted_at"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 테스트 실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/test_routes.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 11 passed (3 from Task 2 + 3 sign-link + 3 upload + 2 list/delete).
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/tests/test_routes.py
|
||||||
|
git commit -m "test(packs-lab): 기존 4 라우트 통합 테스트 (sign-link, upload, list, delete)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `tests/test_dsm_client.py` — DSM client mock 테스트
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packs-lab/tests/test_dsm_client.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: DSM client 테스트 작성**
|
||||||
|
|
||||||
|
`packs-lab/tests/test_dsm_client.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""DSM 7.x API client 테스트 — httpx mock으로 외부 호출 차단."""
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.dsm_client import create_share_link, DSMError, _login, _logout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _dsm_env(monkeypatch):
|
||||||
|
monkeypatch.setenv("DSM_HOST", "https://test-nas:5001")
|
||||||
|
monkeypatch.setenv("DSM_USER", "test-user")
|
||||||
|
monkeypatch.setenv("DSM_PASS", "test-pass")
|
||||||
|
# 모듈 캐시도 갱신
|
||||||
|
from app import dsm_client
|
||||||
|
monkeypatch.setattr(dsm_client, "DSM_HOST", "https://test-nas:5001")
|
||||||
|
monkeypatch.setattr(dsm_client, "DSM_USER", "test-user")
|
||||||
|
monkeypatch.setattr(dsm_client, "DSM_PASS", "test-pass")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_response(json_data, status_code=200):
|
||||||
|
"""httpx.Response mock."""
|
||||||
|
mock = MagicMock(spec=httpx.Response)
|
||||||
|
mock.json.return_value = json_data
|
||||||
|
mock.status_code = status_code
|
||||||
|
mock.raise_for_status = MagicMock()
|
||||||
|
return mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_share_link_login_logout():
|
||||||
|
"""login → Sharing.create → logout 순서가 보장되어야 한다."""
|
||||||
|
call_order = []
|
||||||
|
|
||||||
|
async def fake_get(self, url, *, params=None, **kw):
|
||||||
|
api = (params or {}).get("api", "")
|
||||||
|
method = (params or {}).get("method", "")
|
||||||
|
call_order.append(f"{api}.{method}")
|
||||||
|
if api == "SYNO.API.Auth" and method == "login":
|
||||||
|
return _make_response({"success": True, "data": {"sid": "fake-sid"}})
|
||||||
|
if api == "SYNO.API.Auth" and method == "logout":
|
||||||
|
return _make_response({"success": True})
|
||||||
|
if api == "SYNO.FileStation.Sharing" and method == "create":
|
||||||
|
return _make_response({
|
||||||
|
"success": True,
|
||||||
|
"data": {"links": [{"url": "https://test-nas:5001/sharing/abc"}]},
|
||||||
|
})
|
||||||
|
return _make_response({"success": False, "error": "unexpected"})
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||||
|
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=3600))
|
||||||
|
|
||||||
|
assert url == "https://test-nas:5001/sharing/abc"
|
||||||
|
assert call_order == [
|
||||||
|
"SYNO.API.Auth.login",
|
||||||
|
"SYNO.FileStation.Sharing.create",
|
||||||
|
"SYNO.API.Auth.logout",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_share_link_returns_url_and_expiry():
|
||||||
|
"""응답 파싱 — links[0].url 사용."""
|
||||||
|
async def fake_get(self, url, *, params=None, **kw):
|
||||||
|
method = (params or {}).get("method", "")
|
||||||
|
if method == "login":
|
||||||
|
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||||||
|
if method == "create":
|
||||||
|
return _make_response({
|
||||||
|
"success": True,
|
||||||
|
"data": {"links": [{"url": "https://nas/sharing/xyz"}]},
|
||||||
|
})
|
||||||
|
return _make_response({"success": True})
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||||
|
url, expires_at = asyncio.run(create_share_link("/volume1/test/file.zip", expires_in_sec=7200))
|
||||||
|
|
||||||
|
assert url == "https://nas/sharing/xyz"
|
||||||
|
assert expires_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsm_login_failure_raises():
|
||||||
|
"""login API success=False → DSMError."""
|
||||||
|
async def fake_get(self, url, *, params=None, **kw):
|
||||||
|
return _make_response({"success": False, "error": {"code": 400}})
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||||
|
with pytest.raises(DSMError, match="login 실패"):
|
||||||
|
asyncio.run(create_share_link("/volume1/test/file.zip"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsm_share_failure_logs_out():
|
||||||
|
"""Sharing.create 실패해도 logout 호출 (try/finally)."""
|
||||||
|
call_order = []
|
||||||
|
|
||||||
|
async def fake_get(self, url, *, params=None, **kw):
|
||||||
|
method = (params or {}).get("method", "")
|
||||||
|
call_order.append(method)
|
||||||
|
if method == "login":
|
||||||
|
return _make_response({"success": True, "data": {"sid": "sid"}})
|
||||||
|
if method == "create":
|
||||||
|
return _make_response({"success": False, "error": {"code": 401}})
|
||||||
|
if method == "logout":
|
||||||
|
return _make_response({"success": True})
|
||||||
|
return _make_response({"success": False})
|
||||||
|
|
||||||
|
with patch.object(httpx.AsyncClient, "get", new=fake_get):
|
||||||
|
with pytest.raises(DSMError, match="Sharing.create 실패"):
|
||||||
|
asyncio.run(create_share_link("/volume1/test/file.zip"))
|
||||||
|
|
||||||
|
assert "login" in call_order
|
||||||
|
assert "logout" in call_order, "logout이 호출되지 않음 (finally 누락 의심)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 테스트 실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/test_dsm_client.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 4 passed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/tests/test_dsm_client.py
|
||||||
|
git commit -m "test(packs-lab): DSM client mock 테스트 (login/share/logout 순서)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: DELETE 라우트 docstring 수정
|
||||||
|
|
||||||
|
`routes.py` 모듈 docstring의 한 줄 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packs-lab/app/routes.py:1-7` (모듈 docstring)
|
||||||
|
|
||||||
|
- [ ] **Step 1: docstring 수정**
|
||||||
|
|
||||||
|
`packs-lab/app/routes.py` 첫 docstring을 다음으로 변경:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""packs-lab API 엔드포인트.
|
||||||
|
|
||||||
|
- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
|
||||||
|
- POST /api/packs/admin/mint-token — Vercel HMAC 인증 → 일회성 upload 토큰
|
||||||
|
- 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 공유는 자동 만료)
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
(변경: `정리` → `자동 만료`, mint-token 줄 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 회귀 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 그대로 통과 (15 passed).
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/app/routes.py
|
||||||
|
git commit -m "docs(packs-lab): routes 모듈 docstring 정리 (mint-token 추가, DSM 자동 만료 명시)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Supabase `pack_files` DDL
|
||||||
|
|
||||||
|
운영 적용 시 Supabase SQL editor에서 실행할 SQL 파일.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packs-lab/supabase/pack_files.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: SQL 파일 생성**
|
||||||
|
|
||||||
|
`packs-lab/supabase/pack_files.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
|
||||||
|
-- 운영 적용: Supabase Dashboard → SQL editor에서 실행
|
||||||
|
create table if not exists public.pack_files (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
min_tier text not null check (min_tier in ('starter','pro','master')),
|
||||||
|
label text not null,
|
||||||
|
file_path text not null unique,
|
||||||
|
filename text not null,
|
||||||
|
size_bytes bigint not null check (size_bytes > 0),
|
||||||
|
sort_order integer not null default 0,
|
||||||
|
uploaded_at timestamptz not null default now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- list 라우트 hot path: deleted_at IS NULL + tier/order 정렬
|
||||||
|
create index if not exists pack_files_active_idx
|
||||||
|
on public.pack_files (min_tier, sort_order)
|
||||||
|
where deleted_at is null;
|
||||||
|
|
||||||
|
-- soft-deleted 통계 / cleanup 잡 대비
|
||||||
|
create index if not exists pack_files_deleted_at_idx
|
||||||
|
on public.pack_files (deleted_at)
|
||||||
|
where deleted_at is not null;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packs-lab/supabase/pack_files.sql
|
||||||
|
git commit -m "feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱스"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: 인프라 통합 — docker-compose / nginx / .env.example / deploy-nas.sh
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docker-compose.yml` (packs-lab 서비스 추가, env에 PACK_BASE_DIR/PACK_HOST_DIR 포함)
|
||||||
|
- Modify: `nginx/default.conf` (`/api/packs/` 라우팅)
|
||||||
|
- Modify: `.env.example` (DSM/HMAC/Supabase 6 + PACK 3 path)
|
||||||
|
- Modify: `scripts/deploy-nas.sh` (SERVICES 화이트리스트에 `packs-lab` 추가 — 누락 시 NAS 컨테이너 미등장)
|
||||||
|
|
||||||
|
- [ ] **Step 1: docker-compose.yml — packs-lab 서비스 추가**
|
||||||
|
|
||||||
|
`docker-compose.yml`에서 다른 lab 서비스(예: `realestate-lab`) 정의 다음에 추가:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
packs-lab:
|
||||||
|
build:
|
||||||
|
context: ./packs-lab
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: packs-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18950:8000"
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
DSM_HOST: ${DSM_HOST}
|
||||||
|
DSM_USER: ${DSM_USER}
|
||||||
|
DSM_PASS: ${DSM_PASS}
|
||||||
|
BACKEND_HMAC_SECRET: ${BACKEND_HMAC_SECRET}
|
||||||
|
SUPABASE_URL: ${SUPABASE_URL}
|
||||||
|
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
|
||||||
|
UPLOAD_TOKEN_TTL_SEC: ${UPLOAD_TOKEN_TTL_SEC:-1800}
|
||||||
|
volumes:
|
||||||
|
- ${PACK_DATA_PATH:-./data/packs}:/volume1/docker/webpage/media/packs
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: nginx/default.conf — /api/packs/ 라우팅**
|
||||||
|
|
||||||
|
기존 `location /api/agent-office/ { ... }` 다음(또는 다른 `/api/...` 라우트들 근처)에 추가:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/packs/ {
|
||||||
|
proxy_pass http://packs-lab:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# 5GB 멀티파트 업로드 대응
|
||||||
|
client_max_body_size 5G;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 1800s;
|
||||||
|
proxy_send_timeout 1800s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: .env.example — 6+1 환경변수 추가**
|
||||||
|
|
||||||
|
`.env.example` 끝에 추가:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
|
||||||
|
# Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||||
|
DSM_HOST=https://gahusb.synology.me:5001
|
||||||
|
DSM_USER=
|
||||||
|
DSM_PASS=
|
||||||
|
|
||||||
|
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
|
||||||
|
BACKEND_HMAC_SECRET=
|
||||||
|
|
||||||
|
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
|
||||||
|
SUPABASE_URL=https://<project>.supabase.co
|
||||||
|
SUPABASE_SERVICE_KEY=
|
||||||
|
|
||||||
|
# admin upload 토큰 TTL (초). default 1800 = 30분
|
||||||
|
UPLOAD_TOKEN_TTL_SEC=1800
|
||||||
|
|
||||||
|
# 로컬 개발: ./data/packs / NAS 운영: /volume1/docker/webpage/media/packs
|
||||||
|
PACK_DATA_PATH=./data/packs
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: docker compose config 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||||||
|
docker compose config 2>&1 | grep -A 10 "packs-lab:"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: packs-lab 서비스 정의가 정상 출력 (port mapping, environment 변수, volumes 모두 보임). 환경변수가 비어있어도 docker compose config는 통과.
|
||||||
|
|
||||||
|
> ⚠️ Docker가 로컬에 설치되어 있어야 검증 가능. 실제 실행은 NAS에서. 로컬 docker가 없으면 step skip하고 nginx config 문법만 별도 검증.
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker-compose.yml nginx/default.conf .env.example
|
||||||
|
git commit -m "chore(infra): packs-lab 서비스 통합 (compose 18950 + nginx 5GB streaming + env 7개)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: NAS 디렉토리 준비 가이드 + 문서 갱신
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web-backend/CLAUDE.md` (5곳 갱신)
|
||||||
|
- Modify: `workspace/CLAUDE.md` (1줄 추가)
|
||||||
|
|
||||||
|
- [ ] **Step 1: web-backend/CLAUDE.md — 1.프로젝트 개요**
|
||||||
|
|
||||||
|
찾을 위치 (1.프로젝트 개요 섹션):
|
||||||
|
|
||||||
|
```
|
||||||
|
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
|
||||||
|
```
|
||||||
|
|
||||||
|
다음으로 수정:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||||
|
```
|
||||||
|
|
||||||
|
같은 섹션의 인프라 줄도:
|
||||||
|
|
||||||
|
```
|
||||||
|
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: web-backend/CLAUDE.md — 4.Docker 서비스 표**
|
||||||
|
|
||||||
|
표 마지막에 신규 행 추가 (deployer 행 직전 또는 personal 행 다음 — 알파벳 순):
|
||||||
|
|
||||||
|
```
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: web-backend/CLAUDE.md — 5.Nginx 라우팅 표**
|
||||||
|
|
||||||
|
표 적절한 위치에 신규 행 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: web-backend/CLAUDE.md — 8.로컬 개발 표**
|
||||||
|
|
||||||
|
표 끝에 신규 행 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
| Packs Lab | http://localhost:18950 |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: web-backend/CLAUDE.md — 9.서비스별 packs-lab 신규 섹션**
|
||||||
|
|
||||||
|
`### deployer (deployer/)` 섹션 직전에 추가 (또는 personal 다음):
|
||||||
|
|
||||||
|
```
|
||||||
|
### packs-lab (packs-lab/)
|
||||||
|
- NAS 자료 다운로드 자동화 — Synology DSM 공유링크 발급 + 5GB 멀티파트 업로드 수신
|
||||||
|
- Vercel SaaS와 HMAC 인증으로 통신, 사용자 인증은 Vercel이 Supabase로 처리 (본 서비스는 외부 인증 없음)
|
||||||
|
- DB: 외부 Supabase `pack_files` 테이블 (DDL: `packs-lab/supabase/pack_files.sql`)
|
||||||
|
- 파일 구조: `app/main.py`, `app/auth.py`, `app/dsm_client.py`, `app/routes.py`, `app/models.py`
|
||||||
|
- 운영 디렉토리: `/volume1/docker/webpage/media/packs/{starter,pro,master}/` (NAS PUID:PGID 권한 필요)
|
||||||
|
|
||||||
|
**환경변수**
|
||||||
|
- `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||||
|
- `BACKEND_HMAC_SECRET`: Vercel SaaS와 양쪽 공유 시크릿 (HMAC SHA256)
|
||||||
|
- `SUPABASE_URL` / `SUPABASE_SERVICE_KEY`: Supabase pack_files 테이블 접근 (service_role, RLS 우회)
|
||||||
|
- `UPLOAD_TOKEN_TTL_SEC`: admin upload 토큰 TTL (기본 1800초 = 30분)
|
||||||
|
- `PACK_DATA_PATH`: 호스트 마운트 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`)
|
||||||
|
|
||||||
|
**HMAC 인증 패턴**
|
||||||
|
- Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret))
|
||||||
|
- Replay 방어: 타임스탬프 ±5분 윈도우
|
||||||
|
- admin browser → backend upload: `Authorization: Bearer <token>` (jti 단발성)
|
||||||
|
|
||||||
|
**packs-lab API 목록**
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/packs/sign-link` | Vercel HMAC → DSM Sharing.create로 4시간 유효 다운로드 URL 발급 |
|
||||||
|
| POST | `/api/packs/admin/mint-token` | Vercel HMAC → 일회성 upload 토큰 발급 (기본 30분 TTL) |
|
||||||
|
| POST | `/api/packs/upload` | Bearer token → multipart 5GB 저장 + Supabase INSERT |
|
||||||
|
| GET | `/api/packs/list` | Vercel HMAC → 활성 pack_files 목록 (deleted_at IS NULL) |
|
||||||
|
| DELETE | `/api/packs/{file_id}` | Vercel HMAC → soft delete (DSM 공유는 자동 만료) |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: workspace/CLAUDE.md — 컨테이너 표 한 줄 추가**
|
||||||
|
|
||||||
|
`workspace/CLAUDE.md`의 "Docker 서비스 & 포트" 표에 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
|
||||||
|
```
|
||||||
|
|
||||||
|
(personal 행 다음 또는 적절한 위치)
|
||||||
|
|
||||||
|
- [ ] **Step 7: 커밋 (web-backend repo의 CLAUDE.md만)**
|
||||||
|
|
||||||
|
작업 디렉토리는 `C:\Users\jaeoh\Desktop\workspace\web-backend`. 그 안의 `CLAUDE.md`만 git 추적 대상.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md
|
||||||
|
git commit -m "docs(claude): packs-lab 10번째 서비스로 등록 (포트/라우팅/API 표 + 신규 섹션)"
|
||||||
|
```
|
||||||
|
|
||||||
|
> ℹ️ `workspace/CLAUDE.md`(상위 디렉토리의 워크스페이스 메모)는 git repo가 아님. 텍스트 편집만 하고 commit 대상에서 제외.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 회귀 검증 + NAS 디렉토리 가이드
|
||||||
|
|
||||||
|
전체 테스트 + docker compose config + NAS 배포 전 가이드.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- (검증만)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 전체 pytest**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packs-lab
|
||||||
|
python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 모든 테스트 통과 (test_auth + test_routes + test_dsm_client = 약 15+ tests).
|
||||||
|
|
||||||
|
- [ ] **Step 2: docker compose config 검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:\Users\jaeoh\Desktop\workspace\web-backend
|
||||||
|
docker compose config 2>&1 | tail -30
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: error 없이 packs-lab 포함된 전체 config 출력.
|
||||||
|
|
||||||
|
> ⚠️ Docker 미설치 시 skip. NAS에서 git push 후 webhook 배포 시점에 검증됨.
|
||||||
|
|
||||||
|
- [ ] **Step 3: NAS 배포 전 가이드 출력**
|
||||||
|
|
||||||
|
배포 전 NAS에서 SSH로 1회 실행할 명령들을 README 또는 NAS 배포 노트로 정리. 본 task에서는 명령만 제시 (실행은 사용자):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NAS SSH로 접속 후
|
||||||
|
mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
|
||||||
|
chown -R PUID:PGID /volume1/docker/webpage/media/packs # PUID/PGID는 .env 값 사용
|
||||||
|
|
||||||
|
# .env에 신규 환경변수 추가 (DSM_*, BACKEND_HMAC_SECRET, SUPABASE_*, UPLOAD_TOKEN_TTL_SEC, PACK_DATA_PATH=/volume1/docker/webpage/media/packs)
|
||||||
|
|
||||||
|
# Supabase에서 packs-lab/supabase/pack_files.sql 실행
|
||||||
|
|
||||||
|
# git push 후 webhook이 자동 배포
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 최종 commit (검증 결과 빈 commit으로 마일스톤 표시 — 선택)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 만약 위 step에서 어떤 자동 수정이 있었으면 commit. 없으면 skip.
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
회귀 검증으로 변경 사항 없으면 별도 commit 없이 종료.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 기준
|
||||||
|
|
||||||
|
- 모든 task의 step 통과 (체크박스 모두 체크)
|
||||||
|
- `cd packs-lab && python -m pytest tests/ -v` — 통과 (test_auth + test_routes + test_dsm_client)
|
||||||
|
- `docker compose config` — packs-lab 포함된 전체 config 정상
|
||||||
|
- web-backend/CLAUDE.md 5곳 갱신 + workspace/CLAUDE.md 1줄
|
||||||
|
- Supabase DDL 파일 존재 (운영 적용은 사용자가 NAS에서 SQL editor로)
|
||||||
|
- NAS 디렉토리 준비 명령은 사용자가 SSH로 실행 (배포 전 1회)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 배포
|
||||||
|
|
||||||
|
git push → Gitea webhook → deployer rsync → docker compose up -d --build (자동).
|
||||||
|
|
||||||
|
**배포 전 사용자 액션 (1회)**:
|
||||||
|
1. Supabase에서 `pack_files` 테이블 생성 (DDL 실행)
|
||||||
|
2. NAS SSH로 `/volume1/docker/webpage/media/packs/{starter,pro,master}` 디렉토리 생성 + 권한
|
||||||
|
3. NAS `.env`에 신규 7개 환경변수 입력 (DSM 인증, HMAC secret, Supabase 키 등)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 — 후속 별도 plan (스코프 외)
|
||||||
|
|
||||||
|
- Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase user 테이블
|
||||||
|
- DSM 공유 추적 (즉시 차단 필요 시)
|
||||||
|
- deleted_at + N일 후 실제 파일 삭제 cron
|
||||||
|
- multi-admin 토큰 발급 권한 분리
|
||||||
|
- resumable multipart 업로드 (5GB tus 등)
|
||||||
|
- pack_files sort_order 편집 endpoint
|
||||||
|
- 모니터링 (업로드 실패율, DSM API latency)
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
# packs-lab 인프라 통합 + admin mint-token 설계
|
||||||
|
|
||||||
|
> 대상: `web-backend/packs-lab/`
|
||||||
|
> 외부 의존: Supabase(`pack_files` 테이블) + Vercel SaaS(HMAC 호출자)
|
||||||
|
> 후속 별도 스펙: Vercel-side admin UI / 사용자 다운로드 / cleanup cron / multi-admin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 목표
|
||||||
|
|
||||||
|
`packs-lab`은 NAS 자료 다운로드 자동화 백엔드. Synology DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신을 담당하고, Vercel SaaS와 HMAC으로 통신한다. 사용자 인증은 Vercel이 Supabase로 처리하고 본 서비스는 외부 인증을 다루지 않는다.
|
||||||
|
|
||||||
|
이미 코드(HMAC 미들웨어 / DSM client / 4 라우트)는 작성되어 있으나 인프라 통합 + Supabase 스키마 + admin upload 토큰 발급 흐름이 빠져 있어 운영 가능 상태가 아니다. 본 스펙은 그 갭을 메운다.
|
||||||
|
|
||||||
|
### 핵심 변경
|
||||||
|
|
||||||
|
- **신규 라우트**: `POST /api/packs/admin/mint-token` (Vercel HMAC → 일회성 업로드 토큰)
|
||||||
|
- **Supabase DDL**: `pack_files` 테이블 + 활성·삭제 인덱스
|
||||||
|
- **인프라**: docker-compose `packs-lab` 서비스 등록(18950) + nginx `/api/packs/` 5GB 통과 + `.env.example` 6+1 환경변수
|
||||||
|
- **테스트**: routes 통합 + DSM client mock
|
||||||
|
- **문서**: web-backend / workspace CLAUDE.md 5곳 갱신
|
||||||
|
- **DELETE 라우트 docstring**: "DSM 공유 정리" 표현을 "DSM 공유 자동 만료"로 수정 (실제 동작과 일치)
|
||||||
|
|
||||||
|
### 변경하지 않는 것
|
||||||
|
|
||||||
|
- 기존 `auth.py` (`mint_upload_token` 그대로 활용)
|
||||||
|
- 기존 `dsm_client.py`
|
||||||
|
- 기존 `routes.py`의 sign-link / upload / list / delete 본문
|
||||||
|
- DSM 공유 추적 테이블 — 4시간 자동 만료로 충분(브레인스토밍 결정)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 컴포넌트 + 통신 흐름
|
||||||
|
|
||||||
|
### 2.1 변경 받는 파일
|
||||||
|
|
||||||
|
| 영역 | 파일 | 변경 |
|
||||||
|
|------|------|------|
|
||||||
|
| 백엔드 | `packs-lab/app/routes.py` | DELETE docstring 수정 + admin mint-token 라우트 추가 |
|
||||||
|
| 백엔드 | `packs-lab/app/models.py` | `MintTokenRequest`, `MintTokenResponse` 스키마 추가 |
|
||||||
|
| 백엔드 | `packs-lab/app/auth.py` | 변경 없음 (기존 `mint_upload_token` 활용) |
|
||||||
|
| 테스트 | `packs-lab/tests/conftest.py` (신규) | autouse `BACKEND_HMAC_SECRET` 셋팅 |
|
||||||
|
| 테스트 | `packs-lab/tests/test_routes.py` (신규) | 5 라우트 통합 테스트 |
|
||||||
|
| 테스트 | `packs-lab/tests/test_dsm_client.py` (신규) | DSM 7.x API mock 테스트 |
|
||||||
|
| DB | `packs-lab/supabase/pack_files.sql` (신규) | DDL + 인덱스 |
|
||||||
|
| 인프라 | `docker-compose.yml` | `packs-lab` 서비스 추가 |
|
||||||
|
| 인프라 | `nginx/default.conf` | `/api/packs/` 라우팅 (`client_max_body_size 5G` + streaming) |
|
||||||
|
| 인프라 | `.env.example` | 6+1 신규 환경변수 |
|
||||||
|
| 문서 | `web-backend/CLAUDE.md` | 1·4·5·8·9 섹션 갱신 |
|
||||||
|
| 문서 | `workspace/CLAUDE.md` | 컨테이너 표 한 줄 추가 |
|
||||||
|
|
||||||
|
### 2.2 통신 흐름
|
||||||
|
|
||||||
|
**ADMIN 업로드**
|
||||||
|
|
||||||
|
```
|
||||||
|
Vercel admin UI ─────→ Vercel API (HMAC 헤더 추가)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/packs/admin/mint-token
|
||||||
|
│
|
||||||
|
backend: verify_request_hmac
|
||||||
|
│
|
||||||
|
mint_upload_token({tier, label, filename, size_bytes, jti, expires_at})
|
||||||
|
│
|
||||||
|
Vercel ←─────────────── token ──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
admin browser → POST /api/packs/upload
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
multipart body (≤5GB)
|
||||||
|
│
|
||||||
|
backend: verify_upload_token + JTI mark
|
||||||
|
│
|
||||||
|
파일 저장 (PACK_BASE_DIR/{filename}, 평면 구조 — tier는 filename 규칙으로 구분)
|
||||||
|
│
|
||||||
|
Supabase INSERT pack_files
|
||||||
|
```
|
||||||
|
|
||||||
|
**사용자 다운로드**
|
||||||
|
|
||||||
|
```
|
||||||
|
사용자 → Vercel SaaS (Supabase auth + tier·결제 검증)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/packs/sign-link (HMAC + file_path)
|
||||||
|
│
|
||||||
|
backend: verify_request_hmac
|
||||||
|
│
|
||||||
|
DSM Sharing.create (4시간 만료)
|
||||||
|
│
|
||||||
|
사용자 ← Vercel ← 다운로드 URL (4시간 유효)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 기각된 대안
|
||||||
|
|
||||||
|
| 대안 | 기각 사유 |
|
||||||
|
|------|-----------|
|
||||||
|
| Vercel-side 토큰 발급 | 토큰 포맷 양쪽 분산, 변경 시 동기화 부담 |
|
||||||
|
| admin browser → backend 직접 HMAC | admin browser에 secret 노출, 보안 약화 |
|
||||||
|
| DSM 공유 추적 테이블 | 4시간 자동 만료로 충분, YAGNI |
|
||||||
|
| Resumable multipart upload | 5GB는 단일 stream으로 충분, 복잡도 증가 |
|
||||||
|
| `pack_files.min_tier`를 PostgreSQL ENUM | tier 추가 시 ALTER TYPE 번거로움. text+CHECK 채택 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `POST /api/packs/admin/mint-token`
|
||||||
|
|
||||||
|
### 3.1 Pydantic 스키마 (`models.py` 추가)
|
||||||
|
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 라우트 본문 (`routes.py` 추가)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time, uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .auth import mint_upload_token, verify_request_hmac
|
||||||
|
from .models import MintTokenRequest, MintTokenResponse
|
||||||
|
|
||||||
|
UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default
|
||||||
|
|
||||||
|
@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) # upload 라우트와 동일 검증
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 결정 근거
|
||||||
|
|
||||||
|
| 항목 | 값 | 근거 |
|
||||||
|
|------|-----|------|
|
||||||
|
| TTL default | 1800s (30분) | 5GB 업로드 시작 + 진행 시간 여유. 1Gbps에서 약 40s, 50Mbps에서 약 14분 |
|
||||||
|
| TTL env override | `UPLOAD_TOKEN_TTL_SEC` | 운영 중 조정 가능 |
|
||||||
|
| filename 검증 | upload와 동일 (`_check_filename`) | 토큰 발급 시점에 미리 거부 → admin UI 즉시 피드백 |
|
||||||
|
| jti 응답 포함 | yes | admin이 업로드 결과 추적용 |
|
||||||
|
| Vercel ↔ backend | HMAC (`X-Timestamp` + `X-Signature`) | 다른 admin 라우트와 동일 패턴 |
|
||||||
|
| admin browser ↔ backend | Bearer token (단발성 jti) | 기존 upload 라우트 그대로 |
|
||||||
|
|
||||||
|
### 3.4 DELETE 라우트 docstring 수정
|
||||||
|
|
||||||
|
`routes.py` 모듈 docstring에서:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete + DSM 공유 정리
|
||||||
|
+ DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
|
||||||
|
```
|
||||||
|
|
||||||
|
`delete_file` 함수에는 변경 없음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Supabase `pack_files` DDL
|
||||||
|
|
||||||
|
**파일**: `packs-lab/supabase/pack_files.sql` (신규, 운영 배포 시 Supabase SQL editor에서 실행)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- pack_files: NAS에 저장된 다운로드 가능한 패키지 파일 메타
|
||||||
|
create table if not exists public.pack_files (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
min_tier text not null check (min_tier in ('starter','pro','master')),
|
||||||
|
label text not null,
|
||||||
|
file_path text not null unique, -- NAS 절대경로, 동일 경로 중복 방지
|
||||||
|
filename text not null,
|
||||||
|
size_bytes bigint not null check (size_bytes > 0),
|
||||||
|
sort_order integer not null default 0,
|
||||||
|
uploaded_at timestamptz not null default now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- list 라우트의 hot path: deleted_at IS NULL + tier/order 정렬
|
||||||
|
create index if not exists pack_files_active_idx
|
||||||
|
on public.pack_files (min_tier, sort_order)
|
||||||
|
where deleted_at is null;
|
||||||
|
|
||||||
|
-- soft-deleted 통계 / cleanup 잡 대비
|
||||||
|
create index if not exists pack_files_deleted_at_idx
|
||||||
|
on public.pack_files (deleted_at)
|
||||||
|
where deleted_at is not null;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 필드 결정 근거
|
||||||
|
|
||||||
|
| 필드 | 타입 / 제약 | 근거 |
|
||||||
|
|------|------------|------|
|
||||||
|
| `id` | uuid PK + `gen_random_uuid()` default | routes.py가 client-side `uuid.uuid4()` 생성하지만 default도 둬 fallback |
|
||||||
|
| `min_tier` | text + CHECK | enum 대신 text+CHECK가 PostgreSQL에서 더 유연 |
|
||||||
|
| `file_path` | text NOT NULL UNIQUE | 같은 tier/filename 충돌은 파일시스템에서 잡지만 DB 레벨도 보강 |
|
||||||
|
| `size_bytes` | bigint + CHECK > 0 | 5GB는 int 범위 안이지만 미래 대비 bigint |
|
||||||
|
| `sort_order` | int NOT NULL default 0 | routes INSERT가 sort_order 미지정 → 0 기본 |
|
||||||
|
| `uploaded_at` | timestamptz default now() | routes 코드가 `res.data[0]["uploaded_at"]` 그대로 응답에 사용 — DB가 채워줌 |
|
||||||
|
| `deleted_at` | nullable | soft delete |
|
||||||
|
|
||||||
|
### 4.2 RLS
|
||||||
|
|
||||||
|
비활성. backend가 `service_role` key 사용하므로 RLS 우회. Vercel/사용자 직접 접근 없음 → unsafe 아님.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 인프라 통합
|
||||||
|
|
||||||
|
### 5.1 `docker-compose.yml` — `packs-lab` 서비스
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
packs-lab:
|
||||||
|
build:
|
||||||
|
context: ./packs-lab
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: packs-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18950:8000"
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
DSM_HOST: ${DSM_HOST}
|
||||||
|
DSM_USER: ${DSM_USER}
|
||||||
|
DSM_PASS: ${DSM_PASS}
|
||||||
|
BACKEND_HMAC_SECRET: ${BACKEND_HMAC_SECRET}
|
||||||
|
SUPABASE_URL: ${SUPABASE_URL}
|
||||||
|
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
|
||||||
|
UPLOAD_TOKEN_TTL_SEC: ${UPLOAD_TOKEN_TTL_SEC:-1800}
|
||||||
|
PACK_BASE_DIR: ${PACK_BASE_DIR:-/app/data/packs}
|
||||||
|
PACK_HOST_DIR: ${PACK_HOST_DIR:-${PACK_DATA_PATH:-./data/packs}}
|
||||||
|
volumes:
|
||||||
|
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 결정 | 값 | 근거 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 포트 | 18950 | 18800(realestate) → 18900(agent-office) → 18950(packs) 순차 |
|
||||||
|
| `PACK_BASE_DIR` (컨테이너 내부) | `/app/data/packs` | routes.py upload target. docker-compose volume 우측. |
|
||||||
|
| `PACK_HOST_DIR` (NAS 호스트) | 운영 `/volume1/docker/webpage/media/packs` / 로컬 fallback `./data/packs` | DSM·Supabase에 노출되는 절대경로. routes.py가 file_path로 저장. 미설정 시 `PACK_BASE_DIR`로 fallback. |
|
||||||
|
| `PACK_DATA_PATH` (호스트 마운트) | default `./data/packs` (로컬), NAS `/volume1/docker/webpage/media/packs` | docker-compose volume 좌측만 사용 |
|
||||||
|
|
||||||
|
### 5.2 `nginx/default.conf` — `/api/packs/` 라우팅
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /api/packs/ {
|
||||||
|
proxy_pass http://packs-lab:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# 5GB 멀티파트 업로드 대응
|
||||||
|
client_max_body_size 5G;
|
||||||
|
proxy_request_buffering off; # 스트리밍 통과 (메모리/디스크 buffer 회피)
|
||||||
|
proxy_read_timeout 1800s;
|
||||||
|
proxy_send_timeout 1800s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 결정 | 근거 |
|
||||||
|
|------|------|
|
||||||
|
| `client_max_body_size 5G` | 라우트 단위 — 다른 location은 default 유지 |
|
||||||
|
| `proxy_request_buffering off` | 5GB 파일을 nginx가 모두 받고 backend에 forward하면 ~5GB 디스크 buffer 발생 |
|
||||||
|
| `proxy_read/send_timeout 1800s` | 30분 — 업로드 토큰 TTL과 일치, 느린 업링크에서 5GB 전송 여유 |
|
||||||
|
|
||||||
|
### 5.3 `.env.example` — 신규 환경변수 (6 + 3 path)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
|
||||||
|
# Synology DSM 7.x 인증 (공유 링크 발급용)
|
||||||
|
DSM_HOST=https://gahusb.synology.me:5001
|
||||||
|
DSM_USER=
|
||||||
|
DSM_PASS=
|
||||||
|
|
||||||
|
# Vercel SaaS ↔ backend HMAC 시크릿 (양쪽 동일 값)
|
||||||
|
BACKEND_HMAC_SECRET=
|
||||||
|
|
||||||
|
# Supabase pack_files 테이블 접근 (service_role 키, RLS 우회)
|
||||||
|
SUPABASE_URL=https://<project>.supabase.co
|
||||||
|
SUPABASE_SERVICE_KEY=
|
||||||
|
|
||||||
|
# admin upload 토큰 TTL (초). default 1800 = 30분
|
||||||
|
UPLOAD_TOKEN_TTL_SEC=1800
|
||||||
|
|
||||||
|
# 호스트 마운트 경로 (로컬 ./data/packs, NAS /volume1/docker/webpage/media/packs)
|
||||||
|
PACK_DATA_PATH=./data/packs
|
||||||
|
|
||||||
|
# 컨테이너 내부 저장 경로 (routes.py upload target. docker-compose volume 우측)
|
||||||
|
PACK_BASE_DIR=/app/data/packs
|
||||||
|
|
||||||
|
# DSM·Supabase에 노출되는 NAS 호스트 절대경로. 운영에서 반드시 설정 — 미설정 시 sign-link 시 DSM에 컨테이너 경로 전달돼 파일 못 찾음.
|
||||||
|
PACK_HOST_DIR=/volume1/docker/webpage/media/packs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 NAS 디렉토리 준비
|
||||||
|
|
||||||
|
운영 첫 배포 시 SSH로 1회. 파일은 `PACK_HOST_DIR` 평면에 직접 저장 — tier 디렉토리 분기는 만들지 않음(tier 구분은 filename 규칙으로 admin이 관리):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /volume1/docker/webpage/media/packs
|
||||||
|
chown -R PUID:PGID /volume1/docker/webpage/media/packs
|
||||||
|
```
|
||||||
|
|
||||||
|
PUID/PGID는 `.env`의 기존 값 사용.
|
||||||
|
|
||||||
|
### 5.5 `scripts/deploy-nas.sh` SERVICES 화이트리스트
|
||||||
|
|
||||||
|
webhook 자동 배포(deployer)가 호출하는 sync 스크립트는 화이트리스트로 동기화 대상 디렉토리를 명시한다. 신규 서비스 추가 시 반드시 함께 수정해야 NAS 운영 디렉토리에 소스 sync + docker compose 빌드가 동작한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SERVICES="lotto travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office personal packs-lab nginx scripts"
|
||||||
|
```
|
||||||
|
|
||||||
|
(packs-lab 누락 시 `docker compose ps`에 packs-lab 미등장 — 첫 배포 시 가장 흔한 누락 항목)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 전략
|
||||||
|
|
||||||
|
기존 `tests/test_auth.py` 유지. 신규 3 파일.
|
||||||
|
|
||||||
|
### 6.1 `tests/conftest.py` (신규)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _hmac_secret(monkeypatch):
|
||||||
|
"""모든 테스트에서 동일한 HMAC secret 사용."""
|
||||||
|
monkeypatch.setenv("BACKEND_HMAC_SECRET", "test-secret-do-not-use-in-prod")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 `tests/test_routes.py` (신규) — 통합 테스트
|
||||||
|
|
||||||
|
DSM·Supabase 모두 mock. `pytest`, `monkeypatch`, `unittest.mock`, `fastapi.testclient.TestClient` 사용.
|
||||||
|
|
||||||
|
| 테스트 | 검증 |
|
||||||
|
|--------|------|
|
||||||
|
| `test_sign_link_hmac_required` | timestamp/signature 헤더 누락 → 401 |
|
||||||
|
| `test_sign_link_outside_base_dir` | file_path가 `PACK_BASE_DIR` 외부 → 400 |
|
||||||
|
| `test_sign_link_calls_dsm` | mock된 `create_share_link` 호출 검증, URL 응답 |
|
||||||
|
| `test_mint_token_hmac_required` | HMAC 누락 → 401 |
|
||||||
|
| `test_mint_token_returns_valid_token` | 발급된 token이 `verify_upload_token`으로 통과 |
|
||||||
|
| `test_mint_token_invalid_filename` | 확장자 미허용 → 400 |
|
||||||
|
| `test_upload_token_required` | Authorization Bearer 누락 → 401 |
|
||||||
|
| `test_upload_size_mismatch` | 토큰 size_bytes ≠ 실제 → 400 |
|
||||||
|
| `test_upload_jti_replay` | 같은 토큰 두 번 → 두 번째 409 |
|
||||||
|
| `test_list_returns_active_only` | mock supabase 응답에서 deleted_at NULL만 반환 |
|
||||||
|
| `test_delete_soft_deletes` | mock supabase update에 deleted_at ISO timestamp 들어감 |
|
||||||
|
|
||||||
|
### 6.3 `tests/test_dsm_client.py` (신규)
|
||||||
|
|
||||||
|
httpx mock(`respx` 또는 `MockTransport`) 또는 `monkeypatch.setattr` 패치.
|
||||||
|
|
||||||
|
| 테스트 | 검증 |
|
||||||
|
|--------|------|
|
||||||
|
| `test_create_share_link_login_logout` | login → Sharing.create → logout 순서 |
|
||||||
|
| `test_create_share_link_returns_url_and_expiry` | 응답 파싱 |
|
||||||
|
| `test_dsm_login_failure_raises` | login API success=false → DSMError |
|
||||||
|
| `test_dsm_share_failure_logs_out` | Sharing.create 실패해도 logout 호출 (try/finally) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 문서 갱신
|
||||||
|
|
||||||
|
### 7.1 `web-backend/CLAUDE.md` — 5곳
|
||||||
|
|
||||||
|
**1. 1.프로젝트 개요**
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
|
||||||
|
+ 서비스: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, packs-lab, deployer (10개)
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 4.Docker 서비스 표** — 신규 행
|
||||||
|
|
||||||
|
```
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 5.Nginx 라우팅 표** — 신규 행
|
||||||
|
|
||||||
|
```
|
||||||
|
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 (`client_max_body_size 5G` + `proxy_request_buffering off`) |
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 8.로컬 개발 표** — 신규 행
|
||||||
|
|
||||||
|
```
|
||||||
|
| Packs Lab | http://localhost:18950 |
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. 9.서비스별** — `### packs-lab (packs-lab/)` 신규 섹션
|
||||||
|
|
||||||
|
내용:
|
||||||
|
- 용도 (NAS DSM 공유링크 + 5GB 업로드 + Vercel HMAC, 사용자 인증은 Vercel이 Supabase로 처리)
|
||||||
|
- 환경변수 6+1개
|
||||||
|
- DB는 외부 Supabase `pack_files` (DDL은 `packs-lab/supabase/pack_files.sql`)
|
||||||
|
- 파일 구조: `main.py`, `auth.py`, `dsm_client.py`, `routes.py`, `models.py`
|
||||||
|
- API 표 5개:
|
||||||
|
- `POST /api/packs/sign-link` (Vercel HMAC → DSM Sharing.create)
|
||||||
|
- `POST /api/packs/admin/mint-token` (Vercel HMAC → upload 토큰)
|
||||||
|
- `POST /api/packs/upload` (Bearer token → multipart 5GB)
|
||||||
|
- `GET /api/packs/list` (Vercel HMAC → 활성 파일 목록)
|
||||||
|
- `DELETE /api/packs/{file_id}` (Vercel HMAC → soft delete)
|
||||||
|
|
||||||
|
### 7.2 `workspace/CLAUDE.md`
|
||||||
|
|
||||||
|
컨테이너 표에 한 줄 추가:
|
||||||
|
|
||||||
|
```
|
||||||
|
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (Vercel SaaS와 HMAC 통신) |
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 스코프
|
||||||
|
|
||||||
|
### 본 spec 범위
|
||||||
|
|
||||||
|
- ✅ admin mint-token 라우트 신설
|
||||||
|
- ✅ Supabase `pack_files` DDL
|
||||||
|
- ✅ docker-compose / nginx / .env.example / NAS 디렉토리 마운트
|
||||||
|
- ✅ tests (auth 유지 + routes 통합 + dsm_client mock)
|
||||||
|
- ✅ CLAUDE.md 2곳 갱신
|
||||||
|
- ✅ DELETE 라우트 docstring 수정
|
||||||
|
|
||||||
|
### 후속 별도 spec
|
||||||
|
|
||||||
|
- ❌ Vercel SaaS-side admin UI / 사용자 다운로드 UI / Supabase pricing & user 테이블
|
||||||
|
- ❌ DSM 공유 추적 (즉시 차단 필요시)
|
||||||
|
- ❌ deleted_at + N일 후 실제 파일 삭제 cron
|
||||||
|
- ❌ multi-admin 토큰 발급 권한 분리
|
||||||
|
- ❌ resumable multipart 업로드 (5GB tus 등)
|
||||||
|
- ❌ pack_files sort_order 편집 endpoint (admin UI 단계)
|
||||||
|
- ❌ monitoring (업로드 실패율, DSM API latency)
|
||||||
Reference in New Issue
Block a user