82 lines
3.3 KiB
Python
82 lines
3.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""P2V 정적 파일 경량 프록시.
|
|
|
|
결과물(영상·결과 이미지)은 Azure Blob 공개 URL 로 나가므로 여기를 지나지 않는다.
|
|
이 프록시는 **작은 이미지 두 종류**만 중계한다:
|
|
|
|
- regions/ — 검수용 영역분석 이미지. 사용자 포스터가 담기므로 로그인 필수.
|
|
(프론트는 authenticatedFetch → blob URL 로 <img> 에 넣는다)
|
|
- templates/, user_templates/ — 템플릿 썸네일. `<img src>` 는 인증 헤더를 못
|
|
붙이고 내용도 공개 명화(퍼블릭 도메인)라 인증 없이 중계한다.
|
|
|
|
인증 라우트를 분리한 이유(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")
|