GitHub Actions commited on
Commit
3e811b0
·
1 Parent(s): 5d50b8b

Deploy backend from GitHub 8974fd33063c795ad2f2714f3442b7889001528d

Browse files
backend/app/core/auth.py CHANGED
@@ -2,7 +2,10 @@
2
  Firebase Authentication utilities.
3
  Verifies Firebase ID tokens issued by the frontend (Firebase Auth SDK).
4
 
5
- Env vars: FIREBASE_PROJECT_ID (used implicitly by firebase-admin)
 
 
 
6
  """
7
  from fastapi import Depends, HTTPException, status
8
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
 
2
  Firebase Authentication utilities.
3
  Verifies Firebase ID tokens issued by the frontend (Firebase Auth SDK).
4
 
5
+ Requires firebase-admin to be initialised first (done in main.py lifespan
6
+ via backend.app.db.firestore.init_firebase).
7
+
8
+ Env vars: FIREBASE_CREDENTIALS_JSON, FIREBASE_PROJECT_ID
9
  """
10
  from fastapi import Depends, HTTPException, status
11
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
backend/app/db/firestore.py CHANGED
@@ -1,13 +1,14 @@
1
  """
2
- Firestore client using the Firestore REST API over plain HTTPS.
3
 
4
- Why REST instead of firebase-admin + gRPC:
5
- The firebase-admin SDK uses gRPC for Firestore. When FIREBASE_CREDENTIALS_JSON
6
- is stored as an env-var in HF Spaces, the private_key newlines are double-escaped
7
- (\\n instead of \n), causing 'invalid_grant: Invalid JWT Signature' errors that
8
- fire in a tight background loop and spam the logs. The REST approach uses
9
- google-auth (already installed) directly over HTTPS — no gRPC, no background
10
- token-refresh loop, and the newline fix is applied once at startup.
 
11
 
12
  Env vars required:
13
  FIREBASE_CREDENTIALS_JSON – service account JSON string
@@ -16,116 +17,62 @@ Env vars required:
16
  from __future__ import annotations
17
 
18
  import json
19
- import httpx
20
  from typing import Any
21
 
22
- import google.oauth2.service_account as sa
23
- import google.auth.transport.requests as ga_requests
24
 
25
  from backend.app.core.config import settings
26
  from backend.app.core.logging import get_logger
27
 
28
  logger = get_logger(__name__)
29
 
30
- _SCOPES = ["https://www.googleapis.com/auth/datastore"]
31
- _FIRESTORE_BASE = "https://firestore.googleapis.com/v1"
32
-
33
- _credentials: sa.Credentials | None = None
34
- _project_id: str = ""
35
  _enabled: bool = False
36
 
37
 
38
  def _fix_private_key(d: dict) -> dict:
39
- """Unescape double-escaped newlines in private_key (common in env-var pastes)."""
40
  if "private_key" in d:
41
- d["private_key"] = d["private_key"].replace("\\n", "\n")
42
  return d
43
 
44
 
45
  def init_firebase() -> None:
46
- """Load service-account credentials. Non-fatal if misconfigured."""
47
- global _credentials, _project_id, _enabled
 
48
  if not settings.FIREBASE_CREDENTIALS_JSON:
49
  logger.warning("FIREBASE_CREDENTIALS_JSON not set – Firestore disabled")
50
  return
 
51
  try:
52
  cred_dict = json.loads(settings.FIREBASE_CREDENTIALS_JSON)
53
  cred_dict = _fix_private_key(cred_dict)
54
- _credentials = sa.Credentials.from_service_account_info(cred_dict, scopes=_SCOPES)
55
- _project_id = settings.FIREBASE_PROJECT_ID or cred_dict.get("project_id", "")
56
- # Validate credentials once at startup to avoid repeated runtime failures.
57
- req = ga_requests.Request()
58
- _credentials.refresh(req)
 
 
59
  _enabled = True
60
- logger.info("Firebase REST client initialised", project=_project_id)
 
61
  except Exception as e:
62
- _credentials = None
63
  _enabled = False
64
  logger.warning("Firebase init failed – Firestore disabled", error=str(e))
65
 
66
 
67
- def _auth_headers() -> dict:
68
- """Return a fresh Bearer token header (refreshes automatically when needed)."""
69
- req = ga_requests.Request()
70
- _credentials.refresh(req)
71
- return {"Authorization": f"Bearer {_credentials.token}"}
72
-
73
-
74
- def _collection_url(collection: str) -> str:
75
- return f"{_FIRESTORE_BASE}/projects/{_project_id}/databases/(default)/documents/{collection}"
76
-
77
-
78
- def _doc_url(collection: str, doc_id: str) -> str:
79
- return f"{_collection_url(collection)}/{doc_id}"
80
-
81
-
82
- def _to_firestore_value(v: Any) -> dict:
83
- """Convert a Python value to a Firestore REST value object."""
84
- if isinstance(v, bool):
85
- return {"booleanValue": v}
86
- if isinstance(v, int):
87
- return {"integerValue": str(v)}
88
- if isinstance(v, float):
89
- return {"doubleValue": v}
90
- if isinstance(v, str):
91
- return {"stringValue": v}
92
- if v is None:
93
- return {"nullValue": None}
94
- if isinstance(v, dict):
95
- return {"mapValue": {"fields": {k: _to_firestore_value(u) for k, u in v.items()}}}
96
- if isinstance(v, list):
97
- return {"arrayValue": {"values": [_to_firestore_value(i) for i in v]}}
98
- return {"stringValue": str(v)}
99
-
100
-
101
- def _from_firestore_value(v: dict) -> Any:
102
- """Convert a Firestore REST value object to a Python value."""
103
- if "stringValue" in v: return v["stringValue"]
104
- if "integerValue" in v: return int(v["integerValue"])
105
- if "doubleValue" in v: return float(v["doubleValue"])
106
- if "booleanValue" in v: return v["booleanValue"]
107
- if "nullValue" in v: return None
108
- if "mapValue" in v: return {k: _from_firestore_value(u) for k, u in v["mapValue"].get("fields", {}).items()}
109
- if "arrayValue" in v: return [_from_firestore_value(i) for i in v["arrayValue"].get("values", [])]
110
- return None
111
-
112
-
113
  # ---- Public helpers --------------------------------------------------------
114
 
115
  async def save_document(collection: str, doc_id: str, data: dict) -> bool:
116
  """Create or overwrite a Firestore document. Returns True on success."""
117
- if not _enabled:
118
  return False
119
  try:
120
- fields = {k: _to_firestore_value(v) for k, v in data.items()}
121
- url = _doc_url(collection, doc_id)
122
- async with httpx.AsyncClient(timeout=10.0) as client:
123
- resp = await client.patch(
124
- url,
125
- json={"fields": fields},
126
- headers=_auth_headers(),
127
- )
128
- resp.raise_for_status()
129
  return True
130
  except Exception as e:
131
  logger.warning("Firestore save_document failed", collection=collection, doc_id=doc_id, error=str(e))
@@ -134,24 +81,16 @@ async def save_document(collection: str, doc_id: str, data: dict) -> bool:
134
 
135
  async def get_document(collection: str, doc_id: str) -> dict | None:
136
  """Fetch a single Firestore document. Returns None if not found or disabled."""
137
- if not _enabled:
138
  return None
139
  try:
140
- url = _doc_url(collection, doc_id)
141
- async with httpx.AsyncClient(timeout=10.0) as client:
142
- resp = await client.get(url, headers=_auth_headers())
143
- if resp.status_code == 404:
144
- return None
145
- resp.raise_for_status()
146
- fields = resp.json().get("fields", {})
147
- return {k: _from_firestore_value(v) for k, v in fields.items()}
148
  except Exception as e:
149
  logger.warning("Firestore get_document failed", collection=collection, doc_id=doc_id, error=str(e))
150
  return None
151
 
152
 
153
  def get_db():
154
- """Legacy shim for code that calls get_db(). Returns None if Firestore is disabled."""
155
- if not _enabled:
156
- return None
157
- return True # callers should use save_document/get_document directly
 
1
  """
