108 lines
3.9 KiB
Python
108 lines
3.9 KiB
Python
"""Azure Blob Storage 업로드.
|
|
|
|
SAS 토큰을 붙인 URL로 PUT 한 번. 경로 규칙은 부르는 쪽이 정한다.
|
|
클라이언트를 모듈에서 공유해 커넥션 풀을 재사용한다.
|
|
"""
|
|
import mimetypes
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
import httpx
|
|
|
|
from settings import settings
|
|
|
|
BLOB_TYPE_HEADER = {"x-ms-blob-type": "BlockBlob"}
|
|
DEFAULT_CONTENT_TYPE = "application/octet-stream"
|
|
UPLOAD_TIMEOUT = httpx.Timeout(180.0, connect=10.0)
|
|
POOL_LIMITS = httpx.Limits(max_keepalive_connections=10, max_connections=20)
|
|
|
|
_client: httpx.AsyncClient | None = None
|
|
|
|
|
|
def get_client() -> httpx.AsyncClient:
|
|
global _client
|
|
if _client is None or _client.is_closed:
|
|
_client = httpx.AsyncClient(timeout=UPLOAD_TIMEOUT, limits=POOL_LIMITS)
|
|
return _client
|
|
|
|
|
|
async def close_client() -> None:
|
|
global _client
|
|
if _client is not None and not _client.is_closed:
|
|
await _client.aclose()
|
|
_client = None
|
|
|
|
|
|
def guess_content_type(path: str) -> str:
|
|
return mimetypes.guess_type(path)[0] or DEFAULT_CONTENT_TYPE
|
|
|
|
|
|
def public_url(path: str) -> str:
|
|
return f"{settings.azure_blob_base_url.rstrip('/')}/{path.lstrip('/')}"
|
|
|
|
|
|
def sas_token() -> str:
|
|
# SAS 토큰이 따옴표나 ? 로 감싸여 오는 경우가 있다
|
|
return settings.azure_blob_sas_token.strip("?'\"")
|
|
|
|
|
|
def upload_url(path: str) -> str:
|
|
return f"{public_url(path)}?{sas_token()}"
|
|
|
|
|
|
def with_sas(url: str) -> str:
|
|
"""컨테이너가 비공개면 읽기에도 토큰이 필요함
|
|
우리가 올린 URL일 때만 붙임
|
|
"""
|
|
token = sas_token()
|
|
base = settings.azure_blob_base_url.rstrip("/")
|
|
if token and base and url.startswith(base) and "?" not in url:
|
|
return f"{url}?{token}"
|
|
return url
|
|
|
|
|
|
def as_attachment(filename: str) -> str:
|
|
"""이 값이 붙은 blob은 브라우저가 열면 내려받기가 된다.
|
|
|
|
<a download>은 다른 출처에 안 먹어 완성본은 이 길로 간다.
|
|
한글 파일명은 RFC 5987 형식이라야 온전히 건너간다.
|
|
"""
|
|
return f"attachment; filename*=UTF-8''{quote(filename)}"
|
|
|
|
|
|
async def upload_bytes(data: bytes, path: str, content_type: str | None = None,
|
|
disposition: str | None = None) -> str:
|
|
"""path 예: playreel/nol_26007416/clip.mp4. 공개 URL을 돌려준다."""
|
|
if not settings.azure_blob_base_url or not settings.azure_blob_sas_token:
|
|
raise RuntimeError("AZURE_BLOB_BASE_URL / AZURE_BLOB_SAS_TOKEN 없음")
|
|
|
|
headers = {"Content-Type": content_type or guess_content_type(path), **BLOB_TYPE_HEADER}
|
|
if disposition:
|
|
# 헤더 이름이 Content-Disposition이면 이 요청 본문의 성격으로 읽힌다.
|
|
# blob 속성으로 저장되는 것은 x-ms- 쪽이다
|
|
headers["x-ms-blob-content-disposition"] = disposition
|
|
response = await get_client().put(upload_url(path), content=data, headers=headers)
|
|
if response.status_code not in (200, 201):
|
|
print(f"[blob] 업로드 실패 {response.status_code} {path}: {response.text[:300]}",
|
|
flush=True)
|
|
raise RuntimeError(f"blob 업로드 실패 {response.status_code}")
|
|
return public_url(path)
|
|
|
|
|
|
async def upload_file(file_path: Path, path: str, content_type: str | None = None) -> str:
|
|
return await upload_bytes(file_path.read_bytes(), path,
|
|
content_type or guess_content_type(file_path.name))
|
|
|
|
|
|
async def download_bytes(url: str) -> bytes:
|
|
"""upload_bytes가 돌려준 URL을 그대로 받아 내용을 가져온다.
|
|
|
|
예외 메시지에 주소를 넣지 않는다 — 실패는 잡에 기록되어 화면까지 간다.
|
|
어느 파일이었는지는 로그를 본다.
|
|
"""
|
|
response = await get_client().get(with_sas(url))
|
|
if response.status_code != 200:
|
|
print(f"[blob] 다운로드 실패 {response.status_code}: {url}", flush=True)
|
|
raise RuntimeError(f"blob 다운로드 실패 {response.status_code}")
|
|
return response.content
|