larxius commited on
Commit
a566bcf
·
verified ·
1 Parent(s): a0a625f

Update backend/utils/firebase_storage.py

Browse files
Files changed (1) hide show
  1. backend/utils/firebase_storage.py +154 -36
backend/utils/firebase_storage.py CHANGED
@@ -1,12 +1,12 @@
1
  """
2
- Firebase Cloud Storage helper for SentinelScan.
3
 
4
  Uploads organization logos and scan report PDFs to Firebase Storage
5
  and returns public/signed download URLs.
6
 
7
  Env vars required:
8
- FIREBASE_CREDENTIALS — path to serviceAccountKey.json OR base64-encoded JSON
9
- FIREBASE_STORAGE_BUCKET — e.g. your-project.appspot.com
10
  """
11
 
12
  import os
@@ -14,33 +14,45 @@ import io
14
  import base64
15
  import json
16
  import logging
17
- from typing import Optional
 
 
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
  # Firebase references — lazily initialized
22
  _app = None
23
- _bucket = None
24
  _initialized = False
25
 
26
 
27
  def _get_credentials_path() -> Optional[str]:
 
28
  return os.getenv("FIREBASE_CREDENTIALS")
29
 
30
 
31
  def _get_bucket_name() -> Optional[str]:
32
- return os.getenv("FIREBASE_STORAGE_BUCKET")
 
 
 
 
 
 
 
 
 
33
 
34
 
35
- def init_firebase() -> bool:
36
  """
37
  Initialize the Firebase Admin SDK with service-account credentials.
38
- Safe to call multiple times — only initializes once.
39
  Returns True if initialization succeeded, False otherwise.
40
  """
41
  global _app, _bucket, _initialized
42
 
43
- if _initialized:
44
  return _bucket is not None
45
 
46
  creds_path = _get_credentials_path()
@@ -59,24 +71,37 @@ def init_firebase() -> bool:
59
  from firebase_admin import credentials, storage
60
 
61
  if not firebase_admin._apps:
62
- # Support both file path, base64-encoded JSON, and direct JSON string
 
63
  if os.path.isfile(creds_path):
64
  cred = credentials.Certificate(creds_path)
65
  else:
66
  try:
 
67
  creds_json = base64.b64decode(creds_path).decode("utf-8")
68
  creds_dict = json.loads(creds_json)
69
  except Exception:
 
70
  creds_dict = json.loads(creds_path)
71
  cred = credentials.Certificate(creds_dict)
72
 
73
  _app = firebase_admin.initialize_app(cred, {"storageBucket": bucket_name})
 
 
74
 
75
- _bucket = storage.bucket()
 
76
  _initialized = True
77
- logger.info(f"[Firebase] Initialized. Bucket: {bucket_name}")
78
  return True
79
 
 
 
 
 
 
 
 
80
  except Exception as e:
81
  logger.error(f"[Firebase] Initialization failed: {e}")
82
  _initialized = True
@@ -84,12 +109,27 @@ def init_firebase() -> bool:
84
 
85
 
86
  def is_available() -> bool:
87
- """Check if Firebase Storage is ready to use."""
88
- if not _initialized:
89
- init_firebase()
 
 
 
 
 
 
 
 
90
  return _bucket is not None
91
 
