o2o-site-AEO/solution/backend/router/v1/media/relay.py
Mina Choi f0c4d5f413 [fix] solution: 엽서·가요 다방 손질 — 재굽기 전 사이트도 공유되게 사진을 중계한다
배포 뒤 대표 지적 넷을 한 번에 고친다.

- 사진 중계(`backend/router/v1/media/relay.py`, `/v1/image/relay`) — 굽는 쪽이 사진을
  우리 자리로 옮기는 건 **다시 굽는 사이트에만** 적용된다. 아직 재발행 안 한 사이트는
  캔버스가 오염된 채라 저장·공유가 막혀 있다. 같은 오리진으로 바이트를 흘려보내 그 사이를
  메운다. `postcard-canvas.loadImage` 가 CORS 실패 때만 두 번째로 탄다 —
  이미 우리 자리에 있는 사진은 첫 시도에서 끝난다.
  ★ 열린 프록시가 되지 않게: https · 호스트 allowlist · 이미지 타입 · 8MB ·
    리다이렉트 후 호스트 재검사 · IP 주소 거절. 실측: pstatic 사진 158KB 통과,
    http·목록 밖·127.0.0.1·169.254.169.254 전부 거절
- 엽서 미리보기 360px (320 → 240 은 작다 하셔서 그 사이로)
- 엽서 사진 목록에 슬라이더가 없던 것 — `.slider-viewport` 마크업만 손으로 찍어서
  embla 가 안 붙어 있었다(그건 스크립트 붙기 전의 기본 상태다). 진짜 `Carousel` 로 바꾼다
- 가요 다방 판이 전부 주朱색이던 것 — **실측 67곡 중 63곡에 `labelColor` 가 없다.**
  모델이 공통 규칙 2("확인 안 된 값은 뺀다")를 색에까지 적용한다. 색은 사실이 아니라
  디자인 값이다: 프롬프트에 그렇게 못 박고, 비었을 때는 제목 해시로 레코드 라벨색 8종에서
  고른다(무작위가 아니다 — 하이드레이션이 어긋나면 안 되고, 새로고침마다 바뀌어도 안 된다)

tsc·eslint 통과(site), site 79 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:51:53 +09:00

88 lines
4.3 KiB
Python

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