# -*- coding: utf-8 -*- """P2V 정적 파일 경량 프록시. 결과물(영상·결과 이미지)은 Azure Blob 공개 URL 로 나가므로 여기를 지나지 않는다. 이 프록시는 **작은 이미지 두 종류**만 중계한다: - regions/ — 검수용 영역분석 이미지. 사용자 포스터가 담기므로 로그인 필수. (프론트는 authenticatedFetch → blob URL 로 에 넣는다) - templates/, user_templates/ — 템플릿 썸네일. `` 는 인증 헤더를 못 붙이고 내용도 공개 명화(퍼블릭 도메인)라 인증 없이 중계한다. 인증 라우트를 분리한 이유(2026-08-26 리뷰 Critical 반영): 401 응답을 직접 만들면 프로젝트 표준 포맷(detail={"code", "message"})과 어긋나 authenticatedFetch 의 토큰 자동 갱신이 무력화되고 강제 로그아웃이 난다. get_current_user 의존성에 맡기면 만료/누락 케이스 모두 표준 예외 포맷으로 나간다. render/·uploads/·f2/ 는 어느 라우트에도 없다 — 원본·결과물은 Blob 으로만 나간다. Range 미지원(대용량 스트리밍 용도가 아니다). """ from fastapi import APIRouter, Depends, Response from app.p2v.constants import PUBLIC_FILE_PREFIXES from app.p2v.exceptions import P2vDisabledError, P2vFileNotFoundError from app.p2v.services import client from app.user.dependencies.auth import get_current_user from app.user.models import User from app.utils.logger import get_logger from config import p2v_settings logger = get_logger("p2v") router = APIRouter(prefix="/p2v", tags=["P2V"]) def _validate_path(path: str) -> None: """경로 탈출 방어. uvicorn 이 라우팅 전에 percent-decoding 하므로 %2e%2e 우회도 여기서 걸린다 (2026-08-26 리뷰에서 검증됨).""" if not p2v_settings.P2V_ENABLED: raise P2vDisabledError() if ".." in path or path.startswith("/") or "\\" in path: raise P2vFileNotFoundError() async def _relay(path: str, cache: str) -> Response: content, content_type = await client.download(f"/files/{path}") return Response( content=content, media_type=content_type, headers={"Cache-Control": cache}, ) # 정적 경로가 더 구체적이므로 catch-all 보다 먼저 선언한다 (ssulbox 라우트 순서 관례) @router.get( "/files/regions/{path:path}", summary="검수용 영역분석 이미지 중계 (로그인 필수)", responses={ 401: {"description": "인증 실패 (표준 인증 오류 포맷 — 토큰 갱신 대상)"}, 404: {"description": "없는 파일"}, }, ) async def proxy_region_file( path: str, current_user: User = Depends(get_current_user), ) -> Response: _validate_path(path) return await _relay(f"regions/{path}", cache="private, max-age=300") @router.get( "/files/{path:path}", summary="P2V 공개 파일 중계 (템플릿 썸네일 전용)", responses={404: {"description": "화이트리스트 밖이거나 없는 파일"}}, ) async def proxy_public_file(path: str) -> Response: _validate_path(path) if not path.startswith(PUBLIC_FILE_PREFIXES): # render/·uploads/ 등 — 존재 여부를 노출하지 않는다 raise P2vFileNotFoundError() return await _relay(path, cache="public, max-age=3600")