Spaces:
Sleeping
Sleeping
| """Encrypted, per-browser persistence of the Setup config (incl. API key). | |
| Goal: a visitor enters their API key once and, on the same browser, never has | |
| to re-enter it — without any login or any code they must remember. A random, | |
| invisible per-browser *tag* identifies the returning visitor (see | |
| :mod:`app.services.browser_tag`); this module stores their config in Supabase, | |
| encrypted so that: | |
| * Supabase only ever holds **ciphertext** — a DB/storage leak reveals nothing. | |
| * The encryption secret is derived from the browser tag, which is **never sent | |
| to Supabase**, so one visitor's stored blob can't be read with another's tag. | |
| * The storage object id is derived from the tag with a *different* salt than the | |
| encryption key, so even the id can't be turned back into the decryption key. | |
| Everything here is **fail-safe**: if Supabase isn't configured, or any network | |
| call fails, every function quietly degrades to a no-op / ``None`` and the app | |
| falls back to today's session-only behavior. It must never raise into the UI. | |
| Storage backend: a private Supabase Storage bucket (auto-created on first use), | |
| one small object per browser tag. Using Storage means no database schema / SQL | |
| setup is required — the bucket is created via the service key at runtime. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import hashlib | |
| import json | |
| import os | |
| from typing import Optional | |
| # Bucket that holds the encrypted per-tag config blobs. | |
| _BUCKET = "apikeys" | |
| _TIMEOUT = 8.0 | |
| # Salts keep the storage-object id and the encryption key independent, so | |
| # knowing the id (which Supabase stores) never yields the decryption key. | |
| _ID_SALT = b"upwork-strategist::object-id::v1" | |
| _KEY_SALT = b"upwork-strategist::enc-key::v1" | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| def is_configured() -> bool: | |
| """True only when the Supabase service credentials are present.""" | |
| return bool(_base_url() and _service_key()) | |
| def _base_url() -> str: | |
| return (os.getenv("SUPABASE_URL") or "").rstrip("/") | |
| def _service_key() -> str: | |
| return os.getenv("SUPABASE_SERVICE_KEY") or "" | |
| # --------------------------------------------------------------------------- | |
| # Crypto / id derivation (pure, unit-testable) | |
| # --------------------------------------------------------------------------- | |
| def _object_path(tag: str) -> str: | |
| digest = hashlib.sha256(_ID_SALT + tag.encode("utf-8")).hexdigest() | |
| return f"{digest}.bin" | |
| def _fernet(tag: str): | |
| from cryptography.fernet import Fernet | |
| raw = hashlib.sha256(_KEY_SALT + tag.encode("utf-8")).digest() | |
| return Fernet(base64.urlsafe_b64encode(raw)) | |
| def encrypt_payload(tag: str, payload: dict) -> bytes: | |
| """Encrypt a config dict for ``tag`` -> ciphertext bytes (pure).""" | |
| data = json.dumps(payload, separators=(",", ":")).encode("utf-8") | |
| return _fernet(tag).encrypt(data) | |
| def decrypt_payload(tag: str, token: bytes) -> Optional[dict]: | |
| """Decrypt ciphertext for ``tag`` -> config dict, or ``None`` if it can't | |
| be decrypted with this tag (wrong tag / corrupt / tampered).""" | |
| from cryptography.fernet import InvalidToken | |
| try: | |
| plain = _fernet(tag).decrypt(token) | |
| obj = json.loads(plain) | |
| return obj if isinstance(obj, dict) else None | |
| except (InvalidToken, ValueError, TypeError): | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # HTTP transport (single seam — monkeypatched in tests) | |
| # --------------------------------------------------------------------------- | |
| def _http(method: str, url: str, headers: dict, content: Optional[bytes] = None): | |
| """Perform one HTTP request. Returns ``(status_code, body_bytes)``. | |
| Isolated so tests can replace it with an in-memory fake. Uses ``httpx``, | |
| which is already installed (httpx is an OpenAI SDK dependency). | |
| """ | |
| import httpx | |
| with httpx.Client(timeout=_TIMEOUT) as client: | |
| resp = client.request(method, url, headers=headers, content=content) | |
| return resp.status_code, resp.content | |
| def _auth_headers(extra: Optional[dict] = None) -> dict: | |
| key = _service_key() | |
| headers = {"Authorization": f"Bearer {key}", "apikey": key} | |
| if extra: | |
| headers.update(extra) | |
| return headers | |
| def _ensure_bucket() -> None: | |
| """Create the private bucket if missing. Idempotent; errors are ignored | |
| (a 'already exists' response is the normal case after the first run).""" | |
| url = f"{_base_url()}/storage/v1/bucket" | |
| body = json.dumps({"id": _BUCKET, "name": _BUCKET, "public": False}).encode() | |
| try: | |
| _http("POST", url, _auth_headers({"Content-Type": "application/json"}), body) | |
| except Exception: # noqa: BLE001 - best-effort; save() will surface real failure | |
| pass | |
| # --------------------------------------------------------------------------- | |
| # Public API (fail-safe) | |
| # --------------------------------------------------------------------------- | |
| def save_config(tag: str, payload: dict) -> bool: | |
| """Encrypt ``payload`` under ``tag`` and upsert it to Supabase. | |
| Returns True on success, False on any failure or when not configured. | |
| Never raises. | |
| """ | |
| if not (is_configured() and tag and payload): | |
| return False | |
| try: | |
| _ensure_bucket() | |
| token = encrypt_payload(tag, payload) | |
| url = f"{_base_url()}/storage/v1/object/{_BUCKET}/{_object_path(tag)}" | |
| # x-upsert overwrites an existing object for this tag. | |
| status, _ = _http( | |
| "POST", | |
| url, | |
| _auth_headers( | |
| {"Content-Type": "application/octet-stream", "x-upsert": "true"} | |
| ), | |
| token, | |
| ) | |
| return 200 <= status < 300 | |
| except Exception: # noqa: BLE001 - fail-safe: never break the UI | |
| return False | |
| def load_config(tag: str) -> Optional[dict]: | |
| """Fetch and decrypt the stored config for ``tag``. | |
| Returns the config dict, or ``None`` if absent / not configured / any | |
| failure. Never raises. | |
| """ | |
| if not (is_configured() and tag): | |
| return None | |
| obj = _object_path(tag) | |
| # Supabase exposes private downloads at two equivalent paths depending on | |
| # version; try the authenticated form first, then the plain one. | |
| candidates = ( | |
| f"/storage/v1/object/authenticated/{_BUCKET}/{obj}", | |
| f"/storage/v1/object/{_BUCKET}/{obj}", | |
| ) | |
| for path in candidates: | |
| try: | |
| status, body = _http("GET", f"{_base_url()}{path}", _auth_headers()) | |
| except Exception: # noqa: BLE001 - fail-safe; try the next candidate | |
| continue | |
| if 200 <= status < 300 and body: | |
| payload = decrypt_payload(tag, body) | |
| if payload is not None: | |
| return payload | |
| return None | |
| def delete_config(tag: str) -> bool: | |
| """Remove the stored config for ``tag`` (used by Clear Credentials). | |
| Returns True on success, False otherwise. Never raises. | |
| """ | |
| if not (is_configured() and tag): | |
| return False | |
| try: | |
| url = f"{_base_url()}/storage/v1/object/{_BUCKET}/{_object_path(tag)}" | |
| status, _ = _http("DELETE", url, _auth_headers()) | |
| return 200 <= status < 300 | |
| except Exception: # noqa: BLE001 - fail-safe | |
| return False | |