# -*- coding: utf-8 -*- """Client HTTP con sessione cookie (Loogle Casa, Irrigazione).""" import logging import threading import time from typing import Any, Optional from urllib.parse import urljoin import httpx LOGGER = logging.getLogger("loogle_mcp.session_client") _sessions: dict[str, tuple[str, float]] = {} _sessions_lock = threading.Lock() SESSION_TTL = 3600 * 12 class SessionApiClient: """Login cookie-based con cache per utente MCP.""" def __init__( self, *, service: str, base_url: str, login_path: str = "/api/login", verify_ssl: bool = True, ) -> None: self.service = service self.base_url = base_url.rstrip("/") self.login_path = login_path self.verify_ssl = verify_ssl def _password_for_user(self, username: str) -> Optional[str]: import os user = username.lower() env_key = f"{self.service}_PASSWORD_{user.upper()}" pwd = os.environ.get(env_key, "").strip() if pwd: return pwd fallback = os.environ.get(f"{self.service}_PASSWORD", "").strip() if fallback: return fallback return user def _cache_key(self, username: str) -> str: return f"{self.service}:{username.lower()}" def _get_cached_cookie(self, username: str) -> Optional[str]: key = self._cache_key(username) with _sessions_lock: row = _sessions.get(key) if not row: return None cookie, expires = row if time.time() > expires: _sessions.pop(key, None) return None return cookie def _store_cookie(self, username: str, cookie: str) -> None: key = self._cache_key(username) with _sessions_lock: _sessions[key] = (cookie, time.time() + SESSION_TTL) def login(self, username: str) -> str: cached = self._get_cached_cookie(username) if cached: return cached service_user = username if hasattr(self, "map_username"): service_user = self.map_username(username) # type: ignore[attr-defined] password = self._password_for_user(username) if not password: raise RuntimeError( f"Password {self.service} non configurata per {username}. " f"Imposta {self.service}_PASSWORD_{username.upper()} in .env" ) url = urljoin(self.base_url + "/", self.login_path.lstrip("/")) with httpx.Client(timeout=30.0, verify=self.verify_ssl) as client: resp = client.post( url, json={"username": service_user, "password": password}, ) if resp.status_code != 200: raise RuntimeError( f"Login {self.service} fallito per {username}: HTTP {resp.status_code}" ) cookie = resp.cookies.get("session") if not cookie: raise RuntimeError(f"Login {self.service}: cookie session mancante") self._store_cookie(username, cookie) return cookie def request( self, method: str, path: str, *, username: str, params: Optional[dict] = None, json_body: Optional[dict] = None, ) -> Any: cookie = self.login(username) url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/")) headers = {"Cookie": f"session={cookie}"} with httpx.Client(timeout=60.0, verify=self.verify_ssl) as client: resp = client.request(method, url, headers=headers, params=params, json=json_body) if resp.status_code == 401: with _sessions_lock: _sessions.pop(self._cache_key(username), None) cookie = self.login(username) headers = {"Cookie": f"session={cookie}"} resp = client.request(method, url, headers=headers, params=params, json=json_body) if resp.status_code >= 400: raise RuntimeError(f"{self.service} {method} {path}: HTTP {resp.status_code} {resp.text[:200]}") if resp.headers.get("content-type", "").startswith("application/json"): return resp.json() return resp.text def get(self, path: str, *, username: str, params: Optional[dict] = None) -> Any: return self.request("GET", path, username=username, params=params)