Files
osint_web/server/osintdb_client.py
egutierrez 9cbea2d036 feat(contacts): multi-valor (varios tel/email/direccion) + libretas + backend osint_db (flag)
- ContactIn + frontmatter + vCard multi-valor: emite N TEL, N EMAIL, N ADR; _vcard_to_json
  parsea ADR -> direcciones[] (y sigue leyendo X-OSINT-DIRECCION legacy). Los singulares
  telefono/email/direccion se mantienen por compat (= primer elemento de cada lista).
- Libretas de contactos (addressbooks): endpoints GET/POST /api/addressbooks; en
  ContactsView un selector de libreta + boton 'Nueva libreta' (replica del patron de crear
  calendario) + filtro por libreta en la lista.
- Frontend ContactsView: TagsInput para telefonos/emails/direcciones, cargando TODOS los
  valores al editar (antes solo el primero).
- Feature flag OSINT_DB_BACKEND (dev/feature_flags.json, default off): con ON, osint_web
  lee/escribe contra el service osint_db (DuckDB = fuente de verdad) via
  server/osintdb_client.py; con OFF, el comportamiento historico (vault .md + vCard
  Xandikos) queda intacto byte a byte.

Verificado: 52 tests backend (40 + 12 nuevos), tsc --noEmit limpio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:47:38 +02:00

241 lines
8.2 KiB
Python

