5/11 운영 첫 호출 검증 중 발견된 사항을 spec/CLAUDE.md에 반영: 1. DSM API path 형식 차이: Synology DSM은 일반 사용자 권한일 때 /<shared_folder>/... 형식만 인식, /volume1/... 거부 (error 408). PACK_HOST_DIR 운영 예시값 /docker/webpage/media/packs로 변경. 2. DSM_VERIFY_SSL env 명시: LAN IP + self-signed cert 환경에서 SSL 검증 끄기 위한 환경변수. .env.example 7+3 path로 갱신. 3. DSM 사용자 권한 가이드: File Station + Sharing 둘 다 ON 필요. 4. NAS 디렉토리 준비 명령에서 호스트 OS path와 DSM API path 차이 명시. 운영 검증: HTTP 200 + DSM 공유 URL (gofile.me/...) 발급 확인.
19 KiB
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.example6+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 추가)
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 추가)
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에서:
- 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에서 실행)
-- 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 서비스
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/ 라우팅
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 — 신규 환경변수 (7 + 3 path)
# ─── packs-lab — NAS 자료 다운로드 자동화 ────────────────────────────
# Synology DSM 7.x 인증 (공유 링크 발급용)
DSM_HOST=https://gahusb.synology.me:5001
DSM_USER=
DSM_PASS=
# LAN IP + self-signed cert 환경에서 IP mismatch 시 false (LAN 내부 통신이라 허용)
DSM_VERIFY_SSL=false
# 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 API용 path. Synology DSM API는 일반 사용자 권한일 때 /<shared_folder>/... 형식만 인식하고 /volume1/... 절대경로는 거부(error 408).
# 운영 NAS는 반드시 shared folder 시점 — /docker/webpage/media/packs.
# admin 사용자는 /volume1/... 도 가능하지만 보안상 별도 packs-bot user 권장.
PACK_HOST_DIR=/docker/webpage/media/packs
5.4 NAS 디렉토리 준비
운영 첫 배포 시 SSH로 1회. 파일은 PACK_HOST_DIR 평면에 직접 저장 — tier 디렉토리 분기는 만들지 않음(tier 구분은 filename 규칙으로 admin이 관리):
mkdir -p /volume1/docker/webpage/media/packs # 호스트 OS path (volume 마운트용)
chown -R PUID:PGID /volume1/docker/webpage/media/packs
PUID/PGID는 .env의 기존 값 사용.
⚠️ DSM 사용자 권한 — File Station + Sharing 둘 다 필요: Control Panel → User → packs-bot(또는 admin) → Permissions → File Station에서
dockershared folder Read 권한 + Applications → Sharing 권한 ON.
5.5 scripts/deploy-nas.sh SERVICES 화이트리스트
webhook 자동 배포(deployer)가 호출하는 sync 스크립트는 화이트리스트로 동기화 대상 디렉토리를 명시한다. 신규 서비스 추가 시 반드시 함께 수정해야 NAS 운영 디렉토리에 소스 sync + docker compose 빌드가 동작한다.
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 (신규)
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.프로젝트 개요
- 서비스: 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_filesDDL - ✅ 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)