Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| """ | |
| Firebase Cloud Storage helper for LarShield / SentinelScan. | |
| Uploads organization logos and scan report PDFs to Firebase Storage | |
| and returns public/signed download URLs. | |
| Env vars required: | |
| FIREBASE_CREDENTIALS — path to serviceAccountKey.json OR base64-encoded JSON OR JSON string | |
| FIREBASE_STORAGE_BUCKET — e.g. your-project.appspot.com or gs://your-project.appspot.com | |
| """ | |
| import os | |
| import io | |
| import base64 | |
| import json | |
| import logging | |
| import urllib.parse | |
| from datetime import timedelta | |
| from typing import Optional, Union, Any | |
| try: | |
| import firebase_admin | |
| from firebase_admin import credentials, storage | |
| HAS_FIREBASE = True | |
| except ImportError: | |
| firebase_admin = None # type: ignore | |
| credentials = None # type: ignore | |
| storage = None # type: ignore | |
| HAS_FIREBASE = False | |
| logger = logging.getLogger(__name__) | |
| # Firebase references — lazily initialized | |
| _app: Any = None | |
| _bucket: Any = None | |
| _initialized: bool = False | |
| def _get_credentials_path() -> Optional[str]: | |
| """Retrieve Firebase credentials path/string from environment.""" | |
| creds = os.getenv("FIREBASE_CREDENTIALS") | |
| return creds.strip() if creds else None | |
| def _get_bucket_name() -> Optional[str]: | |
| """Retrieve and sanitize Firebase storage bucket name from environment.""" | |
| raw_bucket = os.getenv("FIREBASE_STORAGE_BUCKET") | |
| if not raw_bucket: | |
| return None | |
| # Sanitize prefix (gs:// or https://) and trailing slashes | |
| clean_bucket = raw_bucket.strip() | |
| for prefix in ("gs://", "https://", "http://"): | |
| if clean_bucket.lower().startswith(prefix): | |
| clean_bucket = clean_bucket[len(prefix):] | |
| return clean_bucket.split('/')[0].strip() | |
| def init_firebase(force_reinit: bool = False) -> bool: | |
| """ | |
| Initialize the Firebase Admin SDK with service-account credentials. | |
| Safe to call multiple times — only initializes once unless force_reinit=True. | |
| Returns True if initialization succeeded, False otherwise. | |
| """ | |
| global _app, _bucket, _initialized | |
| if _initialized and not force_reinit and _bucket is not None: | |
| return True | |
| if not HAS_FIREBASE or firebase_admin is None or credentials is None or storage is None: | |
| logger.warning( | |
| "[Firebase] firebase_admin package is not installed. " | |
| "File uploads will fall back to local disk." | |
| ) | |
| _initialized = True | |
| return False | |
| creds_path = _get_credentials_path() | |
| bucket_name = _get_bucket_name() | |
| if not creds_path or not bucket_name: | |
| logger.warning( | |
| "[Firebase] FIREBASE_CREDENTIALS or FIREBASE_STORAGE_BUCKET not set. " | |
| "File uploads will fall back to local disk." | |
| ) | |
| _initialized = True | |
| return False | |
| try: | |
| # Parse credentials from file, raw JSON string, or base64 JSON | |
| cred = None | |
| if os.path.isfile(creds_path): | |
| cred = credentials.Certificate(creds_path) | |
| elif creds_path.startswith('{'): | |
| cred = credentials.Certificate(json.loads(creds_path)) | |
| else: | |
| try: | |
| # Attempt base64 decode | |
| creds_json = base64.b64decode(creds_path).decode("utf-8") | |
| creds_dict = json.loads(creds_json) | |
| cred = credentials.Certificate(creds_dict) | |
| except Exception: | |
| # Fallback to raw JSON load | |
| creds_dict = json.loads(creds_path) | |
| cred = credentials.Certificate(creds_dict) | |
| # Initialize or retrieve Firebase Admin app instance | |
| if not firebase_admin._apps: | |
| _app = firebase_admin.initialize_app(cred, {"storageBucket": bucket_name}) | |
| else: | |
| try: | |
| _app = firebase_admin.get_app() | |
| except ValueError: | |
| _app = firebase_admin.initialize_app(cred, {"storageBucket": bucket_name}) | |
| # Always bind to the specific bucket name requested | |
| if _app: | |
| _bucket = storage.bucket(name=bucket_name, app=_app) | |
| else: | |
| _bucket = storage.bucket(name=bucket_name) | |
| _initialized = True | |
| logger.info(f"[Firebase] Initialized successfully. Bucket: {bucket_name}") | |
| return True | |
| except Exception as e: | |
| logger.error(f"[Firebase] Initialization failed: {e}") | |
| _initialized = True | |
| return False | |
| def is_available() -> bool: | |
| """ | |
| Check if Firebase Storage is ready to use. | |
| Retries initialization if environment variables become available later. | |
| """ | |
| global _initialized | |
| if not _initialized or _bucket is None: | |
| # Retry initialization if credentials are now present in environment | |
| if _get_credentials_path() and _get_bucket_name(): | |
| return init_firebase(force_reinit=True) | |
| if not _initialized: | |
| init_firebase() | |
| return _bucket is not None | |
| def _build_fallback_url(destination_blob: str) -> str: | |
| """Generate public media download URL for Uniform Bucket-Level Access buckets.""" | |
| clean_blob = destination_blob.lstrip('/') | |
| encoded_blob = urllib.parse.quote(clean_blob, safe='') | |
| bucket_name = _bucket.name if _bucket and hasattr(_bucket, 'name') else _get_bucket_name() or "storage" | |
| return f"https://firebasestorage.googleapis.com/v0/b/{bucket_name}/o/{encoded_blob}?alt=media" | |
| def _get_public_or_fallback_url(blob: Any, destination_blob: str) -> str: | |
| """Extract public URL or fallback to media download link.""" | |
| url = None | |
| try: | |
| blob.make_public() | |
| url = getattr(blob, 'public_url', None) | |
| except Exception as pub_err: | |
| logger.debug(f"[Firebase] make_public skipped (Uniform Bucket Access active): {pub_err}") | |
| if not url: | |
| url = _build_fallback_url(destination_blob) | |
| return url | |
| def upload_bytes( | |
| data: bytes, | |
| content_type: str, | |
| destination_blob: str, | |
| ) -> Optional[str]: | |
| """ | |
| Upload raw bytes to Firebase Storage. | |
| Args: | |
| data: File content as bytes. | |
| content_type: MIME type (e.g. "image/png", "application/pdf"). | |
| destination_blob: Full blob path (e.g. "logos/org123/logo.png"). | |
| Returns: | |
| Public download URL on success, None on failure. | |
| """ | |
| if not is_available(): | |
| logger.warning("[Firebase] Storage not available. Upload skipped.") | |
| return None | |
| if not data: | |
| logger.warning(f"[Firebase] Empty data bytes provided for {destination_blob}.") | |
| return None | |
| try: | |
| blob = _bucket.blob(destination_blob) | |
| blob.upload_from_string(data, content_type=content_type) | |
| url = _get_public_or_fallback_url(blob, destination_blob) | |
| logger.info(f"[Firebase] Uploaded {destination_blob} ({len(data)} bytes)") | |
| return url | |
| except Exception as e: | |
| logger.error(f"[Firebase] Upload failed for {destination_blob}: {e}") | |
| return None | |
| def upload_fileobj( | |
| fileobj: Union[io.BytesIO, io.BufferedIOBase, Any], | |
| content_type: str, | |
| destination_blob: str, | |
| ) -> Optional[str]: | |
| """ | |
| Upload a file-like object to Firebase Storage. | |
| Args: | |
| fileobj: A BytesIO or file stream with the file data. | |
| content_type: MIME type. | |
| destination_blob: Full blob path. | |
| Returns: | |
| Public download URL on success, None on failure. | |
| """ | |
| if not is_available(): | |
| return None | |
| try: | |
| blob = _bucket.blob(destination_blob) | |
| try: | |
| fileobj.seek(0) | |
| except Exception: | |
| pass | |
| blob.upload_from_file(fileobj, content_type=content_type) | |
| url = _get_public_or_fallback_url(blob, destination_blob) | |
| logger.info(f"[Firebase] Uploaded fileobj to {destination_blob}") | |
| return url | |
| except Exception as e: | |
| logger.error(f"[Firebase] Upload failed for {destination_blob}: {e}") | |
| return None | |
| def download_bytes(destination_blob: str) -> Optional[bytes]: | |
| """ | |
| Download raw bytes from Firebase Storage. | |
| Args: | |
| destination_blob: Full blob path. | |
| Returns: | |
| File contents as bytes on success, None on failure. | |
| """ | |
| if not is_available(): | |
| return None | |
| try: | |
| blob = _bucket.blob(destination_blob) | |
| if not blob.exists(): | |
| logger.warning(f"[Firebase] Blob not found: {destination_blob}") | |
| return None | |
| data = blob.download_as_bytes() | |
| logger.info(f"[Firebase] Downloaded {destination_blob} ({len(data)} bytes)") | |
| return data | |
| except Exception as e: | |
| logger.error(f"[Firebase] Download failed for {destination_blob}: {e}") | |
| return None | |
| def blob_exists(destination_blob: str) -> bool: | |
| """Check if a file exists in Firebase Storage.""" | |
| if not is_available(): | |
| return False | |
| try: | |
| blob = _bucket.blob(destination_blob) | |
| return bool(blob.exists()) | |
| except Exception as e: | |
| logger.error(f"[Firebase] Exists check failed for {destination_blob}: {e}") | |
| return False | |
| def delete_blob(destination_blob: str) -> bool: | |
| """Delete a file from Firebase Storage.""" | |
| if not is_available(): | |
| return False | |
| try: | |
| blob = _bucket.blob(destination_blob) | |
| if blob.exists(): | |
| blob.delete() | |
| logger.info(f"[Firebase] Deleted {destination_blob}") | |
| return True | |
| logger.warning(f"[Firebase] Delete skipped, blob does not exist: {destination_blob}") | |
| return False | |
| except Exception as e: | |
| logger.error(f"[Firebase] Delete failed for {destination_blob}: {e}") | |
| return False | |
| def get_blob_url(blob_name: str) -> Optional[str]: | |
| """Return the public URL for an existing blob (without uploading).""" | |
| if not is_available(): | |
| return None | |
| try: | |
| blob = _bucket.blob(blob_name) | |
| if hasattr(blob, 'public_url') and blob.public_url: | |
| return blob.public_url | |
| return _build_fallback_url(blob_name) | |
| except Exception: | |
| return _build_fallback_url(blob_name) | |
| def get_signed_url(destination_blob: str, expiration_minutes: int = 60) -> Optional[str]: | |
| """ | |
| Generate a temporary signed download URL for private files. | |
| Args: | |
| destination_blob: Full blob path. | |
| expiration_minutes: Expiration time in minutes (default: 60). | |
| Returns: | |
| Signed URL string on success, None on failure. | |
| """ | |
| if not is_available(): | |
| return None | |
| try: | |
| blob = _bucket.blob(destination_blob) | |
| signed_url = blob.generate_signed_url( | |
| expiration=timedelta(minutes=expiration_minutes), | |
| method='GET' | |
| ) | |
| return signed_url | |
| except Exception as e: | |
| logger.error(f"[Firebase] Generating signed URL failed for {destination_blob}: {e}") | |
| return None | |