"""Cliente HTTP fino al service osint_db (DuckDB, 127.0.0.1:8771).
Fuente de verdad de los contactos cuando el feature flag ``OSINT_DB_BACKEND``
está activo. El osint_db es quien escribe la DuckDB y empuja el cambio a
Xandikos; esta app solo le habla por HTTP. Todas las respuestas del service son
``200 + {status: "ok"|"error", ...}`` (los errores de dominio viajan en el cuerpo,
no en el código HTTP).
Solo stdlib (urllib, json) para no añadir dependencias de runtime: el cliente es
un wrapper de transporte, no reimplementa lógica del osint_db. Errores de red
(timeout, conexión rechazada, host caído) se traducen a la excepción
``OsintDbUnavailable`` para que los endpoints degraden con un 503 claro, igual que
el camino DAV, en vez de tumbar el server.
Contrato (cuerpo JSON):
POST /api/query {sql, params?, max_rows?} → {status, columns, rows}
POST /api/contact {collection, fn, telefonos, emails, direcciones, ...}
PUT /api/contact/{uid} (mismo cuerpo, sin uid en el body)
DELETE /api/contact/{uid}
POST /api/addressbook {slug, display_name, color?}
"""
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Optional
# URL base del service. Se mantiene como módulo-global para poder monkeypatchearla
# en tests sin tocar cada llamada.
BASE_URL = "http://127.0.0.1:8771"
# Timeout por petición. El osint_db es local (loopback): si tarda más que esto,
# algo va mal y es mejor degradar que colgar el endpoint.
_TIMEOUT_S = 20.0
class OsintDbUnavailable(Exception):
"""El service osint_db no responde (no arrancado, timeout, conexión caída).
Los endpoints la capturan y devuelven un 503 JSON claro, en paralelo a
``DavUnavailable`` del camino DAV.
"""
def _request(method: str, path: str, body: Optional[dict] = None) -> dict:
"""Hace una petición HTTP al osint_db y devuelve el JSON de respuesta.
Args:
method: verbo HTTP (``GET``/``POST``/``PUT``/``DELETE``).
path: ruta absoluta del endpoint (``/api/query``, ...).
body: cuerpo JSON opcional (se serializa con ``ensure_ascii=False``).
Returns:
El cuerpo de respuesta ya deserializado a dict.
Raises:
OsintDbUnavailable: si el service no responde o la respuesta no es JSON.
"""
url = BASE_URL.rstrip("/") + path
data = None
headers = {"Accept": "application/json"}
if body is not None:
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp:
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
# El contrato dice 200 siempre; un HTTPError es anómalo. Intenta leer el
# cuerpo (puede traer {status:error,...}); si no, degrada.
try:
return json.loads(exc.read().decode("utf-8"))
except (ValueError, OSError):
raise OsintDbUnavailable(
"osint_db respondió HTTP %s en %s" % (exc.code, path)
) from exc
except (urllib.error.URLError, OSError, TimeoutError) as exc:
raise OsintDbUnavailable(
"osint_db no responde en %s: %s" % (BASE_URL, exc)
) from exc
try:
return json.loads(raw)
except ValueError as exc:
raise OsintDbUnavailable(
"osint_db devolvió una respuesta no-JSON en %s" % path
) from exc
def query(sql: str, params: Optional[list] = None, max_rows: int = 2000) -> dict:
"""Ejecuta una SELECT contra la DuckDB del osint_db.
Args:
sql: la consulta SQL (de solo lectura; el service la valida).
params: parámetros posicionales opcionales.
max_rows: tope de filas devueltas.
Returns:
dict ``{status, columns, rows}`` tal cual lo devuelve el service.
Raises:
OsintDbUnavailable: si el service no responde.
"""
body: dict[str, Any] = {"sql": sql, "max_rows": max_rows}
if params:
body["params"] = params
return _request("POST", "/api/query", body)
def list_addressbooks() -> list:
"""Lista las libretas (addressbooks) del osint_db.
Devuelve una lista de dicts ``{slug, display_name, collection_path, color}``
ordenados por ``display_name``. Si la consulta falla a nivel de dominio
(``status != ok``) devuelve lista vacía, no lanza.
Raises:
OsintDbUnavailable: si el service no responde.
"""
res = query(
"SELECT slug, display_name, collection_path, color "
"FROM addressbooks ORDER BY display_name",
max_rows=1000,
)
if res.get("status") != "ok":
return []
cols = res.get("columns") or []
rows = res.get("rows") or []
out: list = []
for row in rows:
# El service puede devolver filas como lista posicional o como dict.
if isinstance(row, dict):
out.append(row)
else:
out.append({cols[i]: row[i] for i in range(min(len(cols), len(row)))})
return out
def list_contacts() -> list:
"""Lista los contactos del osint_db, con los campos que consume el frontend.
Devuelve filas ``{uid, collection, fn, tels, emails, note_path}``; ``tels`` y
``emails`` llegan como JSON array (string JSON o lista) y se parsean a lista de
strings.
Raises:
OsintDbUnavailable: si el service no responde.
"""
res = query(
"SELECT uid, collection, fn, tels, emails, note_path "
"FROM contacts ORDER BY fn",
max_rows=5000,
)
if res.get("status") != "ok":
return []
cols = res.get("columns") or []
rows = res.get("rows") or []
out: list = []
for row in rows:
rec = row if isinstance(row, dict) else {
cols[i]: row[i] for i in range(min(len(cols), len(row)))
}
out.append(rec)
return out
def _parse_json_array(value: Any) -> list:
"""Normaliza un valor que puede venir como lista o como string JSON a lista.
El osint_db devuelve ``tels``/``emails`` como JSON array; según el driver,
puede llegar ya como lista Python o como string JSON. Tolera ambos y los
valores nulos/vacíos.
"""
if value is None or value == "":
return []
if isinstance(value, list):
return [str(v) for v in value if v not in (None, "")]
if isinstance(value, str):
try:
parsed = json.loads(value)
except ValueError:
return [value]
if isinstance(parsed, list):
return [str(v) for v in parsed if v not in (None, "")]
return [str(parsed)]
return [str(value)]
def create_contact(payload: dict) -> dict:
"""Crea un contacto en el osint_db (POST /api/contact).
Args:
payload: cuerpo JSON del contacto (``collection, fn, telefonos, emails,
direcciones, nombre?, aliases?, dni?, pais?, contexto?, notas?``).
Returns:
El cuerpo de respuesta del service (``{status, uid, ...}``).
Raises:
OsintDbUnavailable: si el service no responde.
"""
return _request("POST", "/api/contact", payload)
def update_contact(uid: str, payload: dict) -> dict:
"""Edita un contacto del osint_db (PUT /api/contact/{uid}).
Raises:
OsintDbUnavailable: si el service no responde.
"""
return _request("PUT", "/api/contact/%s" % urllib.parse.quote(uid), payload)
def delete_contact(uid: str) -> dict:
"""Borra un contacto del osint_db (DELETE /api/contact/{uid}).
Raises:
OsintDbUnavailable: si el service no responde.
"""
return _request("DELETE", "/api/contact/%s" % urllib.parse.quote(uid))
def create_addressbook(slug: str, name: str, color: Optional[str] = None) -> dict:
"""Crea una libreta (addressbook) en el osint_db (POST /api/addressbook).
El osint_db crea la colección CardDAV en Xandikos y la registra en la DuckDB.
Returns:
El cuerpo de respuesta del service (``{status, slug, ...}``).
Raises:
OsintDbUnavailable: si el service no responde.
"""
body: dict[str, Any] = {"slug": slug, "display_name": name}
if color:
body["color"] = color
return _request("POST", "/api/addressbook", body)