15 Commits

Author SHA1 Message Date
24a57f2b69 docs: STATUS.md — 서비스 현황 + 향후 계획 정리
10개 컨테이너 운영 상태와 Phase별 향후 작업 인덱스. CLAUDE.md를 보완.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:57:28 +09:00
b9d3242341 Merge branch 'feat/packs-lab-infra'
packs-lab 인프라 통합 + admin mint-token 구현 — 9 task TDD

- conftest.py로 테스트 HMAC secret 통일
- POST /api/packs/admin/mint-token 라우트 (Vercel HMAC → 일회성 upload 토큰)
- 기존 4 라우트 회귀 테스트 + DSM client mock 테스트
- Supabase pack_files DDL + 활성/삭제 인덱스
- docker-compose 18950 + nginx /api/packs/ 5GB streaming + env 8개
- PACK_BASE_DIR 환경변수화 (마운트 경로와 정합성 확보)
- web-backend CLAUDE.md 5곳 + workspace CLAUDE.md 1줄 갱신
- 24 tests passing
2026-05-06 03:35:37 +09:00
5e9a51c9e8 docs(claude): packs-lab 10번째 서비스로 등록 (포트/라우팅/API 표 + 신규 섹션)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:42:15 +09:00
5844567048 fix(packs-lab): PACK_BASE_DIR을 환경변수로 — 컨테이너 마운트 경로와 routes 정합성 확보
이전 docker-compose는 컨테이너 내부 /app/data/packs로 마운트하지만 routes.py는
/volume1/docker/webpage/media/packs를 하드코딩하고 있어 mismatch였다.

- routes.py: PACK_BASE_DIR = Path(os.getenv("PACK_BASE_DIR", "/app/data/packs"))
- docker-compose: PACK_BASE_DIR env 추가 + volume 마운트가 같은 경로 사용
- .env.example: PACK_BASE_DIR 신규 명시 (마운트 경로와 반드시 일치 안내)
2026-05-06 01:40:07 +09:00
0906c3ba35 chore(infra): packs-lab 서비스 통합 (compose 18950 + nginx 5GB streaming + env 7개)
- docker-compose.yml: 포트 18910→18950 수정, env 형식을 list 스타일로 통일,
  TZ/UPLOAD_TOKEN_TTL_SEC 추가, volume 경로를 /app/data/packs으로 정정
- .env.example: packs-lab 섹션 신규 추가 (DSM_HOST/DSM_USER/DSM_PASS/
  BACKEND_HMAC_SECRET/SUPABASE_URL/SUPABASE_SERVICE_KEY/UPLOAD_TOKEN_TTL_SEC/PACK_DATA_PATH)
