"""Shared OSS storage service for API and Worker.""" import base64 import datetime as dt import hashlib import hmac import json import logging import os from typing import Optional from urllib.parse import urlparse try: import oss2 except ImportError: # pragma: no cover oss2 = None from packages.shared.config import get_shared_settings logger = logging.getLogger(__name__) class SharedStorageService: """Shared OSS storage service.""" def __init__(self): settings = get_shared_settings() self.bucket_name = settings.oss_bucket_name self.public_url = f"https://{settings.oss_bucket_name}.{settings.oss_endpoint}" self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files") self.bucket = None if settings.oss_access_key_id and settings.oss_access_key_secret: if oss2 is not None: auth = oss2.Auth( settings.oss_access_key_id, settings.oss_access_key_secret, ) self.bucket = oss2.Bucket( auth, settings.oss_endpoint, settings.oss_bucket_name, ) self.access_key_id = settings.oss_access_key_id self.access_key_secret = settings.oss_access_key_secret self.endpoint = settings.oss_endpoint def _is_local_generated_url(self, storage_key_or_url: str) -> bool: parsed = urlparse(storage_key_or_url) path = parsed.path if parsed.scheme else storage_key_or_url return path.startswith(f"{self.local_url_prefix}/") def upload_file( self, file_or_path, storage_key: str, content_type: str = "application/octet-stream", ) -> str: """Upload file to OSS.""" if self.bucket is None: raise RuntimeError("OSS storage is not configured") try: if isinstance(file_or_path, str): self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type}) else: file_or_path.seek(0) self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type}) return f"{self.public_url}/{storage_key}" except Exception as e: raise Exception(f"Failed to upload file to OSS: {e}") def get_url(self, storage_key: str) -> str: """Get public URL for a file.""" return f"{self.public_url}/{storage_key}" def download_file(self, storage_key: str, local_path: str): """Download file from OSS to local path.""" if self.bucket is None: raise RuntimeError("OSS storage is not configured") try: os.makedirs(os.path.dirname(local_path), exist_ok=True) self.bucket.get_object_to_file(storage_key, local_path) except Exception as e: raise Exception(f"Failed to download file from OSS: {e}") def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str: """Get signed download URL.""" if self.bucket is None: if self._is_local_generated_url(storage_key_or_url): return storage_key_or_url return self.get_url(self._normalize_storage_key(storage_key_or_url)) storage_key = self._normalize_storage_key(storage_key_or_url) try: return self.bucket.sign_url("GET", storage_key, expires_seconds) except Exception: return self.get_url(storage_key) def _normalize_storage_key(self, storage_key_or_url: str) -> str: """Extract storage key from URL.""" if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"): parsed = urlparse(storage_key_or_url) return parsed.path.lstrip("/") return storage_key_or_url.lstrip("/") def create_direct_upload_post( self, storage_key: str, content_type: str, max_size_bytes: int, expires_seconds: int, ) -> dict[str, object]: """Create browser direct upload POST form.""" if not self.access_key_id or not self.access_key_secret: raise RuntimeError("OSS storage is not configured") normalized_key = self._normalize_storage_key(storage_key) if not normalized_key.startswith("uploads/"): raise ValueError("direct upload key must be under uploads/") expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime( "%Y-%m-%dT%H:%M:%S.000Z" ) policy = { "expiration": expiration, "conditions": [ {"bucket": self.bucket_name}, {"key": normalized_key}, ["content-length-range", 1, max_size_bytes], ["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""], ], } encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii") signature = base64.b64encode( hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest() ).decode("ascii") return { "url": self.public_url, "method": "POST", "storage_key": normalized_key, "expires_at": expiration, "fields": { "key": normalized_key, "OSSAccessKeyId": self.access_key_id, "policy": encoded_policy, "Signature": signature, "success_action_status": "201", "Content-Type": content_type, }, } def delete_file(self, storage_key: str): """Delete file from OSS.""" if self.bucket is None: return try: self.bucket.delete_object(storage_key) except Exception as error: logger.warning("Failed to delete file from OSS", extra={"storage_key": storage_key, "error": str(error)}) def file_exists(self, storage_key: str) -> bool: """Check if file exists.""" if self.bucket is None: return False return self.bucket.object_exists(storage_key) _storage_service: Optional[SharedStorageService] = None def get_shared_storage_service() -> SharedStorageService: """Get shared storage service instance (global singleton).""" global _storage_service if _storage_service is None: _storage_service = SharedStorageService() return _storage_service # Backward compatibility alias def get_storage_service() -> SharedStorageService: """Backward compatibility: returns shared storage service.""" return get_shared_storage_service()