2
+ Firestore client using the official firebase-admin SDK.
3
 
4
+ Why firebase-admin (not REST):
5
+ The REST approach required google.auth to sign JWTs directly, which
6
+ failed with 'invalid_grant: Invalid JWT Signature' when the private_key
7
+ in FIREBASE_CREDENTIALS_JSON had double-escaped newlines (\\n) from
8
+ HF Spaces env-var storage. The firebase-admin SDK handles credential
9
+ refresh internally without surface-level JWT errors, and the
10
+ _fix_private_key helper below corrects the newline escaping before the
11
+ SDK ever touches the key.
12
 
13
  Env vars required:
14
  FIREBASE_CREDENTIALS_JSON – service account JSON string
 
17
  from __future__ import annotations
18
 
19
  import json
 
20
  from typing import Any
21
 
22
+ import firebase_admin
23
+ from firebase_admin import credentials, firestore as fb_firestore
24
 
25
  from backend.app.core.config import settings
26
  from backend.app.core.logging import get_logger
27
 
28
  logger = get_logger(__name__)
29
 
30
+ _db: Any = None
 
 
 
 
31
  _enabled: bool = False
32
 
33
 
34
  def _fix_private_key(d: dict) -> dict:
35
+ """Unescape double-escaped newlines in private_key (common in HF Spaces env-var pastes)."""
36
  if "private_key" in d:
