# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/hubfav.py # Desc: Favoris « Mon univers Ka » — le hub Groupe KA (groupe-ka.com) est le # MAGASIN CENTRAL des favoris du groupe : Créa-Ka ne stocke rien # localement. Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : # un échec remonte à l'appelant) et la liste est lue au hub (GET signé, # cache mémoire 30 s, invalidé à chaque toggle). Même secret que le SSO. # Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel). # ============================================================================== from __future__ import annotations import hashlib import hmac import os import threading import time import requests CLIENT_ID = "crea-ka" KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") CACHE_TTL = 30 # secondes — liste des favoris TIMEOUT = 6 # secondes _cache: dict[str, tuple[float, list[dict]]] = {} _lock = threading.Lock() # champs d'item acceptés -> longueur maximale (troncature défensive) _FIELDS = {"item_id": 120, "title": 200, "subtitle": 200, "price_label": 60, "image_url": 500, "url": 500} def _sig(ka_id: str, ts: int) -> str | None: """Signature HMAC-SHA256 du hub : hex("crea-ka..").""" secret = os.environ.get("KA_SSO_SECRET") if not secret: return None return hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(), hashlib.sha256).hexdigest() def linked(ka_id: str | None) -> bool: """Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe).""" return bool(ka_id) and str(ka_id).startswith("ka-") def clean_item(item: dict) -> dict: """Ne garde que les champs d'item connus, en chaînes tronquées.""" return {k: str(item.get(k) or "")[:n] for k, n in _FIELDS.items() if item.get(k)} def hub_toggle(ka_id: str, action: str, item: dict) -> bool: """Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s : le hub est le magasin des favoris, l'échec doit remonter à l'appelant.""" ts = int(time.time()) sig = _sig(ka_id, ts) if not sig or not linked(ka_id) or action not in ("add", "remove"): return False try: r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, json={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": str(ts), "sig": sig, "action": action, "item": item}) ok = r.status_code == 200 except Exception: ok = False if ok: with _lock: _cache.pop(ka_id, None) # la prochaine lecture reflète le toggle return ok def hub_list(ka_id: str) -> list[dict] | None: """Favoris Créa-Ka du membre, lus au hub (cache mémoire 30 s). [] = aucun favori ; None = hub injoignable (erreur, jamais mise en cache).""" if not linked(ka_id): return [] # compte legacy non relié au hub now = time.time() with _lock: hit = _cache.get(ka_id) if hit and now - hit[0] < CACHE_TTL: return hit[1] sig = _sig(ka_id, int(now)) if not sig: return None try: r = requests.get(f"{KA_HUB_URL}/api/sso/favorites", timeout=TIMEOUT, params={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": int(now), "sig": sig}) if r.status_code != 200: return None favs = r.json().get("favorites") or [] if not isinstance(favs, list): return None except Exception: return None favs = [f for f in favs if isinstance(f, dict)] with _lock: _cache[ka_id] = (now, favs) return favs