# 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 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://.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)