From f1f1dc98a6aeffe06918747a130f8bbb46f401b5 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 11 May 2026 02:47:26 +0900 Subject: [PATCH] =?UTF-8?q?fix(packs-lab):=20PACK=5FHOST=5FDIR=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=E2=80=94=20sign-link=20=EC=8B=9C=20DSM=EC=9D=B4=20?= =?UTF-8?q?NAS=20=ED=98=B8=EC=8A=A4=ED=8A=B8=EA=B2=BD=EB=A1=9C=20=EB=B0=9B?= =?UTF-8?q?=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전: upload가 컨테이너 경로(/app/data/packs/...)를 Supabase에 저장 → sign-link 시 그 경로를 DSM에 전달 → DSM은 NAS 호스트 절대경로 (/volume1/.../media/packs/...) 기준이라 파일을 찾지 못함. 수정: - routes.py: PACK_HOST_DIR 신규 (env, fallback=PACK_BASE_DIR) - upload 시 host_path = PACK_HOST_DIR/{tier}/{filename}을 Supabase에 INSERT - sign-link 시 PACK_HOST_DIR 기준 경로 검증 - docker-compose: PACK_HOST_DIR env 주입 (default=PACK_DATA_PATH) - .env.example + CLAUDE.md: 환경변수 의미 분리 명시 - tests: 호스트경로 저장 검증 신규 (test_upload_stores_host_path_not_container_path) - 25/25 passing Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 4 +++ CLAUDE.md | 5 +-- docker-compose.yml | 1 + packs-lab/app/routes.py | 17 +++++++--- packs-lab/tests/test_routes.py | 57 ++++++++++++++++++++++++++++++++-- 5 files changed, 76 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 6c10111..ebb65d3 100644 --- a/.env.example +++ b/.env.example @@ -115,3 +115,7 @@ PACK_DATA_PATH=./data/packs # 컨테이너 내부 PACK_BASE_DIR (routes.py가 파일 저장 시 사용. docker-compose volume의 컨테이너 측 경로와 반드시 일치) PACK_BASE_DIR=/app/data/packs + +# DSM·Supabase에 노출되는 NAS 호스트 절대경로 (PACK_DATA_PATH와 같은 디렉토리를 호스트 시점에서 가리킴). +# 운영 NAS는 반드시 /volume1/docker/webpage/media/packs 같은 절대경로 설정. 미설정 시 PACK_DATA_PATH로 fallback (로컬 개발용). +PACK_HOST_DIR=/volume1/docker/webpage/media/packs diff --git a/CLAUDE.md b/CLAUDE.md index ced1191..e59eae4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -642,7 +642,7 @@ docker compose up -d - 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 마운트와 일치 필수. +- 경로 분리: `PACK_BASE_DIR`(컨테이너 내부, upload 저장 target) ↔ `PACK_HOST_DIR`(NAS 호스트 절대경로, Supabase에 저장 + DSM 호출 시 사용). 운영 NAS에서 `PACK_HOST_DIR` 미설정 시 sign-link가 컨테이너 경로를 DSM에 전달해 파일을 못 찾음. **환경변수** - `DSM_HOST` / `DSM_USER` / `DSM_PASS`: Synology DSM 7.x 인증 (공유 링크 발급용) @@ -650,7 +650,8 @@ docker compose up -d - `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`) +- `PACK_HOST_DIR`: NAS 호스트 절대경로 (운영 `/volume1/docker/webpage/media/packs`, 미설정 시 PACK_BASE_DIR로 fallback) +- `PACK_DATA_PATH`: docker-compose volume 마운트의 호스트 측 경로 (로컬 `./data/packs`, NAS `/volume1/docker/webpage/media/packs`) **HMAC 인증 패턴** - Vercel → backend 요청: `X-Timestamp` (UNIX 초) + `X-Signature` (HMAC_SHA256(timestamp + "." + body, secret)) diff --git a/docker-compose.yml b/docker-compose.yml index 9443316..dc36304 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -203,6 +203,7 @@ services: - 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} healthcheck: diff --git a/packs-lab/app/routes.py b/packs-lab/app/routes.py index dd52bd2..03c5669 100644 --- a/packs-lab/app/routes.py +++ b/packs-lab/app/routes.py @@ -32,6 +32,10 @@ logger = logging.getLogger("packs-lab.routes") router = APIRouter(prefix="/api/packs") PACK_BASE_DIR = Path(os.getenv("PACK_BASE_DIR", "/app/data/packs")) +# DSM·Supabase에 노출되는 NAS 호스트 절대경로. 컨테이너 내부 PACK_BASE_DIR과 같은 디렉토리를 +# 호스트 시점에서 가리켜야 한다 (docker volume 마운트의 호스트 측 경로). 미설정 시 PACK_BASE_DIR로 +# fallback — 로컬 개발용. 운영 NAS에서는 반드시 PACK_HOST_DIR=/volume1/docker/webpage/media/packs. +PACK_HOST_DIR = Path(os.getenv("PACK_HOST_DIR", str(PACK_BASE_DIR))) 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]+$") @@ -67,9 +71,10 @@ async def sign_link( verify_request_hmac(body, x_timestamp, x_signature) payload = SignLinkRequest.model_validate_json(body) - # 경로 안전: PACK_BASE_DIR 하위인지 확인 + # 경로 안전: PACK_HOST_DIR(NAS 호스트 절대경로) 하위인지 확인. + # file_path는 upload 라우트가 Supabase에 저장한 호스트경로 그대로 전달되어 DSM API에 사용됨. abs_path = Path(payload.file_path).resolve() - if not str(abs_path).startswith(str(PACK_BASE_DIR)): + if not str(abs_path).startswith(str(PACK_HOST_DIR)): raise HTTPException(status_code=400, detail="허용된 경로 외부") try: @@ -148,6 +153,10 @@ async def upload( target.unlink(missing_ok=True) raise HTTPException(status_code=400, detail=f"실제 크기({written})와 토큰 크기({expected_size}) 불일치") + # Supabase·DSM에 노출되는 file_path는 NAS 호스트 절대경로여야 한다. + # 컨테이너 경로(target)는 마운트된 호스트경로의 다른 시점일 뿐이라, 같은 디렉토리 구조를 보유. + host_path = PACK_HOST_DIR / tier / filename + # supabase INSERT sb = _supabase() file_id = str(uuid.uuid4()) @@ -155,7 +164,7 @@ async def upload( "id": file_id, "min_tier": tier, "label": label, - "file_path": str(target), + "file_path": str(host_path), "filename": filename, "size_bytes": written, }).execute() @@ -165,7 +174,7 @@ async def upload( return UploadResponse( file_id=file_id, - file_path=str(target), + file_path=str(host_path), filename=filename, size_bytes=written, min_tier=tier, diff --git a/packs-lab/tests/test_routes.py b/packs-lab/tests/test_routes.py index d253ab9..02ed84b 100644 --- a/packs-lab/tests/test_routes.py +++ b/packs-lab/tests/test_routes.py @@ -37,11 +37,12 @@ def test_health(): @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도 동일하게 패치 + # Windows에서는 절대경로 resolve 결과가 C:\... 로 prefix되므로 PACK_HOST_DIR도 동일하게 패치 + # sign-link는 PACK_HOST_DIR(NAS 호스트경로) 기준으로 검증함. 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): + with patch("app.routes.PACK_HOST_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 @@ -245,3 +246,55 @@ def test_list_filters_deleted(): assert resp.status_code == 200 fake_supabase.table.return_value.select.return_value.is_.assert_called_with("deleted_at", "null") + + +def test_upload_stores_host_path_not_container_path(tmp_path, monkeypatch): + """upload 시 Supabase에 저장되는 file_path는 PACK_BASE_DIR(컨테이너) 가 아닌 PACK_HOST_DIR(NAS 호스트) 절대경로여야 한다. + + DSM API는 NAS 호스트 절대경로 기준이라 컨테이너 내부 경로(/app/data/packs/...)를 + Supabase에 저장하면 sign-link 시 DSM이 파일을 못 찾는다. + """ + from pathlib import Path + container_base = tmp_path / "container" + host_base = Path("/volume1/docker/webpage/media/packs") + + monkeypatch.setattr("app.routes.PACK_BASE_DIR", container_base) + monkeypatch.setattr("app.routes.PACK_HOST_DIR", host_base) + + captured_insert = {} + + fake_supabase = MagicMock() + + def capture_insert(payload): + captured_insert.update(payload) + m = MagicMock() + m.execute.return_value = MagicMock(data=[{"uploaded_at": "2026-05-11T00:00:00+00:00"}]) + return m + + fake_supabase.table.return_value.insert.side_effect = capture_insert + + token = auth.mint_upload_token({ + "tier": "pro", + "label": "샘플", + "filename": "host_path_check.zip", + "size_bytes": 5, + "jti": str(uuid.uuid4()), + "expires_at": int(time.time()) + 1800, + }) + + with patch("app.routes._supabase", return_value=fake_supabase): + test_client = TestClient(app) + resp = test_client.post( + "/api/packs/upload", + files={"file": ("host_path_check.zip", b"hello")}, + headers={"Authorization": f"Bearer {token}"}, + ) + + assert resp.status_code == 200 + # Supabase에 저장된 file_path는 호스트 경로 + expected_host = str(host_base / "pro" / "host_path_check.zip") + assert captured_insert["file_path"] == expected_host + # 응답의 file_path도 호스트 경로 + assert resp.json()["file_path"] == expected_host + # 컨테이너 경로(tmp_path 하위)와 다름 + assert str(container_base) not in captured_insert["file_path"]