File size: 11,277 Bytes
7804693
 
 
 
3808a22
7804693
 
 
 
 
 
 
 
 
 
 
 
3808a22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d099157
 
 
 
 
 
 
 
 
 
 
 
 
 
7804693
 
 
 
 
 
 
 
 
 
 
 
9ea9ab6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7804693
 
9ea9ab6
7804693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ba42f3b
 
 
7804693
 
 
 
 
 
 
 
 
0efd223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7804693
0efd223
 
 
 
 
7804693
 
 
 
 
 
 
 
 
 
 
0efd223
 
 
 
 
 
 
 
7804693
 
 
 
 
 
 
 
 
 
 
 
 
 
0efd223
 
 
 
 
 
 
 
 
 
 
7804693
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
"""Shared infrastructure for the EvalBot platform: settings, database, auth, file storage."""
import json
import logging
import os
import re
import threading
import time
from typing import Any, Dict, List, Optional

import httpx
from fastapi import Depends, Header, HTTPException

logger = logging.getLogger("evalbot.platform")

# --------------------------------------------------------------------------------------
# Settings (all from environment / Space secrets)
# --------------------------------------------------------------------------------------
def _normalize_db_url(raw: str) -> str:
    """Forgive common copy-paste mistakes in the DATABASE_URL secret: stray spaces or newlines,
    the [ ] placeholder brackets left around the password, and unescaped symbols in the password."""
    from urllib.parse import quote, unquote

    url = re.sub(r"\s+", "", raw or "")
    m = re.match(r"^(postgres(?:ql)?://)([^:/@]+):(.*)@([^@]+)$", url)
    if not m:
        return url
    scheme, user, password, rest = m.groups()
    if password.startswith("[") and password.endswith("]"):
        password = password[1:-1]
    return f"{scheme}{user}:{quote(unquote(password), safe='')}@{rest}"


def _database_url() -> str:
    """DB_PASSWORD alone is enough: the Supabase pooler address is built from SUPABASE_URL.
    Otherwise use the full DATABASE_URL connection string."""
    from urllib.parse import quote

    password = (os.getenv("DB_PASSWORD") or "").strip()
    ref = re.match(r"https://([a-z0-9]+)\.supabase\.co", os.getenv("SUPABASE_URL", "").strip())
    if password and ref:
        host = os.getenv("DB_POOLER_HOST", "aws-1-eu-central-1.pooler.supabase.com")
        return f"postgresql://postgres.{ref.group(1)}:{quote(password, safe='')}@{host}:6543/postgres"
    return _normalize_db_url(os.getenv("DATABASE_URL", ""))


DATABASE_URL = _database_url()
SUPABASE_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "")
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
STORAGE_BUCKET = os.getenv("STORAGE_BUCKET", "evalbot")
DEV_AUTH = os.getenv("EVALBOT_DEV_AUTH", "") == "1"          # local testing only: accepts "dev:<uuid>:<email>"
LOCAL_STORAGE_DIR = os.getenv("EVALBOT_LOCAL_STORAGE", "./platform_storage")


def enabled() -> bool:
    return bool(DATABASE_URL)


_providers: Dict[str, Any] = {"at": 0.0, "value": {}}


def _auth_providers() -> Dict[str, bool]:
    """Which sign-in providers are switched on in Supabase (cached for 5 minutes)."""
    if not SUPABASE_URL or time.time() - _providers["at"] < 300:
        return _providers["value"]
    try:
        r = httpx.get(f"{SUPABASE_URL}/auth/v1/settings", headers={"apikey": SUPABASE_ANON_KEY}, timeout=5)
        ext = r.json().get("external", {}) if r.status_code == 200 else {}
        _providers["value"] = {"google": bool(ext.get("google"))}
    except Exception:
        pass
    _providers["at"] = time.time()
    return _providers["value"]


def public_config() -> Dict[str, Any]:
    return {"enabled": enabled(), "supabase_url": SUPABASE_URL, "supabase_anon_key": SUPABASE_ANON_KEY,
            "dev_auth": DEV_AUTH, "providers": _auth_providers()}


