From ef026e7ac6f0edeb1300b392e7a6aec3f0200446 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 6 May 2026 01:24:17 +0900 Subject: [PATCH 1/9] =?UTF-8?q?test(packs-lab):=20conftest=EB=A1=9C=20HMAC?= =?UTF-8?q?=20secret=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모든 테스트에서 BACKEND_HMAC_SECRET 환경변수와 auth._SECRET 모듈 캐시를 동일한 값으로 설정하는 autouse fixture 추가. 기존 test_auth.py / test_routes.py와 동일한 secret 값 사용. Co-Authored-By: Claude Sonnet 4.6 --- packs-lab/tests/conftest.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packs-lab/tests/conftest.py diff --git a/packs-lab/tests/conftest.py b/packs-lab/tests/conftest.py new file mode 100644 index 0000000..ee12adc --- /dev/null +++ b/packs-lab/tests/conftest.py @@ -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) From dc482b32e44637a62e7e598e44d5d422ef004bbd Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 6 May 2026 01:27:43 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(packs-lab):=20POST=20/api/packs/admin/?= =?UTF-8?q?mint-token=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20+=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MintTokenRequest/Response 스키마 추가, mint_token 라우트 구현 (HMAC 인증 + 확장자 검증 + JTI 발급), 테스트 3건 추가. Co-Authored-By: Claude Sonnet 4.6 --- packs-lab/app/models.py | 14 +++++++++++++ packs-lab/app/routes.py | 34 +++++++++++++++++++++++++++++++- packs-lab/tests/test_routes.py | 36 ++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packs-lab/app/models.py b/packs-lab/app/models.py index 0a7a186..c0fd9b8 100644 --- a/packs-lab/app/models.py +++ b/packs-lab/app/models.py @@ -37,3 +37,17 @@ class PackFileItem(BaseModel): size_bytes: int sort_order: int uploaded_at: datetime + + +class MintTokenRequest(BaseModel): + """Vercel → backend: admin upload 토큰 발급 요청.""" + tier: PackTier + label: str = Field(..., max_length=200) + filename: str = Field(..., max_length=255) + size_bytes: int = Field(..., gt=0, le=5 * 1024 * 1024 * 1024) + + +class MintTokenResponse(BaseModel): + token: str + expires_at: datetime + jti: str diff --git a/packs-lab/app/routes.py b/packs-lab/app/routes.py index c7e2c73..57b4cbc 100644 --- a/packs-lab/app/routes.py +++ b/packs-lab/app/routes.py @@ -8,6 +8,7 @@ import logging import os import re +import time import uuid from datetime import datetime, timezone from pathlib import Path @@ -15,9 +16,11 @@ from pathlib import Path from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile from supabase import Client, create_client -from .auth import verify_request_hmac, verify_upload_token +from .auth import mint_upload_token, verify_request_hmac, verify_upload_token from .dsm_client import DSMError, create_share_link from .models import ( + MintTokenRequest, + MintTokenResponse, PackFileItem, SignLinkRequest, SignLinkResponse, @@ -31,6 +34,7 @@ PACK_BASE_DIR = Path("/volume1/docker/webpage/media/packs") ALLOWED_EXT = {"pdf", "zip", "mp4", "mov", "mkv", "wav", "m4a", "mp3", "png", "jpg", "jpeg", "webp", "prj"} MAX_BYTES = 5 * 1024 * 1024 * 1024 # 5GB SAFE_FILENAME = re.compile(r"^[\w가-힣\-\.\(\)\s]+$") +UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default def _supabase() -> Client: @@ -76,6 +80,34 @@ async def sign_link( return SignLinkResponse(url=url, expires_at=expires_at) +@router.post("/admin/mint-token", response_model=MintTokenResponse) +async def mint_token( + request: Request, + x_timestamp: str = Header(""), + x_signature: str = Header(""), +): + body = await request.body() + verify_request_hmac(body, x_timestamp, x_signature) + payload = MintTokenRequest.model_validate_json(body) + _check_filename(payload.filename) + + jti = str(uuid.uuid4()) + expires_ts = int(time.time()) + UPLOAD_TOKEN_TTL_SEC + token = mint_upload_token({ + "tier": payload.tier, + "label": payload.label, + "filename": payload.filename, + "size_bytes": payload.size_bytes, + "jti": jti, + "expires_at": expires_ts, + }) + return MintTokenResponse( + token=token, + expires_at=datetime.fromtimestamp(expires_ts, tz=timezone.utc), + jti=jti, + ) + + @router.post("/upload", response_model=UploadResponse) async def upload( file: UploadFile = File(...), diff --git a/packs-lab/tests/test_routes.py b/packs-lab/tests/test_routes.py index f2d88fe..44e74e0 100644 --- a/packs-lab/tests/test_routes.py +++ b/packs-lab/tests/test_routes.py @@ -100,3 +100,39 @@ def test_list_success(mock_sb): r = client.get("/api/packs/list", headers=_signed(body)) assert r.status_code == 200 assert len(r.json()) == 1 + + +def test_mint_token_hmac_required(): + """HMAC 헤더 누락 → 401.""" + body = {"tier": "pro", "label": "샘플", "filename": "x.zip", "size_bytes": 1024} + resp = client.post("/api/packs/admin/mint-token", json=body) + assert resp.status_code == 401 + + +def test_mint_token_returns_valid_token(): + """발급된 token이 verify_upload_token으로 통과해야 한다.""" + from app.auth import verify_upload_token + + body = {"tier": "pro", "label": "샘플", "filename": "test.zip", "size_bytes": 2048} + import json as _json + body_bytes = _json.dumps(body).encode() + resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=_signed(body_bytes)) + assert resp.status_code == 200 + data = resp.json() + assert "token" in data and "expires_at" in data and "jti" in data + + payload = verify_upload_token(data["token"]) + assert payload["tier"] == "pro" + assert payload["label"] == "샘플" + assert payload["filename"] == "test.zip" + assert payload["size_bytes"] == 2048 + assert payload["jti"] == data["jti"] + + +def test_mint_token_invalid_filename(): + """허용 외 확장자 → 400.""" + body = {"tier": "pro", "label": "샘플", "filename": "x.exe", "size_bytes": 1024} + import json as _json + body_bytes = _json.dumps(body).encode() + resp = client.post("/api/packs/admin/mint-token", content=body_bytes, headers=_signed(body_bytes)) + assert resp.status_code == 400 From c18fd8e52be89f74f6434522b9dee3793b45083b Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 6 May 2026 01:30:46 +0900 Subject: [PATCH 3/9] =?UTF-8?q?test(packs-lab):=20upload=20size/replay=20+?= =?UTF-8?q?=20delete=20soft-delete=20+=20list=20filter=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- packs-lab/tests/test_routes.py | 109 +++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/packs-lab/tests/test_routes.py b/packs-lab/tests/test_routes.py index 44e74e0..d253ab9 100644 --- a/packs-lab/tests/test_routes.py +++ b/packs-lab/tests/test_routes.py @@ -136,3 +136,112 @@ def test_mint_token_invalid_filename(): 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") From 1cd3cf8830669ea0fa22960015713a09801c6f53 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 6 May 2026 01:33:11 +0900 Subject: [PATCH 4/9] =?UTF-8?q?test(packs-lab):=20DSM=20client=20mock=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20(login/share/logout=20=EC=88=9C?= =?UTF-8?q?=EC=84=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- packs-lab/tests/test_dsm_client.py | 111 +++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 packs-lab/tests/test_dsm_client.py diff --git a/packs-lab/tests/test_dsm_client.py b/packs-lab/tests/test_dsm_client.py new file mode 100644 index 0000000..e488402 --- /dev/null +++ b/packs-lab/tests/test_dsm_client.py @@ -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 누락 의심)" From 5ebcbae8b558d674d573dbdee6e8891164a1476e Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 6 May 2026 01:34:47 +0900 Subject: [PATCH 5/9] =?UTF-8?q?docs(packs-lab):=20routes=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20docstring=20=EC=A0=95=EB=A6=AC=20(mint-token=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20DSM=20=EC=9E=90=EB=8F=99=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=EB=AA=85=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packs-lab/app/routes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packs-lab/app/routes.py b/packs-lab/app/routes.py index 57b4cbc..65cacbf 100644 --- a/packs-lab/app/routes.py +++ b/packs-lab/app/routes.py @@ -1,9 +1,10 @@ """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 From ff4ef299ad16283fe48cc0f8e70b8dd565b156c3 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 6 May 2026 01:35:19 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat(packs-lab):=20Supabase=20pack=5Ffiles?= =?UTF-8?q?=20DDL=20+=20=ED=99=9C=EC=84=B1/=EC=82=AD=EC=A0=9C=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packs-lab/supabase/pack_files.sql | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 packs-lab/supabase/pack_files.sql diff --git a/packs-lab/supabase/pack_files.sql b/packs-lab/supabase/pack_files.sql new file mode 100644 index 0000000..c8f5417 --- /dev/null +++ b/packs-lab/supabase/pack_files.sql @@ -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; From 0906c3ba354752c846a6f213fb08d193349fb3a5 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 6 May 2026 01:37:29 +0900 Subject: [PATCH 7/9] =?UTF-8?q?chore(infra):=20packs-lab=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=ED=86=B5=ED=95=A9=20(compose=2018950=20+?= =?UTF-8?q?=20nginx=205GB=20streaming=20+=20env=207=EA=B0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 19 +++++++++++++++++++ docker-compose.yml | 18 ++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 307e330..388866b 100644 --- a/.env.example +++ b/.env.example @@ -93,3 +93,22 @@ 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://.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 diff --git a/docker-compose.yml b/docker-compose.yml index fa6823b..708c2d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -181,16 +181,18 @@ services: container_name: packs-lab restart: unless-stopped ports: - - "18910:8000" + - "18950:8000" environment: - 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} + - 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} volumes: - - ${RUNTIME_PATH:-.}/media/packs:/volume1/docker/webpage/media/packs + - ${PACK_DATA_PATH:-./data/packs}:/app/data/packs healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] interval: 30s From 5844567048d8a20b49dd902030881ad28c6a8c58 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 6 May 2026 01:40:07 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(packs-lab):=20PACK=5FBASE=5FDIR?= =?UTF-8?q?=EC=9D=84=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=A7=88?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=20=EA=B2=BD=EB=A1=9C=EC=99=80=20routes=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=ED=99=95=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 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 신규 명시 (마운트 경로와 반드시 일치 안내) --- .env.example | 5 ++++- docker-compose.yml | 3 ++- packs-lab/app/routes.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 388866b..6c10111 100644 --- a/.env.example +++ b/.env.example @@ -110,5 +110,8 @@ SUPABASE_SERVICE_KEY= # admin upload 토큰 TTL (초). default 1800 = 30분 UPLOAD_TOKEN_TTL_SEC=1800 -# 로컬 개발: ./data/packs / NAS 운영: /volume1/docker/webpage/media/packs +# 호스트 마운트 경로 (로컬 ./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 diff --git a/docker-compose.yml b/docker-compose.yml index 708c2d9..260830d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -191,8 +191,9 @@ services: - 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}:/app/data/packs + - ${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 diff --git a/packs-lab/app/routes.py b/packs-lab/app/routes.py index 65cacbf..dd52bd2 100644 --- a/packs-lab/app/routes.py +++ b/packs-lab/app/routes.py @@ -31,7 +31,7 @@ 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]+$") From 5e9a51c9e845a2cc356b8c5f540687bc1d021a51 Mon Sep 17 00:00:00 2001 From: gahusb Date: Wed, 6 May 2026 01:42:15 +0900 Subject: [PATCH 9/9] =?UTF-8?q?docs(claude):=20packs-lab=2010=EB=B2=88?= =?UTF-8?q?=EC=A7=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A1=9C=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20(=ED=8F=AC=ED=8A=B8/=EB=9D=BC=EC=9A=B0=ED=8C=85/API?= =?UTF-8?q?=20=ED=91=9C=20+=20=EC=8B=A0=EA=B7=9C=20=EC=84=B9=EC=85=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 51ab67b..ced1191 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` (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` 환경변수로 시크릿 관리