- nginx/default.conf: 이전 커밋(9a0bbec)에 이미 포함 — 변경 없음

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:37:29 +09:00
ff4ef299ad feat(packs-lab): Supabase pack_files DDL + 활성/삭제 인덱스 2026-05-06 01:35:19 +09:00
5ebcbae8b5 docs(packs-lab): routes 모듈 docstring 정리 (mint-token 추가, DSM 자동 만료 명시) 2026-05-06 01:34:47 +09:00
1cd3cf8830 test(packs-lab): DSM client mock 테스트 (login/share/logout 순서)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:33:11 +09:00
c18fd8e52b test(packs-lab): upload size/replay + delete soft-delete + list filter 회귀 테스트
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:30:46 +09:00
dc482b32e4 feat(packs-lab): POST /api/packs/admin/mint-token 라우트 + 통합 테스트
MintTokenRequest/Response 스키마 추가, mint_token 라우트 구현 (HMAC 인증 + 확장자 검증 + JTI 발급), 테스트 3건 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:27:43 +09:00
ef026e7ac6 test(packs-lab): conftest로 HMAC secret 통일
모든 테스트에서 BACKEND_HMAC_SECRET 환경변수와 auth._SECRET 모듈 캐시를
동일한 값으로 설정하는 autouse fixture 추가.
기존 test_auth.py / test_routes.py와 동일한 secret 값 사용.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 01:24:17 +09:00
80a54d056e docs(plan): packs-lab 인프라 통합 + admin mint-token 구현 계획
9 task TDD 분할:
- Task 1: tests/conftest.py — autouse HMAC secret
- Task 2: admin mint-token (스키마 + 라우트 + 통합 테스트 3건)
- Task 3: 기존 4 라우트 회귀 테스트 (sign-link/upload/list/delete, 8건)
- Task 4: test_dsm_client.py — DSM 7.x mock (4건)
- Task 5: routes 모듈 docstring 정리
- Task 6: Supabase pack_files DDL
- Task 7: 인프라 통합 (compose 18950 + nginx 5GB streaming + env 7개)
- Task 8: CLAUDE.md 5곳 + workspace 1줄
- Task 9: 회귀 검증 + NAS 디렉토리 가이드

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:42:41 +09:00
83192eb66c docs(spec): packs-lab 인프라 통합 + admin mint-token 설계
- POST /api/packs/admin/mint-token (Vercel HMAC → 일회성 upload 토큰)
- Supabase pack_files DDL + 활성/삭제 인덱스
- docker-compose 18950 + nginx 5GB streaming + .env.example 6+1 환경변수
- tests: routes 통합 + DSM client mock + autouse HMAC fixture
- CLAUDE.md: web-backend 5곳 + workspace 1곳 갱신
- DELETE 라우트 docstring 정리(자동 만료)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:51:24 +09:00
9a0bbeccd5 feat(packs-lab): docker-compose 서비스 + nginx 라우팅 (5GB body limit) 2026-05-02 08:57:36 +09:00
7a9690526a test(packs-lab): routes 통합 테스트 (DSM·supabase mock) 2026-05-02 08:55:26 +09:00
13 changed files with 2084 additions and 5 deletions

View File

@@ -93,3 +93,25 @@ REALESTATE_NOTIFY_TIMEOUT=15
PEXELS_API_KEY=
YOUTUBE_DATA_API_KEY=
# VIDEO_DATA_DIR=/app/data/videos # 기본값, 재정의 필요 시만 설정
# ─── 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
# 컨테이너 내부 PACK_BASE_DIR (routes.py가 파일 저장 시 사용. docker-compose volume의 컨테이너 측 경로와 반드시 일치)
PACK_BASE_DIR=/app/data/packs

View File