92
 
 
 
 
 
 
 
 
93
  def upload_bytes(
94
  data: bytes,
95
  content_type: str,
@@ -107,21 +147,26 @@ def upload_bytes(
107
  Public download URL on success, None on failure.
108
  """
109
  if not is_available():
110
- logger.error("[Firebase] Storage not available. Upload skipped.")
 
 
 
 
111
  return None
112
 
113
  try:
114
  blob = _bucket.blob(destination_blob)
115
  blob.upload_from_string(data, content_type=content_type)
 
 
116
  try:
117
  blob.make_public()
118
- url = blob.public_url
119
  except Exception as pub_err:
120
- logger.warning(f"[Firebase] make_public failed (Uniform Bucket-Level Access active), generating public media URL: {pub_err}")
121
- import urllib.parse
122
- encoded_blob = urllib.parse.quote(destination_blob, safe='')
123
- bucket_name = _bucket.name if _bucket else "storage"
124
- url = f"https://firebasestorage.googleapis.com/v0/b/{bucket_name}/o/{encoded_blob}?alt=media"
125
 
126
  logger.info(f"[Firebase] Uploaded {destination_blob} ({len(data)} bytes)")
127
  return url
@@ -132,7 +177,7 @@ def upload_bytes(
132
 
133
 
134
  def upload_fileobj(
135
- fileobj: io.BytesIO,
136
  content_type: str,
137
  destination_blob: str,
138
  ) -> Optional[str]:
@@ -140,7 +185,7 @@ def upload_fileobj(
140
  Upload a file-like object to Firebase Storage.
141
 
142
  Args:
143
- fileobj: A BytesIO (or similar) with the file data.
144
  content_type: MIME type.
145
  destination_blob: Full blob path.
146
 
@@ -152,19 +197,24 @@ def upload_fileobj(
152
 
153
  try:
154
  blob = _bucket.blob(destination_blob)
155
- fileobj.seek(0)
 
 
 
 
156
  blob.upload_from_file(fileobj, content_type=content_type)
 
 
157
  try:
158
  blob.make_public()
159
- url = blob.public_url
160
  except Exception as pub_err:
161
- logger.warning(f"[Firebase] make_public failed (Uniform Bucket-Level Access active), generating public media URL: {pub_err}")
162
- import urllib.parse
163
- encoded_blob = urllib.parse.quote(destination_blob, safe='')
164
- bucket_name = _bucket.name if _bucket else "storage"
165
- url = f"https://firebasestorage.googleapis.com/v0/b/{bucket_name}/o/{encoded_blob}?alt=media"
166
 
167
- logger.info(f"[Firebase] Uploaded {destination_blob}")
 
 
 
168
  return url
169
 
170
  except Exception as e:
@@ -172,6 +222,44 @@ def upload_fileobj(
172
  return None
173
 
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  def delete_blob(destination_blob: str) -> bool:
176
  """Delete a file from Firebase Storage."""
177
  if not is_available():
@@ -179,9 +267,12 @@ def delete_blob(destination_blob: str) -> bool:
179
 
180
  try:
181
  blob = _bucket.blob(destination_blob)
182
- blob.delete()
183
- logger.info(f"[Firebase] Deleted {destination_blob}")
184
- return True
 
 
 
185
 
186
  except Exception as e:
187
  logger.error(f"[Firebase] Delete failed for {destination_blob}: {e}")
@@ -194,6 +285,33 @@ def get_blob_url(blob_name: str) -> Optional[str]:
194
  return None
195
  try:
196
  blob = _bucket.blob(blob_name)
197
- return blob.public_url
 
 
198
  except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  return None
 
1
  """
2
+ Firebase Cloud Storage helper for LarShield / SentinelScan.
3
 
4
  Uploads organization logos and scan report PDFs to Firebase Storage
5
  and returns public/signed download URLs.
6
 
7
  Env vars required:
8
+ FIREBASE_CREDENTIALS — path to serviceAccountKey.json OR base64-encoded JSON OR JSON string
9
+ FIREBASE_STORAGE_BUCKET — e.g. your-project.appspot.com or gs://your-project.appspot.com
10
  """
11
 
12
  import os
 
14
  import base64
15
  import json
16
  import logging
17
+ import urllib.parse
18
+ from datetime import timedelta
19
+ from typing import Optional, Union, Any
20
 
21
  logger = logging.getLogger(__name__)
22
 
23
  # Firebase references — lazily initialized
24
  _app = None
25
+ _bucket: Any = None
26
  _initialized = False
27
 
28
 
29
  def _get_credentials_path() -> Optional[str]:
30
+ """Retrieve Firebase credentials path/string from environment."""
31
  return os.getenv("FIREBASE_CREDENTIALS")
32
 
33
 
34
  def _get_bucket_name() -> Optional[str]:
35
+ """Retrieve and sanitize Firebase storage bucket name from environment."""
36
+ raw_bucket = os.getenv("FIREBASE_STORAGE_BUCKET")
37
+ if not raw_bucket:
38
+ return None
39
+ # Sanitize prefix (gs:// or https://) and trailing slashes
40
+ clean_bucket = raw_bucket.strip()
41
+ for prefix in ("gs://", "https://", "http://"):
42
+ if clean_bucket.lower().startswith(prefix):
43
+ clean_bucket = clean_bucket[len(prefix):]
44
+ return clean_bucket.split('/')[0].strip()
45
 
46
 
47
+ def init_firebase(force_reinit: bool = False) -> bool:
48
  """
49
  Initialize the Firebase Admin SDK with service-account credentials.
50
+ Safe to call multiple times — only initializes once unless force_reinit=True.
51
  Returns True if initialization succeeded, False otherwise.
52
  """
53
  global _app, _bucket, _initialized
54
 
55
+ if _initialized and not force_reinit:
56
  return _bucket is not None
57
 
58
  creds_path = _get_credentials_path()
 
71
  from firebase_admin import credentials, storage
72
 
73
  if not firebase_admin._apps:
74
+ # Support file path, base64-encoded JSON, and direct JSON string
75
+ cred = None
76
  if os.path.isfile(creds_path):
77
  cred = credentials.Certificate(creds_path)
78
  else:
79
  try:
80
+ # Attempt base64 decode
81
  creds_json = base64.b64decode(creds_path).decode("utf-8")
82
  creds_dict = json.loads(creds_json)
83
  except Exception:
84
+ # Fallback to direct JSON string
85
  creds_dict = json.loads(creds_path)
86
  cred = credentials.Certificate(creds_dict)
87
 
88
  _app = firebase_admin.initialize_app(cred, {"storageBucket": bucket_name})
89
+ else:
90
+ _app = firebase_admin.get_app()
91
 
92
+ # Always bind to the specific bucket name requested
93
+ _bucket = storage.bucket(name=bucket_name)
94
  _initialized = True
95
+ logger.info(f"[Firebase] Initialized successfully. Bucket: {bucket_name}")
96
  return True
97
 
98
+ except ImportError:
99
+ logger.warning(
100
+ "[Firebase] firebase_admin package is not installed. "
101
+ "File uploads will fall back to local disk."
102
+ )
103
+ _initialized = True
104
+ return False
105
  except Exception as e:
106
  logger.error(f"[Firebase] Initialization failed: {e}")
107
  _initialized = True
 
109
 
110
 
111
  def is_available() -> bool:
112
+ """
113
+ Check if Firebase Storage is ready to use.
114
+ Retries initialization if environment variables become available later.
115
+ """
116
+ global _initialized
117
+ if not _initialized or _bucket is None:
118
+ # Retry initialization if credentials are now present in environment
119
+ if _get_credentials_path() and _get_bucket_name():
120
+ return init_firebase(force_reinit=True)
121
+ if not _initialized:
122
+ init_firebase()
123
  return _bucket is not None
124
 
125
 
126
+ def _build_fallback_url(destination_blob: str) -> str:
127
+ """Generate public media download URL for Uniform Bucket-Level Access buckets."""
128
+ encoded_blob = urllib.parse.quote(destination_blob, safe='')
129
+ bucket_name = _bucket.name if _bucket and hasattr(_bucket, 'name') else _get_bucket_name() or "storage"
130
+ return f"https://firebasestorage.googleapis.com/v0/b/{bucket_name}/o/{encoded_blob}?alt=media"
131
+
132
+
133
  def upload_bytes(
134
  data: bytes,
135
  content_type: str,
 
147
  Public download URL on success, None on failure.
148
  """
149
  if not is_available():
150
+ logger.warning("[Firebase] Storage not available. Upload skipped.")
151
+ return None
152
+
153
+ if not data:
154
+ logger.warning(f"[Firebase] Empty data bytes provided for {destination_blob}.")
155
  return None
156
 
157
  try:
158
  blob = _bucket.blob(destination_blob)
159
  blob.upload_from_string(data, content_type=content_type)
160
+
161
+ url = None
162
  try:
163
  blob.make_public()
164
+ url = getattr(blob, 'public_url', None)
165
  except Exception as pub_err:
166
+ logger.debug(f"[Firebase] make_public skipped (Uniform Bucket Access active): {pub_err}")
167
+
168
+ if not url:
169
+ url = _build_fallback_url(destination_blob)
 
170
 
171
  logger.info(f"[Firebase] Uploaded {destination_blob} ({len(data)} bytes)")
172
  return url
 
177
 
178
 
179
  def upload_fileobj(
180
+ fileobj: Union[io.BytesIO, io.BufferedIOBase, Any],
181
  content_type: str,
182
  destination_blob: str,
183
  ) -> Optional[str]:
 
185
  Upload a file-like object to Firebase Storage.
186
 
187
  Args:
188
+ fileobj: A BytesIO or file stream with the file data.
189
  content_type: MIME type.
190
  destination_blob: Full blob path.
191
 
 
197
 
198
  try:
199
  blob = _bucket.blob(destination_blob)
200
+ try:
201
+ fileobj.seek(0)
202
+ except Exception:
203
+ pass
204
+
205
  blob.upload_from_file(fileobj, content_type=content_type)
206
+
207
+ url = None
208
  try:
209
  blob.make_public()
210
+ url = getattr(blob, 'public_url', None)
211
  except Exception as pub_err:
212
+ logger.debug(f"[Firebase] make_public skipped (Uniform Bucket Access active): {pub_err}")
 
 
 
 
213
 
214
+ if not url:
215
+ url = _build_fallback_url(destination_blob)
216
+
217
+ logger.info(f"[Firebase] Uploaded fileobj to {destination_blob}")
218
  return url
219
 
220
  except Exception as e:
 
222
  return None
223
 
224
 
225
+ def download_bytes(destination_blob: str) -> Optional[bytes]:
226
+ """
227
+ Download raw bytes from Firebase Storage.
228
+
229
+ Args:
230
+ destination_blob: Full blob path.
231
+
232
+ Returns:
233
+ File contents as bytes on success, None on failure.
234
+ """
235
+ if not is_available():
236
+ return None
237
+
238
+ try:
239
+ blob = _bucket.blob(destination_blob)
240
+ if not blob.exists():
241
+ logger.warning(f"[Firebase] Blob not found: {destination_blob}")
242
+ return None
243
+ data = blob.download_as_bytes()
244
+ logger.info(f"[Firebase] Downloaded {destination_blob} ({len(data)} bytes)")
245
+ return data
246
+ except Exception as e:
247
+ logger.error(f"[Firebase] Download failed for {destination_blob}: {e}")
248
+ return None
249
+
250
+
251
+ def blob_exists(destination_blob: str) -> bool:
252
+ """Check if a file exists in Firebase Storage."""
253
+ if not is_available():
254
+ return False
255
+ try:
256
+ blob = _bucket.blob(destination_blob)
257
+ return bool(blob.exists())
258
+ except Exception as e:
259
+ logger.error(f"[Firebase] Exists check failed for {destination_blob}: {e}")
260
+ return False
261
+
262
+
263
  def delete_blob(destination_blob: str) -> bool:
264
  """Delete a file from Firebase Storage."""
265
  if not is_available():
 
267
 
268
  try:
269
  blob = _bucket.blob(destination_blob)
270
+ if blob.exists():
271
+ blob.delete()
272
+ logger.info(f"[Firebase] Deleted {destination_blob}")
273
+ return True
274
+ logger.warning(f"[Firebase] Delete skipped, blob does not exist: {destination_blob}")
275
+ return False
276
 
277
  except Exception as e:
278
  logger.error(f"[Firebase] Delete failed for {destination_blob}: {e}")
 
285
  return None
286
  try:
287
  blob = _bucket.blob(blob_name)
288
+ if hasattr(blob, 'public_url') and blob.public_url:
289
+ return blob.public_url
290
+ return _build_fallback_url(blob_name)
291
  except Exception:
292
+ return _build_fallback_url(blob_name)
293
+
294
+
295
+ def get_signed_url(destination_blob: str, expiration_minutes: int = 60) -> Optional[str]:
296
+ """
297
+ Generate a temporary signed download URL for private files.
298
+
299
+ Args:
300
+ destination_blob: Full blob path.
301
+ expiration_minutes: Expiration time in minutes (default: 60).
302
+
303
+ Returns:
304
+ Signed URL string on success, None on failure.
305
+ """
306
+ if not is_available():
307
+ return None
308
+ try:
309
+ blob = _bucket.blob(destination_blob)
310
+ signed_url = blob.generate_signed_url(
311
+ expiration=timedelta(minutes=expiration_minutes),
312
+ method='GET'
313
+ )
314
+ return signed_url
315
+ except Exception as e:
316
+ logger.error(f"[Firebase] Generating signed URL failed for {destination_blob}: {e}")
317
  return None