o2o-site-AEO/solution/backend/router/v1/media/relay.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

69 lines
2.7 KiB
Python

"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다."""
import ipaddress
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, HTTPException, Query, Response
from common.logger import LOG
router = APIRouter(prefix="/v1/image", tags=["Image"])
# 우리 수집기가 사진을 가져오는 곳.
_ALLOWED_SUFFIXES = (
".pstatic.net",
"tong.visitkorea.or.kr",
".visitkorea.or.kr",
"upload.wikimedia.org",
)
_MAX_BYTES = 8 * 1024 * 1024
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
# 기본 UA 를 거절하는 CDN 이 있다.
_HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"}
def _allowed(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme != "https" or not parsed.hostname:
return False
host = parsed.hostname.lower()
# 숫자 주소는 받지 않는다 — 사내망으로 향하는 통로를 만들지 않는다.
try:
ipaddress.ip_address(host)
return False
except ValueError:
pass
return any(host == suffix.lstrip(".") or host.endswith(suffix) for suffix in _ALLOWED_SUFFIXES)
@router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다")
async def relay(url: str = Query(min_length=8, max_length=2000)):
"""인증을 요구하지 않는다."""
if not _allowed(url):
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
try:
async with httpx.AsyncClient(timeout=_TIMEOUT, headers=_HEADERS, follow_redirects=True) as client:
res = await client.get(url)
except httpx.HTTPError as ex:
LOG.w(f"[image] 중계 실패 {url}: {ex}")
raise HTTPException(status_code=502, detail="사진을 가져오지 못했습니다")
# 리다이렉트로 allowlist 밖으로 나갔으면 거기서 끊는다.
if not _allowed(str(res.url)):
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
kind = (res.headers.get("content-type") or "").split(";")[0].strip().lower()
if res.status_code != 200 or not kind.startswith("image/"):
raise HTTPException(status_code=502, detail="사진이 아닙니다")
if len(res.content) == 0 or len(res.content) > _MAX_BYTES:
raise HTTPException(status_code=502, detail="사진 크기가 범위를 벗어났습니다")
# 주소가 곧 내용이다(원본이 바뀌면 주소도 바뀐다) — 길게 캐시해서 중계 횟수를 줄인다.
return Response(
content=res.content,
media_type=kind,
headers={"cache-control": "public, max-age=604800, immutable"},
)