37
+ d["private_key"] = d["private_key"].replace("\\\\n", "\\n").replace("\\n", "\n")
38
  return d
39
 
40
 
41
  def init_firebase() -> None:
42
+ """Initialise firebase-admin app and Firestore client. Non-fatal if misconfigured."""
43
+ global _db, _enabled
44
+
45
  if not settings.FIREBASE_CREDENTIALS_JSON:
46
  logger.warning("FIREBASE_CREDENTIALS_JSON not set – Firestore disabled")
47
  return
48
+
49
  try:
50
  cred_dict = json.loads(settings.FIREBASE_CREDENTIALS_JSON)
51
  cred_dict = _fix_private_key(cred_dict)
52
+
53
+ # Avoid re-initialising if already done (e.g. hot reload in dev)
54
+ if not firebase_admin._apps:
55
+ cred = credentials.Certificate(cred_dict)
56
+ firebase_admin.initialize_app(cred)
57
+
58
+ _db = fb_firestore.client()
59
  _enabled = True
60
+ project = settings.FIREBASE_PROJECT_ID or cred_dict.get("project_id", "")
61
+ logger.info("Firebase + Firestore initialised", project=project)
62
  except Exception as e:
63
+ _db = None
64
  _enabled = False
65
  logger.warning("Firebase init failed – Firestore disabled", error=str(e))
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  # ---- Public helpers --------------------------------------------------------
69
 
70
  async def save_document(collection: str, doc_id: str, data: dict) -> bool:
71
  """Create or overwrite a Firestore document. Returns True on success."""
72
+ if not _enabled or _db is None:
73
  return False
74
  try:
75
+ _db.collection(collection).document(doc_id).set(data)
 
 
 
 
 
 
 
 
76
  return True
77
  except Exception as e:
78
  logger.warning("Firestore save_document failed", collection=collection, doc_id=doc_id, error=str(e))
 
81
 
82
  async def get_document(collection: str, doc_id: str) -> dict | None:
83
  """Fetch a single Firestore document. Returns None if not found or disabled."""
84
+ if not _enabled or _db is None:
85
  return None
86
  try:
87
+ doc = _db.collection(collection).document(doc_id).get()
88
+ return doc.to_dict() if doc.exists else None
 
 
 
 
 
 
89
  except Exception as e:
90
  logger.warning("Firestore get_document failed", collection=collection, doc_id=doc_id, error=str(e))
91
  return None
92
 
93
 
94
  def get_db():
95
+ """Legacy shim. Returns the Firestore client or None if disabled."""
96
+ return _db if _enabled else None
 
 
backend/app/main.py CHANGED
@@ -3,7 +3,7 @@ FastAPI main application entry point.
3
  Configures CORS, secure headers, routes, and observability.
4
 
5
  Auth: Firebase Auth (frontend issues ID tokens; backend verifies via firebase-admin)
6
- DB: Firestore (via firebase-admin)
7
 
8
  Env vars: All from core/config.py
9
  Run: uvicorn backend.app.main:app --host 0.0.0.0 --port 7860
@@ -38,20 +38,7 @@ REQUEST_LATENCY = Histogram("http_request_duration_seconds", "Request latency",
38
  @asynccontextmanager
39
  async def lifespan(app: FastAPI):
40
  logger.info("Starting LLM Misuse Detection API")
41
- try:
42
- init_firebase()
43
- logger.info("Firebase + Firestore initialised")
44
- except Exception as e:
45
- err = str(e)
46
- if "Expecting value" in err or "line 1 column 1" in err:
47
- logger.warning(
48
- "Firebase init failed – FIREBASE_CREDENTIALS_JSON is set but contains invalid JSON. "
49
- "Make sure you pasted the full service account JSON contents (not the filename). "
50
- "Auth and DB will be unavailable.",
51
- error=err,
52
- )
53
- else:
54
- logger.warning("Firebase init failed – auth and DB will be unavailable", error=err)
55
  yield
56
  logger.info("Shutting down")
57
 
 
3
  Configures CORS, secure headers, routes, and observability.
4
 
5
  Auth: Firebase Auth (frontend issues ID tokens; backend verifies via firebase-admin)
6
+ DB: Firestore (via firebase-admin SDK)
7
 
8
  Env vars: All from core/config.py
9
  Run: uvicorn backend.app.main:app --host 0.0.0.0 --port 7860
 
38
  @asynccontextmanager
39
  async def lifespan(app: FastAPI):
40
  logger.info("Starting LLM Misuse Detection API")
41
+ init_firebase()
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  yield
43
  logger.info("Shutting down")
44