# --------------------------------------------------------------------------------------
# Database
# --------------------------------------------------------------------------------------
_pool = None
_pool_lock = threading.Lock()


def pool():
    global _pool
    if _pool is None:
        with _pool_lock:
            if _pool is None:
                from psycopg.rows import dict_row
                from psycopg_pool import ConnectionPool

                _pool = ConnectionPool(DATABASE_URL, min_size=1, max_size=5, open=True,
                                       kwargs={"row_factory": dict_row, "autocommit": True,
                                               "prepare_threshold": None})  # pgbouncer-compatible
    return _pool


def init_db() -> None:
    if not enabled():
        logger.warning("DATABASE_URL not set: platform features are disabled")
        return
    sql = open(os.path.join(os.path.dirname(__file__), "schema.sql"), encoding="utf-8").read()
    with pool().connection() as conn:
        conn.execute(sql)
    logger.info("Database schema ready")


def _adapt(params):
    from psycopg.types.json import Jsonb

    if params is None:
        return None
    if isinstance(params, dict):
        return {k: Jsonb(v) if isinstance(v, (dict, list)) else v for k, v in params.items()}
    return [Jsonb(v) if isinstance(v, (dict, list)) else v for v in params]


def q(sql: str, params=None) -> List[Dict]:
    with pool().connection() as conn:
        cur = conn.execute(sql, _adapt(params))
        return cur.fetchall() if cur.description else []


def q1(sql: str, params=None) -> Optional[Dict]:
    rows = q(sql, params)
    return rows[0] if rows else None


# --------------------------------------------------------------------------------------
# Auth (Supabase access tokens)
# --------------------------------------------------------------------------------------
_token_cache: Dict[str, tuple] = {}


def _verify_token(token: str) -> Dict:
    hit = _token_cache.get(token)
    if hit and hit[1] > time.time():
        return hit[0]
    if DEV_AUTH and token.startswith("dev:"):
        _, uid, email = token.split(":", 2)
        user = {"id": uid, "email": email}
    else:
        if not SUPABASE_URL:
            raise HTTPException(500, "Authentication is not configured")
        r = httpx.get(f"{SUPABASE_URL}/auth/v1/user", timeout=10,
                      headers={"Authorization": f"Bearer {token}", "apikey": SUPABASE_ANON_KEY})
        if r.status_code != 200:
            raise HTTPException(401, "Your session has expired. Please sign in again.")
        d = r.json()
        user = {"id": d["id"], "email": d.get("email"),
                "name": (d.get("user_metadata") or {}).get("full_name") or (d.get("user_metadata") or {}).get("name")}
    _token_cache[token] = (user, time.time() + 300)
    return user


def current_user(authorization: str = Header(default="")) -> Dict:
    if not enabled():
        raise HTTPException(503, "The platform is not configured yet")
    if not authorization.lower().startswith("bearer "):
        raise HTTPException(401, "Please sign in")
    user = _verify_token(authorization.split(" ", 1)[1].strip())
    prof = q1("select * from profiles where id = %s", [user["id"]])
    if prof is None:
        prof = q1("insert into profiles (id, email, full_name) values (%s, %s, %s) "
                  "on conflict (id) do update set email = excluded.email returning *",
                  [user["id"], user.get("email"), user.get("name")])
    return prof


def recruiter(user: Dict = Depends(current_user)) -> Dict:
    if user["role"] != "recruiter" or not user.get("company_id"):
        raise HTTPException(403, "This page is for company accounts")
    return user


# --------------------------------------------------------------------------------------
# File storage (Supabase Storage, or a local folder for development)
# --------------------------------------------------------------------------------------
def _storage_headers(content_type: Optional[str] = None) -> Dict[str, str]:
    h = {"apikey": SUPABASE_SERVICE_ROLE_KEY}
    if not SUPABASE_SERVICE_ROLE_KEY.startswith("sb_"):  # legacy JWT keys also go in Authorization; new sb_secret keys must not
        h["Authorization"] = f"Bearer {SUPABASE_SERVICE_ROLE_KEY}"
    if content_type:
        h["Content-Type"] = content_type
    return h


