"""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 (single-shot) - POST /api/packs/upload/init — 일회성 토큰 인증 → chunked upload 세션 초기화 - PUT /api/packs/upload/{session_id}/chunk — 동일 토큰 + offset → 부분파일 append - POST /api/packs/upload/{session_id}/complete — 동일 토큰 → 완료 + supabase INSERT - GET /api/packs/upload/{session_id}/status — 현재 written 조회 (재개용) - DELETE /api/packs/upload/{session_id} — 세션 중단 + 부분파일 정리 - GET /api/packs/list — Vercel HMAC 인증 → pack_files 전체 조회 - DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete (DSM 공유는 자동 만료) """ import json import logging import os import re import shutil import time import uuid from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile from supabase import Client, create_client from .auth import ( mint_upload_token, verify_request_hmac, verify_upload_token, verify_upload_token_no_consume, ) from .dsm_client import DSMError, create_share_link from .models import ( ChunkUploadResponse, InitUploadResponse, MintTokenRequest, MintTokenResponse, PackFileItem, SignLinkRequest, SignLinkResponse, UploadResponse, ) 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]+$") UPLOAD_TOKEN_TTL_SEC = int(os.getenv("UPLOAD_TOKEN_TTL_SEC", "1800")) # 30분 default CHUNK_MAX_SIZE = int(os.getenv("PACK_CHUNK_MAX_SIZE", str(64 * 1024 * 1024))) # 64MB default SESSIONS_DIR_NAME = ".uploads" def _sessions_root() -> Path: return PACK_BASE_DIR / SESSIONS_DIR_NAME def _session_dir(jti: str) -> Path: # jti는 uuid4 형식이라 path traversal 위험 없음. 안전을 위해 추가 검증. if not re.match(r"^[0-9a-fA-F\-]{1,64}$", jti): raise HTTPException(status_code=400, detail="잘못된 session_id") return _sessions_root() / jti def _session_meta_path(jti: str) -> Path: return _session_dir(jti) / "meta.json" def _session_data_path(jti: str) -> Path: return _session_dir(jti) / "data.part" def _load_session(jti: str) -> dict: meta_file = _session_meta_path(jti) if not meta_file.exists(): raise HTTPException(status_code=404, detail="업로드 세션을 찾을 수 없습니다") return json.loads(meta_file.read_text(encoding="utf-8")) def _save_session(jti: str, meta: dict) -> None: _session_meta_path(jti).write_text(json.dumps(meta), encoding="utf-8") def _cleanup_session(jti: str) -> None: shutil.rmtree(_session_dir(jti), ignore_errors=True) def _verify_session_token(authorization: str, session_id: str) -> dict: if not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Authorization 헤더 누락") token = authorization[len("Bearer "):] payload = verify_upload_token_no_consume(token) if payload.get("jti") != session_id: raise HTTPException(status_code=403, detail="토큰과 세션 ID 불일치") return payload def _supabase() -> Client: url = os.getenv("SUPABASE_URL", "") key = os.getenv("SUPABASE_SERVICE_KEY", "") if not url or not key: raise HTTPException(status_code=503, detail="Supabase 설정 미완료") return create_client(url, key) def _check_filename(filename: str) -> str: if not SAFE_FILENAME.match(filename): raise HTTPException(status_code=400, detail="파일명에 허용되지 않은 문자가 포함되어 있습니다") if "/" in filename or "\\" in filename or filename.startswith("."): raise HTTPException(status_code=400, detail="잘못된 파일명") ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" if ext not in ALLOWED_EXT: raise HTTPException(status_code=400, detail=f"허용되지 않은 확장자: {ext}") return filename @router.post("/sign-link", response_model=SignLinkResponse) async def sign_link( request: Request, x_timestamp: str = Header(""), x_signature: str = Header(""), ): body = await request.body() verify_request_hmac(body, x_timestamp, x_signature) payload = SignLinkRequest.model_validate_json(body) # 경로 안전: 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_HOST_DIR)): raise HTTPException(status_code=400, detail="허용된 경로 외부") try: url, expires_at = await create_share_link(str(abs_path), payload.expires_in_seconds) except DSMError as e: logger.error("DSM 오류: %s", e) raise HTTPException(status_code=502, detail=f"DSM 오류: {e}") 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(...), authorization: str = Header(""), ): if not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Authorization 헤더 누락") token = authorization[len("Bearer "):] payload = verify_upload_token(token) tier = payload["tier"] label = payload["label"] filename = _check_filename(payload["filename"]) expected_size = int(payload["size_bytes"]) # tier 디렉토리는 만들지 않고 PACK_BASE_DIR 평면 구조에 저장. tier 구분은 filename 규칙으로. PACK_BASE_DIR.mkdir(parents=True, exist_ok=True) target = PACK_BASE_DIR / filename if target.exists(): raise HTTPException(status_code=409, detail="이미 존재하는 파일명입니다. 다른 이름으로 업로드하거나 기존 파일을 먼저 삭제하세요") upload_committed = False try: # multipart 스트림 저장 + 크기 검증 written = 0 with target.open("wb") as f: while True: chunk = await file.read(1024 * 1024) if not chunk: break written += len(chunk) if written > MAX_BYTES: raise HTTPException(status_code=413, detail="파일 크기 5GB 초과") f.write(chunk) if written != expected_size: raise HTTPException(status_code=400, detail=f"실제 크기({written})와 토큰 크기({expected_size}) 불일치") # Supabase·DSM에 노출되는 file_path는 NAS 호스트 절대경로여야 한다. # 컨테이너 경로(target)는 마운트된 호스트경로의 다른 시점일 뿐이라, 같은 디렉토리 구조를 보유. host_path = PACK_HOST_DIR / filename # supabase INSERT sb = _supabase() file_id = str(uuid.uuid4()) try: res = sb.table("pack_files").insert({ "id": file_id, "min_tier": tier, "label": label, "file_path": str(host_path), "filename": filename, "size_bytes": written, }).execute() except Exception as e: logger.exception("Supabase INSERT 예외: filename=%s", filename) raise HTTPException(status_code=500, detail=f"DB INSERT 실패: {e}") from e if not res.data: raise HTTPException(status_code=500, detail="DB INSERT 실패") upload_committed = True return UploadResponse( file_id=file_id, file_path=str(host_path), filename=filename, size_bytes=written, min_tier=tier, label=label, uploaded_at=res.data[0]["uploaded_at"], ) finally: if not upload_committed and target.exists(): try: target.unlink() logger.warning("업로드 실패로 부분 파일 정리: %s", target) except Exception as e: logger.exception("부분 파일 정리 실패: %s — %s", target, e) # ── Chunked upload (resumable) ────────────────────────────────────────────── # mint-token이 발급한 동일 토큰을 init → chunk* → complete 전 흐름에서 재사용한다. # jti = session_id. init에서만 jti consume, chunk/complete/abort는 no-consume 검증. @router.post("/upload/init", response_model=InitUploadResponse) async def upload_init(authorization: str = Header("")): if not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Authorization 헤더 누락") token = authorization[len("Bearer "):] payload = verify_upload_token(token) # init만 jti consume tier = payload["tier"] label = payload["label"] filename = _check_filename(payload["filename"]) expected_size = int(payload["size_bytes"]) jti = payload["jti"] PACK_BASE_DIR.mkdir(parents=True, exist_ok=True) if (PACK_BASE_DIR / filename).exists(): raise HTTPException(status_code=409, detail="이미 존재하는 파일명입니다") sdir = _session_dir(jti) if sdir.exists(): raise HTTPException(status_code=409, detail="이미 시작된 세션입니다") sdir.mkdir(parents=True, exist_ok=True) _session_data_path(jti).touch() _save_session(jti, { "filename": filename, "expected_size": expected_size, "tier": tier, "label": label, "written": 0, "expires_at": int(payload["expires_at"]), }) return InitUploadResponse( session_id=jti, chunk_max_size=CHUNK_MAX_SIZE, expected_size=expected_size, expires_at=datetime.fromtimestamp(payload["expires_at"], tz=timezone.utc), ) @router.put("/upload/{session_id}/chunk", response_model=ChunkUploadResponse) async def upload_chunk( session_id: str, request: Request, offset: int = 0, authorization: str = Header(""), ): _verify_session_token(authorization, session_id) meta = _load_session(session_id) if offset != meta["written"]: raise HTTPException( status_code=409, detail=f"offset {offset} 불일치 (현재 written={meta['written']})", headers={"X-Current-Offset": str(meta["written"])}, ) body = await request.body() if not body: raise HTTPException(status_code=400, detail="청크가 비어 있음") if len(body) > CHUNK_MAX_SIZE: raise HTTPException(status_code=413, detail=f"청크 크기 {CHUNK_MAX_SIZE} 초과") if meta["written"] + len(body) > meta["expected_size"]: raise HTTPException(status_code=413, detail="누적 크기 expected_size 초과") with _session_data_path(session_id).open("ab") as f: f.write(body) meta["written"] += len(body) _save_session(session_id, meta) return ChunkUploadResponse(written=meta["written"], expected_size=meta["expected_size"]) @router.get("/upload/{session_id}/status", response_model=ChunkUploadResponse) async def upload_status( session_id: str, authorization: str = Header(""), ): _verify_session_token(authorization, session_id) meta = _load_session(session_id) return ChunkUploadResponse(written=meta["written"], expected_size=meta["expected_size"]) @router.post("/upload/{session_id}/complete", response_model=UploadResponse) async def upload_complete( session_id: str, authorization: str = Header(""), ): _verify_session_token(authorization, session_id) meta = _load_session(session_id) if meta["written"] != meta["expected_size"]: raise HTTPException( status_code=400, detail=f"미완료: written={meta['written']} expected={meta['expected_size']}", ) filename = meta["filename"] target = PACK_BASE_DIR / filename if target.exists(): raise HTTPException(status_code=409, detail="이미 존재하는 파일명입니다") data_file = _session_data_path(session_id) data_file.replace(target) # atomic rename within same FS host_path = PACK_HOST_DIR / filename sb = _supabase() file_id = str(uuid.uuid4()) try: res = sb.table("pack_files").insert({ "id": file_id, "min_tier": meta["tier"], "label": meta["label"], "file_path": str(host_path), "filename": filename, "size_bytes": meta["written"], }).execute() except Exception as e: logger.exception("Supabase INSERT 예외 (chunked complete): filename=%s", filename) target.unlink(missing_ok=True) raise HTTPException(status_code=500, detail=f"DB INSERT 실패: {e}") from e if not res.data: target.unlink(missing_ok=True) raise HTTPException(status_code=500, detail="DB INSERT 실패") _cleanup_session(session_id) return UploadResponse( file_id=file_id, file_path=str(host_path), filename=filename, size_bytes=meta["written"], min_tier=meta["tier"], label=meta["label"], uploaded_at=res.data[0]["uploaded_at"], ) @router.delete("/upload/{session_id}") async def upload_abort( session_id: str, authorization: str = Header(""), ): _verify_session_token(authorization, session_id) _cleanup_session(session_id) return {"ok": True} @router.get("/list", response_model=list[PackFileItem]) async def list_files( request: Request, x_timestamp: str = Header(""), x_signature: str = Header(""), ): body = await request.body() verify_request_hmac(body, x_timestamp, x_signature) sb = _supabase() res = ( sb.table("pack_files") .select("*") .is_("deleted_at", "null") .order("min_tier") .order("sort_order") .execute() ) return [PackFileItem(**r) for r in (res.data or [])] @router.delete("/{file_id}") async def delete_file( file_id: str, request: Request, x_timestamp: str = Header(""), x_signature: str = Header(""), ): body = await request.body() verify_request_hmac(body, x_timestamp, x_signature) sb = _supabase() res = sb.table("pack_files").update({ "deleted_at": datetime.now(timezone.utc).isoformat(), }).eq("id", file_id).execute() if not res.data: raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") return {"ok": True}