여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
69 lines
2.7 KiB
Python
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"},
|
|
)
|