9cbea2d036
- 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>
241 lines
8.2 KiB
Python
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)
|