"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다.""" 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"}, )