"""정적 사이트 산출물을 Azure Blob의 `$web` 컨테이너에 발행한다.""" import asyncio import mimetypes import os from pathlib import Path from azure.storage.blob import BlobServiceClient, ContentSettings DEFAULT_CONTAINER = "$web" DEFAULT_PREFIX = "ai-for-web" # 사이트별 산출물이 놓이는 디렉터리(out/s/). 나머지 루트는 전부 공용이다. SITE_ROOT_DIR = "s" class AzurePublishError(RuntimeError): pass def is_configured() -> bool: return bool(os.environ.get("AZURE_STORAGE_CONNECTION_STRING", "").strip()) def output_dir() -> Path: return Path(os.environ.get("SITE_OUTPUT_DIR", "/app/solution/site/out")) def _content_type(path: Path) -> str: overrides = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".xml": "application/xml; charset=utf-8", ".txt": "text/plain; charset=utf-8", # 노래. guess_type 도 audio/mpeg 를 주지만 플랫폼마다 갈려서 못 박아 둔다 — # octet-stream 으로 올라가면 브라우저가 재생 대신 내려받기로 처리한다. ".mp3": "audio/mpeg", } return overrides.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] or "application/octet-stream" def _cache_control(path: Path) -> str: head = path.parts[0] if path.parts else "" # 번들은 파일명에 해시가 박혀 있다 — 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다. if head == "assets": return "public, max-age=31536000, immutable" # 폰트는 이름이 고정이라 immutable 은 못 쓰지만(교체하면 갱신되어야 한다) 거의 안 바뀐다. # 60초로 두면 방문자가 수 MB 짜리 폰트를 계속 다시 받는다. if head == "fonts": return "public, max-age=604800" # 노래 파일명은 song_id(UUID)다 — 곡이 바뀌면 이름도 바뀌므로 영구 캐시가 안전하다. # 1MB 남짓한 파일을 60초마다 다시 받게 두면 헤더 버튼 하나가 트래픽을 먹는다. if path.suffix.lower() == ".mp3": return "public, max-age=31536000, immutable" # HTML · robots.txt · sitemap.xml — 발행하면 곧바로 반영되어야 한다. return "public, max-age=60, must-revalidate" def _upload_file(container, root: Path, file: Path, prefix: str) -> str: relative_name = file.relative_to(root).as_posix() blob_name = f"{prefix}/{relative_name}" if prefix else relative_name with file.open("rb") as stream: container.upload_blob( name=blob_name, data=stream, overwrite=True, content_settings=ContentSettings(content_type=_content_type(file)), cache_control=_cache_control(Path(relative_name)), ) return blob_name def _upload_tree(container, root: Path, relative_root: str, prefix: str) -> set[str]: base = root / relative_root if not base.is_dir(): raise AzurePublishError(f"정적 산출물 디렉터리가 없습니다: {base}") return { _upload_file(container, root, file, prefix) for file in base.rglob("*") if file.is_file() and not file.name.startswith(".") } def _upload_shared(container, root: Path, prefix: str) -> set[str]: """out/ 루트의 공용 산출물 — 사이트별 파일(`s/` 아래)을 뺀 전부. ★ 왜 이게 필요한가: 예전에는 `assets/` 만 올렸다. 그래서 프리렌더가 굽는 루트 `robots.txt` 와 사이트맵 인덱스(`sitemap.xml`)가 **한 번도 올라간 적이 없다**. 크롤러는 robots.txt 를 오리진 루트에서만 읽으므로(RFC 9309), AI 크롤러 명시 허용도 `Sitemap:` 지시도 전달되지 않았다 — 사이트맵을 굽기만 하고 그 존재를 알릴 방법이 없었으니 크롤러가 새 사이트를 찾아올 경로 자체가 없었다. ★ 왜 이름을 하나씩 적지 않고 `s/` 만 빼는가: 루트에 무엇이 놓이는지는 프리렌더가 정한다(`writeSharedAssets` 가 `site/public/` 을 통째로 루트에 복사한다 — 폰트를 넣으면 폰트가 는다). 여기에 파일 목록을 두면 나중에 늘어난 파일이 조용히 빠진다. 지금 고치는 버그가 정확히 그것이므로 같은 모양을 다시 만들지 않는다. """ if not root.is_dir(): raise AzurePublishError(f"정적 산출물 디렉터리가 없습니다: {root}") uploaded: set[str] = set() for entry in sorted(root.iterdir()): # `s/` 는 사이트별 디렉터리다 — 발행한 사이트 하나만 따로 올린다. # (여기서 함께 올리면 한 명이 발행할 때마다 전체 사이트를 다시 올리게 된다.) if entry.name.startswith(".") or entry.name in {SITE_ROOT_DIR, "versions"}: continue if entry.is_dir(): uploaded |= _upload_tree(container, root, entry.name, prefix) elif entry.is_file(): uploaded.add(_upload_file(container, root, entry, prefix)) return uploaded def _remove_stale_site_files(container, site_prefix: str, current: set[str]) -> int: stale = [blob.name for blob in container.list_blobs(name_starts_with=f"{site_prefix}/") if blob.name not in current] for blob_name in stale: container.delete_blob(blob_name) return len(stale) def _publish_sync(slug: str) -> dict: connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip() container_name = os.environ.get("AZURE_STORAGE_CONTAINER", DEFAULT_CONTAINER).strip() or DEFAULT_CONTAINER prefix = os.environ.get("AZURE_STORAGE_PREFIX", DEFAULT_PREFIX).strip().strip("/") root = output_dir() try: service = BlobServiceClient.from_connection_string(connection_string) container = service.get_container_client(container_name) # 공용 산출물은 덮어써도 안전하다(번들은 해시 파일이고, robots·사이트맵은 매 발행마다 # 프리렌더가 현재 발행본 전체를 보고 다시 쓴다). 사이트 경로만 현재 발행본으로 교체한다. shared = _upload_shared(container, root, prefix) site = _upload_tree(container, root, f"{SITE_ROOT_DIR}/{slug}", prefix) site_prefix = "/".join(part for part in (prefix, SITE_ROOT_DIR, slug) if part) removed = _remove_stale_site_files(container, site_prefix, site) return { "container": container_name, "prefix": prefix, "shared_files": len(shared), "site_files": len(site), "removed_stale": removed, } except AzurePublishError: raise except Exception as ex: raise AzurePublishError(f"Azure 정적 파일 업로드 실패: {type(ex).__name__}: {ex}") from ex async def publish(slug: str) -> dict | None: """설정된 경우에만 업로드한다. SDK의 동기 I/O는 별도 스레드에서 실행한다.""" if not is_configured(): return None return await asyncio.to_thread(_publish_sync, slug)