"""발행한 사이트의 썸네일을 Azure Blob 에 남긴다 — 랜딩 쇼케이스 카드가 쓰는 그림.""" import asyncio import os import httpx from azure.storage.blob import BlobClient, BlobServiceClient, ContentSettings from common.logger import LOG from services import azure_static, site_payload # 사이트 경로 밖의 전용 디렉터리. THUMB_DIR = "thumbs" # 허용 content-type → 저장 확장자. _EXT_BY_TYPE = { "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", } # 남의 CDN 을 부르는 길이다. TIMEOUT_SEC = 10.0 # 리다이렉트는 따라가되 무한정은 안 된다(CDN 은 보통 1~2회). MAX_REDIRECTS = 3 # 5MB. MAX_BYTES = 5 * 1024 * 1024 # 썸네일은 재발행마다 바뀔 수 있고 주소는 그대로다 — 길게 캐시하면 옛 그림이 계속 뜬다. CACHE_CONTROL = "public, max-age=60, must-revalidate" # ── 썸네일 전용 저장소 ──────────────────────────────────────────────────────── _BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너> _SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다) _ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. def _blob_base() -> str: return os.environ.get(_BASE_ENV, "").strip().rstrip("/") def _blob_sas() -> str: return os.environ.get(_SAS_ENV, "").strip().lstrip("?") def _blob_root() -> str: return os.environ.get(_ROOT_ENV, "").strip().strip("/") def uses_blob_store() -> bool: """썸네일 전용 저장소를 쓰는가.""" return bool(_blob_base() and _blob_sas()) def is_configured() -> bool: return uses_blob_store() or azure_static.is_configured() def blob_name(slug: str, ext: str) -> str: if uses_blob_store(): # base_url 에 컨테이너까지 들어 있다 — 여기서는 컨테이너 안쪽 경로만 만든다. return "/".join(part for part in (_blob_root(), THUMB_DIR, f"{slug}.{ext}") if part) prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/") return "/".join(part for part in (prefix, THUMB_DIR, f"{slug}.{ext}") if part) def public_url(slug: str, ext: str, version: int | None = None) -> str: """공개 주소.""" # 저장소가 발행 오리진 밖이면 주소도 그쪽이다. base = f"{_blob_base()}/{blob_name(slug, ext)}" if uses_blob_store() \ else f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" return f"{base}?v={version}" if version is not None else base async def _fetch(url: str) -> tuple[bytes, str, str] | None: """대표 사진을 받아온다.""" if not url.lower().startswith(("http://", "https://")): LOG.w(f"[thumbnail] 받아올 수 없는 주소다: {url[:120]}") return None try: async with httpx.AsyncClient( timeout=TIMEOUT_SEC, follow_redirects=True, max_redirects=MAX_REDIRECTS ) as client: async with client.stream("GET", url) as res: if res.status_code != 200: LOG.w(f"[thumbnail] 사진을 받지 못했다 HTTP {res.status_code}: {url[:120]}") return None content_type = (res.headers.get("content-type") or "").split(";")[0].strip().lower() ext = _EXT_BY_TYPE.get(content_type) if not ext: LOG.w(f"[thumbnail] 이미지가 아니다(content-type={content_type or '없음'}): {url[:120]}") return None # Content-Length 가 있으면 한 바이트도 받기 전에 자른다. declared = res.headers.get("content-length") if declared and declared.isdigit() and int(declared) > MAX_BYTES: LOG.w(f"[thumbnail] 사진이 너무 크다({declared} bytes): {url[:120]}") return None chunks: list[bytes] = [] size = 0 async for chunk in res.aiter_bytes(): size += len(chunk) # Content-Length 를 안 주는 서버가 있다 — 받으면서도 상한을 본다. if size > MAX_BYTES: LOG.w(f"[thumbnail] 사진이 너무 크다(>{MAX_BYTES} bytes): {url[:120]}") return None chunks.append(chunk) except httpx.HTTPError as ex: LOG.w(f"[thumbnail] 사진을 받지 못했다 {type(ex).__name__}: {ex}") return None data = b"".join(chunks) if not data: LOG.w(f"[thumbnail] 빈 응답이다: {url[:120]}") return None return data, content_type, ext def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: name = blob_name(slug, ext) settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL) if uses_blob_store(): # SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다. BlobClient.from_blob_url(f"{_blob_base()}/{name}?{_blob_sas()}").upload_blob( data, overwrite=True, content_settings=settings ) return name connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip() container_name = ( os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip() or azure_static.DEFAULT_CONTAINER ) service = BlobServiceClient.from_connection_string(connection_string) container = service.get_container_client(container_name) # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings) return name async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None: """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다.""" if not is_configured(): return None row = site_payload.primary_media(snapshot) url = str((row or {}).get("url") or "").strip() if not url: LOG.w(f"[thumbnail] 대표 사진이 없다 — 썸네일 없이 발행한다: {slug}") return None fetched = await _fetch(url) if fetched is None: return None data, content_type, ext = fetched try: name = await asyncio.to_thread(_upload_sync, slug, data, content_type, ext) except Exception as ex: # noqa: BLE001 — 어떤 이유로도 발행을 되돌리지 않는다 LOG.w(f"[thumbnail] 업로드 실패(발행은 그대로 진행): {type(ex).__name__}: {ex}") return None LOG.i(f"[thumbnail] {slug} → {name} ({len(data)} bytes · {content_type})") return public_url(slug, ext, version)