@@ -7,9 +7,9 @@
## 1. 프로젝트 개요
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
- **서비스**: 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개)
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
- **인프라**: Docker Compose (9컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
- **인프라**: Docker Compose (10컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
---
@@ -59,6 +59,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
| `packs-lab` | 18950 | NAS 자료 다운로드 자동화 (DSM 공유 링크 + 5GB 업로드, Vercel SaaS와 HMAC 통신) |
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
| `frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
@@ -82,6 +83,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
| `/api/blog/` | `personal:8000` | 블로그 API |
| `/api/profile/` | `personal:8000` | 포트폴리오 API |
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket |
| `/api/packs/` | `packs-lab:8000` | 5GB 업로드 대응 (`client_max_body_size 5G`, `proxy_request_buffering off`, 1800s timeout) |
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
| `/media/videos/` | `/data/videos/` (파일 직접 서빙) | YouTube 영상 MP4 |
@@ -135,6 +137,7 @@ docker compose up -d
| Stock Lab | http://localhost:18500 |
| Blog Lab | http://localhost:18700 |
| Realestate Lab | http://localhost:18800 |
| Packs Lab | http://localhost:18950 |
---
@@ -634,6 +637,36 @@ docker compose up -d
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
### 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`
- 컨테이너 저장 경로: `PACK_BASE_DIR` env (default `/app/data/packs`). docker-compose volume 마운트와 일치 필수.
**환경변수**
- `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_BASE_DIR`: 컨테이너 내부 저장 경로 (기본 `/app/data/packs`)
- `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 공유는 자동 만료) |
### deployer (deployer/)
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
- `WEBHOOK_SECRET` 환경변수로 시크릿 관리

109
STATUS.md Normal file
View File

@@ -0,0 +1,109 @@
# web-backend — 구현 현황 & 로드맵
> 최종 갱신: 2026-05-07
> 자세한 서비스·환경변수·DB 표는 [CLAUDE.md](./CLAUDE.md), 설계는 `docs/superpowers/specs/`, 실행 계획은 `docs/superpowers/plans/` 참조.
---
## 1. 서비스 구현 현황
### 1-1. 운영 중인 컨테이너 (10개)
| 서비스 | 포트 | 상태 | 핵심 기능 |
|--------|------|------|-----------|
| `lotto-backend` | 18000 | ✅ | 로또 추천·통계·리포트·구매내역 + 블로그·투두 |
| `stock-lab` | 18500 | ✅ | 주식 뉴스·지수·트레이딩·포트폴리오·자산 스냅샷 |
| `music-lab` | 18600 | ✅ | Suno + MusicGen + YouTube 수익화 + 컴파일 |
| `blog-lab` | 18700 | ✅ | 블로그 마케팅 수익화 파이프라인 |
| `realestate-lab` | 18800 | ✅ | 청약 수집·5티어 매칭·매칭 알림 |
| `agent-office` | 18900 | ✅ | AI 에이전트 (WebSocket + 텔레그램 + YouTubeResearcher) |
| `packs-lab` | 18950 | ✅ | NAS 자료 다운로드 자동화 (HMAC + Supabase) — 2026-05-05 |
| `travel-proxy` | 19000 | ✅ | 여행 사진 API + 썸네일 + 지역 관리 |
| `nginx` | 8080 | ✅ | SPA + 리버스 프록시 (5GB body limit) |
| `webpage-deployer` | 19010 | ✅ | Gitea Webhook 자동 배포 |
### 1-2. 최근 큰 작업 (2026-04 ~ 05)
| 시기 | 영역 | 핵심 |
|------|------|------|
| 2026-05-05 | packs-lab | sign-link / upload / list / delete + admin mint-token + 5GB nginx body limit + Supabase DDL |
| 2026-05-01~06 | music-lab | YouTube 수익화 백엔드 (market_trends·trend_reports DB + 5개 API) + 다중 트랙 FFmpeg concat MP4 |
| 2026-04-28 | realestate-lab | targeting enhancement (5티어 매칭·5축 점수·알림 대상 카운트) |
| 2026-04-27 | personal | personal 서비스 분리 마이그레이션 (블로그·투두·포트폴리오 인증) |
| 2026-04-27 | agent-office | v2 — youtube_researcher (YouTube API + pytrends + Billboard) + 알림 |
| 2026-04-24 | travel-proxy | 갤러리 리디자인 + 성능 개선 (썸네일/페이지네이션) |
| 2026-04-15 | lotto-backend | AI 큐레이터 (Claude 기반 주간 브리핑 자동 생성) |
| 2026-04-08 | music-lab | Suno enhancement + MusicGen 통합 |
| 2026-04-06 | blog-lab | 마케팅 파이프라인 (research → generate → market → review) |
### 1-3. 인프라 / DX
| 항목 | 상태 |
|------|------|
| docker-compose 통합 (10 서비스) | ✅ |
| Gitea Webhook → deployer rsync 자동 배포 | ✅ |
| nginx 라우팅 표 (/api/* 서비스별) | ✅ |
| 배포 환경변수 (PEXELS·YOUTUBE_DATA·VIDEO_DATA_DIR 등) | ✅ |
---
## 2. 진행 중 / 향후 계획
### 2-1. 로또 프리미엄 (Phase 3) — 구독 모델
> 출처: [docs/lotto-premium-roadmap.md](./docs/lotto-premium-roadmap.md)
- [ ] 회원 시스템 (JWT 인증, `users` 테이블)
- [ ] 구독 플랜 (`subscription_plans`, `user_subscriptions`)
- [ ] 결제 연동 (Toss Payments 또는 Stripe)
- [ ] 이메일 발송 자동화 (SendGrid)
- [ ] 소셜 증거 데이터 집계 API (가장 많이 선택된 번호 TOP 10 등)
Phase 1·2 (성과 통계 / 회차별 공략 리포트 / 개인 분석 / 구매 추적)는 이미 완료.
### 2-2. Pet Lab (신규 서비스) — 설계 단계
> 출처: `docs/superpowers/specs/2026-04-07-pet-lab-design.md`, `plans/2026-04-07-pet-lab.md`
- [ ] 컨테이너 추가 + 포트 배정
- [ ] 핵심 도메인 모델 (반려동물 등록·기록·일정)
- [ ] 프론트 페이지 신설
### 2-3. Music YouTube 자동화 후속
- [ ] VideoProjects 실제 렌더링 잡 큐 (현재 스켈레톤)
- [ ] 시장 트렌드 → 자동 음악 생성 트리거 연결
- [ ] Revenue 트래킹 정확도 개선 (YouTube Analytics API)
### 2-4. Travel 영상 지원
- [ ] `travel-proxy`에 영상 메타·썸네일 API 추가
- [ ] `/media/travel/.video-thumb/` 처리
- [ ] `/api/travel/videos` 엔드포인트
### 2-5. 청약 (realestate-lab) 후속
- [ ] 알림 dry-run API (사용자가 사전 시뮬레이션 가능)
- [ ] 신규 매칭 텔레그램 알림 노이즈 필터링 (이미 본 공고 제외)
- [ ] 백오피스용 공고 수동 보정 API
### 2-6. packs-lab 후속
- [ ] 사용자별 다운로드 쿼터 제어
- [ ] 만료된 토큰/링크 정리 스케줄러
- [ ] Vercel SaaS 측 UI 연결 검증
### 2-7. 인프라 일반
- [ ] APScheduler 잡 모니터링 대시보드 (현재 로그 의존)
- [ ] 백업 자동화 (lotto.db / stock.db / 사진 메타)
- [ ] OpenAPI 스펙 통합 (서비스별 자동 수집)
---
## 3. 참고 문서
- 서비스·포트·API 전체 표: [CLAUDE.md](./CLAUDE.md)
- 워크스페이스 통합 가이드: `../CLAUDE.md`
- 프론트엔드 상태: `../web-ui/STATUS.md`
- 설계 스펙: `docs/superpowers/specs/`
- 실행 계획: `docs/superpowers/plans/`
- 로또 프리미엄 로드맵: `docs/lotto-premium-roadmap.md`

View File

@@ -175,6 +175,31 @@ services:
timeout: 5s
retries: 3
packs-lab:
build:
context: ./packs-lab
container_name: packs-lab
restart: unless-stopped
ports:
- "18950:8000"
environment:
- TZ=${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}
volumes:
- ${PACK_DATA_PATH:-./data/packs}:${PACK_BASE_DIR:-/app/data/packs}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
travel-proxy:
build: ./travel-proxy
container_name: travel-proxy

View File

@@ -0,0 +1,976 @@
# 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
**Files:**
- Modify: `docker-compose.yml` (packs-lab 서비스 추가)
- Modify: `nginx/default.conf` (`/api/packs/` 라우팅)
- Modify: `.env.example` (6+1 환경변수)
- [ ] **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)

View File

@@ -0,0 +1,446 @@
# 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/{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}
volumes:
- ${PACK_DATA_PATH:-./data/packs}:/volume1/docker/webpage/media/packs
```
| 결정 | 값 | 근거 |
|------|-----|------|
| 포트 | 18950 | 18800(realestate) → 18900(agent-office) → 18950(packs) 순차 |
| `PACK_BASE_DIR` 마운트 | 컨테이너 경로 `/volume1/docker/webpage/media/packs` 고정 | routes.py 하드코딩 경로 |
| `PACK_DATA_PATH` env | default `./data/packs` (로컬), NAS `.env``/volume1/docker/webpage/media/packs` 명시 | 운영/로컬 분리 |
### 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+1 신규 환경변수
```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
```
### 5.4 NAS 디렉토리 준비
운영 첫 배포 시 SSH로 1회:
```bash
mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
chown -R PUID:PGID /volume1/docker/webpage/media/packs
```
PUID/PGID는 `.env`의 기존 값 사용.
---
## 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)

View File

@@ -190,6 +190,30 @@ server {
proxy_pass http://$personal_backend$request_uri;
}
# packs-lab — admin upload (5GB body limit, request buffering off)
location ^~ /api/packs/upload {
resolver 127.0.0.11 valid=10s;
set $packs_backend packs-lab:8000;
client_max_body_size 5G;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://$packs_backend$request_uri;
proxy_read_timeout 1800s;
proxy_send_timeout 1800s;
}
# packs-lab — 사용자 다운로드 + list/sign-link
location /api/packs/ {
resolver 127.0.0.11 valid=10s;
set $packs_backend packs-lab:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://$packs_backend$request_uri;
}
# agent-office API + WebSocket
location /api/agent-office/ {
resolver 127.0.0.11 valid=10s;

View File

@@ -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

View File

@@ -1,13 +1,15 @@
"""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 공유 정리
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료)
"""
import logging
import os
import re
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
@@ -15,9 +17,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,
@@ -27,10 +31,11 @@ from .models import (
logger = logging.getLogger("packs-lab.routes")
router = APIRouter(prefix="/api/packs")
PACK_BASE_DIR = Path("/volume1/docker/webpage/media/packs")
PACK_BASE_DIR = Path(os.getenv("PACK_BASE_DIR", "/app/data/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 +81,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(...),

View File

@@ -0,0 +1,23 @@
-- 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;

View File

@@ -0,0 +1,16 @@
"""packs-lab 테스트 공통 fixture."""
import pytest
@pytest.fixture(autouse=True)
def _hmac_secret(monkeypatch):
"""모든 테스트에서 동일한 HMAC secret 사용. auth._SECRET 모듈 캐시까지 갱신.
test_auth.py / test_routes.py 모두 모듈 레벨에서 동일한 값을 os.environ에
직접 세팅하므로 여기서도 같은 값을 사용해 충돌 없이 일관성을 보장한다.
"""
secret = "test-secret-32-bytes-XXXXXXXXXXXX"
monkeypatch.setenv("BACKEND_HMAC_SECRET", secret)
# auth.py 모듈은 import 시점에 _SECRET을 캐시하므로 monkeypatch로 함께 갱신
from app import auth
monkeypatch.setattr(auth, "_SECRET", secret)

View File

@@ -0,0 +1,111 @@
"""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
@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 누락 의심)"

View File

@@ -0,0 +1,247 @@
"""routes.py 통합 테스트 (DSM, supabase는 mock)."""
import os
import time
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
# 테스트용 환경변수 (auth import 전)
os.environ["BACKEND_HMAC_SECRET"] = "test-secret-32-bytes-XXXXXXXXXXXX"
os.environ["DSM_HOST"] = "https://test.synology.me:5001"
os.environ["DSM_USER"] = "test"
os.environ["DSM_PASS"] = "test"
os.environ["SUPABASE_URL"] = "https://placeholder.supabase.co"
os.environ["SUPABASE_SERVICE_KEY"] = "placeholder-key"
from app import auth # noqa: E402
from app.main import app # noqa: E402
client = TestClient(app)
def _signed(body: bytes) -> dict:
ts = str(int(time.time()))
sig = auth._sign(ts.encode() + b"." + body)
return {"X-Timestamp": ts, "X-Signature": sig, "Content-Type": "application/json"}
def test_health():
r = client.get("/health")
assert r.status_code == 200
assert r.json()["service"] == "packs-lab"
@patch("app.routes.create_share_link", new_callable=AsyncMock)
def test_sign_link_success(mock_share):
mock_share.return_value = ("https://test.synology.me:5001/d/s/abc", datetime.now(timezone.utc))
# Windows에서는 절대경로 resolve 결과가 C:\... 로 prefix되므로 PACK_BASE_DIR도 동일하게 패치
from pathlib import Path
abs_resolved = Path("/volume1/docker/webpage/media/packs/master/x.mp4").resolve()
base_resolved = Path(str(abs_resolved).rsplit("master", 1)[0].rstrip("\\/"))
with patch("app.routes.PACK_BASE_DIR", base_resolved):
body = b'{"file_path":"/volume1/docker/webpage/media/packs/master/x.mp4","expires_in_seconds":14400}'
r = client.post("/api/packs/sign-link", content=body, headers=_signed(body))
assert r.status_code == 200
assert "url" in r.json()
def test_sign_link_no_hmac():
r = client.post("/api/packs/sign-link", json={"file_path": "/x"})
assert r.status_code == 401
def test_sign_link_path_outside_base():
body = b'{"file_path":"/etc/passwd","expires_in_seconds":14400}'
r = client.post("/api/packs/sign-link", content=body, headers=_signed(body))
assert r.status_code == 400
def test_upload_invalid_token():
r = client.post(
"/api/packs/upload",
files={"file": ("x.pdf", b"abc", "application/pdf")},
headers={"Authorization": "Bearer invalid"},
)
assert r.status_code == 401
def test_upload_no_auth():
r = client.post(
"/api/packs/upload",
files={"file": ("x.pdf", b"abc", "application/pdf")},
)
assert r.status_code == 401
@patch("app.routes._supabase")
def test_list_success(mock_sb):
mock_table = MagicMock()
mock_table.select.return_value = mock_table
mock_table.is_.return_value = mock_table
mock_table.order.return_value = mock_table
mock_table.execute.return_value = MagicMock(data=[
{
"id": str(uuid.uuid4()),
"min_tier": "starter",
"label": "테스트",
"file_path": "/volume1/.../x.pdf",
"filename": "x.pdf",
"size_bytes": 100,
"sort_order": 0,
"uploaded_at": "2026-05-02T12:00:00+00:00",
}
])
mock_sb.return_value.table.return_value = mock_table
body = b''
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
def test_upload_size_mismatch(tmp_path, monkeypatch):
"""토큰 size_bytes ≠ 실제 파일 크기 → 400 + 파일 정리됨."""
monkeypatch.setattr("app.routes.PACK_BASE_DIR", tmp_path)
token = auth.mint_upload_token({
"tier": "pro",
"label": "샘플",
"filename": "size_mismatch_test.zip",
"size_bytes": 999,
"jti": str(uuid.uuid4()),
"expires_at": int(time.time()) + 1800,
})
test_client = TestClient(app)
resp = test_client.post(
"/api/packs/upload",
files={"file": ("size_mismatch_test.zip", b"hello")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 400
assert "크기" in resp.json()["detail"]
# 파일이 정리되었는지 확인
assert not (tmp_path / "pro" / "size_mismatch_test.zip").exists()
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"}]
)
unique_jti = f"replay-jti-unique-{uuid.uuid4()}"
token = auth.mint_upload_token({
"tier": "pro",
"label": "샘플",
"filename": "replay_test.zip",
"size_bytes": 5,
"jti": unique_jti,
"expires_at": int(time.time()) + 1800,
})
with patch("app.routes._supabase", return_value=fake_supabase):
test_client = TestClient(app)
resp1 = test_client.post(
"/api/packs/upload",
files={"file": ("replay_test.zip", b"hello")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp1.status_code == 200
# 2차 — 동일 토큰 재사용 → 409
resp2 = test_client.post(
"/api/packs/upload",
files={"file": ("replay_test.zip", b"world")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp2.status_code == 409
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 = _signed(body_bytes)
with patch("app.routes._supabase", return_value=fake_supabase):
test_client = TestClient(app)
resp = test_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
assert "T" in update_kwargs["deleted_at"] # ISO 8601
def test_list_filters_deleted():
"""list 라우트가 supabase에 is_(deleted_at, 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 = _signed(body_bytes)
with patch("app.routes._supabase", return_value=fake_supabase):
test_client = TestClient(app)
resp = test_client.get("/api/packs/list", headers=headers)
assert resp.status_code == 200
fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null")