def _use_supabase_storage() -> bool:
    return bool(SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY)


# Optional: keep recordings and PDFs in a private Hugging Face dataset (100 GB+ free) instead of
# Supabase Storage (1 GB free). Enabled when HF_STORAGE_REPO and HF_TOKEN are both set.
HF_STORAGE_REPO = os.getenv("HF_STORAGE_REPO", "")
HF_TOKEN = os.getenv("HF_TOKEN", "")
_hf_cache_dir = os.path.join(LOCAL_STORAGE_DIR, ".hf_cache")


def _use_hf_storage() -> bool:
    return bool(HF_STORAGE_REPO and HF_TOKEN)


def storage_backend() -> str:
    return "huggingface" if _use_hf_storage() else "supabase" if _use_supabase_storage() else "local"


def _hf_api():
    from huggingface_hub import HfApi
    return HfApi(token=HF_TOKEN)


def ensure_bucket() -> None:
    if _use_hf_storage():
        _hf_api().create_repo(HF_STORAGE_REPO, repo_type="dataset", private=True, exist_ok=True)
        os.makedirs(_hf_cache_dir, exist_ok=True)
        logger.info("File storage: private HF dataset %s", HF_STORAGE_REPO)
        return
    if not _use_supabase_storage():
        os.makedirs(LOCAL_STORAGE_DIR, exist_ok=True)
        return
    r = httpx.get(f"{SUPABASE_URL}/storage/v1/bucket/{STORAGE_BUCKET}", headers=_storage_headers(), timeout=15)
    if r.status_code != 200:
        r = httpx.post(f"{SUPABASE_URL}/storage/v1/bucket", headers=_storage_headers("application/json"),
                       json={"id": STORAGE_BUCKET, "name": STORAGE_BUCKET, "public": False}, timeout=15)
        logger.info("Created storage bucket %s: %s", STORAGE_BUCKET, r.status_code)


def put_file(path: str, data: bytes, content_type: str) -> str:
    if _use_hf_storage():
        _hf_api().upload_file(path_or_fileobj=data, path_in_repo=path, repo_id=HF_STORAGE_REPO,
                              repo_type="dataset", commit_message=f"add {path}")
        full = os.path.join(_hf_cache_dir, path)  # keep a local copy so reads are instant
        os.makedirs(os.path.dirname(full), exist_ok=True)
        with open(full, "wb") as f:
            f.write(data)
        return path
    if not _use_supabase_storage():
        full = os.path.join(LOCAL_STORAGE_DIR, path)
        os.makedirs(os.path.dirname(full), exist_ok=True)
        with open(full, "wb") as f:
            f.write(data)
        return path
    r = httpx.post(f"{SUPABASE_URL}/storage/v1/object/{STORAGE_BUCKET}/{path}", content=data, timeout=120,
                   headers={**_storage_headers(content_type), "x-upsert": "true"})
    if r.status_code >= 300:
        raise RuntimeError(f"Storage upload failed: {r.status_code} {r.text[:200]}")
    return path


def get_file(path: str) -> bytes:
    if _use_hf_storage():
        full = os.path.join(_hf_cache_dir, path)
        if not os.path.exists(full):
            from huggingface_hub import hf_hub_download
            try:
                return open(hf_hub_download(HF_STORAGE_REPO, path, repo_type="dataset", token=HF_TOKEN,
                                            local_dir=_hf_cache_dir), "rb").read()
            except Exception as e:
                raise RuntimeError(f"Storage download failed: {e}")
        with open(full, "rb") as f:
            return f.read()
    if not _use_supabase_storage():
        with open(os.path.join(LOCAL_STORAGE_DIR, path), "rb") as f:
            return f.read()
    r = httpx.get(f"{SUPABASE_URL}/storage/v1/object/{STORAGE_BUCKET}/{path}", headers=_storage_headers(), timeout=120)
    if r.status_code != 200:
        raise RuntimeError(f"Storage download failed: {r.status_code}")
    return r.content


def dumps(obj) -> str:
    return json.dumps(obj, ensure_ascii=False, default=str)