"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다. ★ 왜 필요한가 (2026-09-15, 실측) 수집한 사진은 `*.pstatic.net` · `tong.visitkorea.or.kr` 에 있고 그쪽은 `Access-Control-Allow-Origin` 을 주지 않는다. 그 사진을 캔버스에 그리면 캔버스가 **오염돼** `toBlob` 이 막힌다 — 엽서 쓰기의 저장·공유가 죽는다. 브라우저 정책이라 클라이언트에서는 못 넘는다. CORS 없는 `fetch` 도 같은 벽이다. **같은 오리진에서 바이트가 와야** 풀린다. ★ 굽는 쪽(`site/scripts/prerender.ts` mirrorMedia)이 이미 사진을 내려받아 사이트 폴더에 놓는다. 그게 근본이다. 다만 그건 **다시 굽는 사이트에만** 적용된다 — 이미 나가 있는 사이트는 사장님이 재발행할 때까지 옛 주소를 문다. 이 중계가 그 사이를 메운다. ★ 열린 프록시가 되지 않게 좁혀 둔다. 이건 "아무 주소나 가져다주는 통로" 가 아니다: · https 만 · 호스트가 `_ALLOWED_SUFFIXES` 에 있는 것만 (우리 수집기가 쓰는 사진 CDN) · 응답이 이미지가 아니면 거절, 크기 상한 · 리다이렉트를 따라가되 최종 호스트도 다시 검사한다 — 안 그러면 allowlist 를 우회한다 """ 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)): """인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는 것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다).""" 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"}, )