diff --git a/app.md b/app.md index 5dc3698..3fdb61e 100644 --- a/app.md +++ b/app.md @@ -15,6 +15,8 @@ uses_functions: - dav_list_calendars_py_infra - dav_list_addressbooks_py_infra - dav_collection_ctag_py_infra + - dav_list_resources_py_infra + - dav_get_resource_py_infra - carddav_put_vcard_py_infra - caldav_put_event_py_infra - dav_delete_resource_py_infra diff --git a/server/main.py b/server/main.py index 0e5f614..f025d90 100644 --- a/server/main.py +++ b/server/main.py @@ -28,6 +28,7 @@ endpoints de datos responden SIEMPRE 200 con status ok|error en el body): POST /api/query/named ejecuta una query del catálogo {name, max_rows} POST /api/ingest/vault re-escanea el vault y reconstruye maestras+derivadas POST /api/ingest/dav baja Xandikos y reconstruye contacts/events+derivadas + POST /api/sync/dav-pull sync inverso incremental por etag (ediciones del móvil) POST /api/render/note ejecuta query y la upserta como bloque sentinel en una nota """ @@ -359,6 +360,12 @@ def create_app(cfg: Config) -> FastAPI: def push_dav() -> dict: return _guard(lambda: writes.push_all_dav(cfg)) + @app.post("/api/sync/dav-pull") + def sync_dav_pull() -> dict: + # Sync inverso: trae a la DB las ediciones del móvil/DAVx5, + # last-write-wins por etag (incremental, distinto de ingest_dav). + return _guard(lambda: writes.pull_dav(cfg)) + return app diff --git a/server/registry_bridge.py b/server/registry_bridge.py index f0539dd..3f454a2 100644 --- a/server/registry_bridge.py +++ b/server/registry_bridge.py @@ -93,6 +93,10 @@ dav_list_addressbooks = _load_registry_fn( "infra", "dav_list_addressbooks", "dav_list_addressbooks" ) dav_collection_ctag = _load_registry_fn("infra", "dav_collection_ctag", "dav_collection_ctag") +# Grupo dav: listado incremental (PROPFIND Depth:1 -> [{href, etag}]) y GET de +# un recurso suelto. Base del sync inverso por etag (/api/sync/dav-pull). +dav_list_resources = _load_registry_fn("infra", "dav_list_resources", "dav_list_resources") +dav_get_resource = _load_registry_fn("infra", "dav_get_resource", "dav_get_resource") # Grupo dav: escritura (push DB -> Xandikos) y creación de colecciones. carddav_put_vcard = _load_registry_fn("infra", "carddav_put_vcard", "carddav_put_vcard") @@ -137,6 +141,8 @@ __all__ = [ "dav_get_collection", "dav_list_calendars", "dav_list_addressbooks", + "dav_list_resources", + "dav_get_resource", "carddav_put_vcard", "caldav_put_event", "dav_delete_resource", diff --git a/server/writes.py b/server/writes.py index 53cb316..0768aca 100644 --- a/server/writes.py +++ b/server/writes.py @@ -28,6 +28,8 @@ from .registry_bridge import ( carddav_put_vcard, create_obsidian_note, dav_delete_resource, + dav_get_resource, + dav_list_resources, dav_make_addressbook, dav_make_calendar, duckdb_execute, @@ -288,6 +290,110 @@ def _default_collection(cfg: Config) -> str: return cfg.dav_contacts_collection +def _person_agenda_extras(db_path: str, note_path) -> tuple: + """Devuelve (direcciones, aliases) de la persons enlazada por note_path. + + El móvil solo recibe datos de agenda: si el contacto está enlazado a una + ficha de persons, sus direcciones y aliases se incluyen en el vCard (como + ADR y NICKNAME). Los campos OSINT (dni/sexo/fecha_nacimiento/pais/contexto) + NUNCA salen de la DB: no se leen aquí. Devuelve ([], []) si no hay enlace. + """ + if not note_path: + return [], [] + res = duckdb_query_readonly( + db_path, + "SELECT direcciones, aliases FROM persons WHERE note_path = ?", + [note_path], + 1, + ) + if res.get("status") != "ok" or not res.get("rows"): + return [], [] + row = res["rows"][0] + return ( + _decode_json_field(row.get("direcciones")), + _decode_json_field(row.get("aliases")), + ) + + +def _compose_agenda_vcard(cfg: Config, uid: str, fields: dict) -> str: + """Compone el vCard de AGENDA de un contacto, SIN ningún campo OSINT. + + Incluye FN, TEL×N, EMAIL×N y, si el contacto está enlazado a una ficha de + persons (por note_path), las direcciones (ADR×N) y aliases (NICKNAME) de esa + persona. NUNCA pasa el dict ``osint`` a build_vcard, así que el vCard jamás + lleva líneas X-OSINT-* (DNI/sexo/fecha-nac/país/contexto quedan solo en + DuckDB + Obsidian). + + Args: + cfg: configuración del service (db_path para resolver la persona). + uid: UID del contacto. + fields: dict con fn/nombre, tels/telefonos, emails/correos, + direcciones/adrs y, opcionalmente, note_path (enlace a persons). + + Returns: + Texto vCard 3.0 listo para PUT a Xandikos. + """ + tels = _as_list(fields.get("tels") or fields.get("telefonos")) + emails = _as_list(fields.get("emails") or fields.get("correos")) + fn = fields.get("fn") or fields.get("nombre") + + # Direcciones y aliases del contacto explícitos en fields... + adrs = _as_list(fields.get("direcciones") or fields.get("adrs")) + aliases = _as_list(fields.get("aliases")) + # ...más los de la persons enlazada (si la hay), deduplicando. + note_path = fields.get("note_path") + person_adrs, person_aliases = _person_agenda_extras(cfg.db_path, note_path) + adrs = _dedup_keep_order(adrs + person_adrs) + aliases = _dedup_keep_order(aliases + person_aliases) + + contact = { + "uid": uid, + "fn": fn, + "tels": tels, + "emails": emails, + "adrs": adrs, + "aliases": aliases, + } + # CLAVE DE PRIVACIDAD: no se pasa 'osint' -> no se emite ninguna X-OSINT-*. + return build_vcard(contact) + + +def _dedup_keep_order(items: list) -> list: + """Deduplica una lista de strings preservando orden (case-insensitive).""" + seen, out = set(), [] + for it in items: + s = str(it).strip() + key = s.lower() + if s and key not in seen: + seen.add(key) + out.append(s) + return out + + +def _resource_href_tail(uid: str) -> str: + """Nombre del recurso .vcf que carddav_put_vcard deriva del UID.""" + return _safe_resource(uid) + ".vcf" + + +def _read_etag_after_push(cfg: Config, pwd: str, collection: str, uid: str): + """Lee el etag NUEVO del recurso .vcf de un contacto tras su PUT. + + Lista la colección (PROPFIND Depth:1) y busca el recurso cuyo href termina + en el nombre .vcf del uid. Devuelve el etag o None si no se encuentra/falla. + Capturar el etag del push propio evita que el sync inverso lo confunda con + una edición hecha desde el móvil. + """ + listing = dav_list_resources(cfg.dav_base, cfg.dav_user, pwd, collection) + if listing.get("status") != "ok": + return None + tail = _resource_href_tail(uid) + for res in listing.get("resources", []): + href = res.get("href") or "" + if href.rstrip("/").rsplit("/", 1)[-1] == tail: + return res.get("etag") + return None + + def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict: """Crea/actualiza un contacto en la DB y lo empuja a Xandikos (PUT vCard). @@ -302,15 +408,18 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict: fn = fields.get("fn") or fields.get("nombre") collection = fields.get("collection") or _default_collection(cfg) - vcard = build_vcard( - { - "uid": uid, - "fn": fn, - "tels": tels, - "emails": emails, - "adrs": _as_list(fields.get("direcciones") or fields.get("adrs")), - } - ) + # Si el contacto ya existe y está enlazado a una ficha, hereda su note_path + # para que el vCard de agenda incluya las direcciones/aliases de la persona. + note_path = fields.get("note_path") + if note_path is None: + existing = duckdb_query_readonly( + cfg.db_path, "SELECT note_path FROM contacts WHERE uid = ?", [uid], 1 + ) + if existing.get("status") == "ok" and existing.get("rows"): + note_path = existing["rows"][0].get("note_path") + + # vCard de AGENDA: nunca lleva X-OSINT-* (privacidad del móvil). + vcard = _compose_agenda_vcard(cfg, uid, {**fields, "note_path": note_path}) row = { "uid": uid, @@ -320,7 +429,7 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict: "tels": _json(tels), "emails": _json(emails), "raw": vcard, - "note_path": None, + "note_path": note_path, "updated_at": _now(), } res = duckdb_upsert( @@ -328,7 +437,7 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict: "contacts", [row], key_cols=["uid"], - update_cols=["collection", "fn", "tels", "emails", "raw", "updated_at"], + update_cols=["collection", "fn", "tels", "emails", "raw", "note_path", "updated_at"], ) if res.get("status") != "ok": return {"status": "error", "error": res.get("error")} @@ -336,17 +445,29 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict: # Push DB -> Xandikos fuera de cualquier transacción de la DB. pwd, err = _resolve_password(cfg) pushed = None + etag = None if err is None: push = carddav_put_vcard( cfg.dav_base, cfg.dav_user, pwd, collection, uid, vcard ) pushed = push.get("status") == "ok" + if pushed: + # Captura el etag NUEVO del recurso para que el sync inverso no + # confunda este push propio con una edición del móvil. + etag = _read_etag_after_push(cfg, pwd, collection, uid) + if etag: + duckdb_execute( + cfg.db_path, + "UPDATE contacts SET etag = ? WHERE uid = ?", + [etag, uid], + ) return { "status": "ok", "uid": uid, "inserted": res.get("inserted", 0), "updated": res.get("updated", 0), "pushed": pushed, + "etag": etag, "push_error": err, } @@ -650,7 +771,7 @@ def push_all_dav(cfg: Config) -> dict: contacts = duckdb_query_readonly( cfg.db_path, - "SELECT uid, collection, fn, tels, emails FROM contacts", + "SELECT uid, collection, fn, tels, emails, note_path FROM contacts", [], 1000000, ) @@ -659,19 +780,31 @@ def push_all_dav(cfg: Config) -> dict: for row in contacts["rows"]: uid = row["uid"] collection = row.get("collection") or _default_collection(cfg) - vcard = build_vcard( + # vCard de AGENDA: compone con la persona enlazada (direcciones + + # aliases) pero SIN ningún campo OSINT (privacidad del móvil). + vcard = _compose_agenda_vcard( + cfg, + uid, { - "uid": uid, "fn": row.get("fn"), "tels": _decode_json_field(row.get("tels")), "emails": _decode_json_field(row.get("emails")), - } + "note_path": row.get("note_path"), + }, ) push = carddav_put_vcard( cfg.dav_base, cfg.dav_user, pwd, collection, uid, vcard ) if push.get("status") == "ok": c_ok += 1 + # Captura el etag nuevo del push (sync inverso fiable). + etag = _read_etag_after_push(cfg, pwd, collection, uid) + if etag: + duckdb_execute( + cfg.db_path, + "UPDATE contacts SET etag = ? WHERE uid = ?", + [etag, uid], + ) else: c_fail += 1 @@ -700,3 +833,146 @@ def push_all_dav(cfg: Config) -> dict: "events_pushed": e_ok, "events_failed": e_fail, } + + +# --------------------------------------------------------------------------- +# sync inverso Xandikos -> DB (pull incremental por etag) +# --------------------------------------------------------------------------- + + +def pull_dav(cfg: Config) -> dict: + """Trae a la DB las ediciones del móvil/DAVx5, last-write-wins por etag. + + A diferencia de ``ingest_dav`` (que hace DELETE + INSERT ciego de TODAS las + colecciones), este pull es INCREMENTAL y respeta la verdad de la DB salvo + donde el etag prueba un cambio externo: + + 1. Para cada colección registrada en ``addressbooks`` lista los recursos + (PROPFIND Depth:1 -> [{href, etag}]). + 2. Por recurso: si su etag difiere del ``contacts.etag`` guardado (o el uid + no existe en la DB) -> GET + parse + upsert con el etag nuevo. Si el + etag coincide -> no se toca (la DB ya está al día). + 3. Los uids que la DB tenía en esa colección y que YA no aparecen en el + PROPFIND se borran (el móvil los eliminó). + 4. Tras el pull se re-enlazan los contactos con sus fichas y se + reconstruyen las derivadas (bajo el lock single-writer). + + Devuelve {status:'ok', pulled, updated, deleted, unchanged} o + {status:'error', error}. + """ + # Late imports: evitan ciclo writes<->ingest a nivel de módulo y reusan la + # lógica de colecciones + enlace + derivadas ya existente (registry-first). + from . import davparse + from .db import write_conn + from .derived import rebuild_derived + from .ingest import _addressbook_collections, _link_contacts + + pwd, err = _resolve_password(cfg) + if err is not None: + return {"status": "error", "error": err} + + collections = _addressbook_collections(cfg) + + pulled = updated = deleted = unchanged = 0 + for collection in collections: + listing = dav_list_resources(cfg.dav_base, cfg.dav_user, pwd, collection) + if listing.get("status") != "ok": + return { + "status": "error", + "error": f"PROPFIND {collection}: {listing.get('error')} " + f"(http {listing.get('http_status')})", + } + + # Estado actual de la DB para esta colección: uid -> etag. + db_state = duckdb_query_readonly( + cfg.db_path, + "SELECT uid, etag FROM contacts WHERE collection = ?", + [collection], + 1000000, + ) + db_etags: dict = {} + if db_state.get("status") == "ok": + db_etags = {r["uid"]: r.get("etag") for r in db_state["rows"]} + + seen_uids: set = set() + for res in listing.get("resources", []): + href = res.get("href") or "" + remote_etag = res.get("etag") + # GET solo cuando el etag cambió o el recurso es nuevo en la DB. + # uid provisional desde el nombre del recurso para el cruce rápido; + # el uid real se toma del vCard tras el GET. + res_name = href.rstrip("/").rsplit("/", 1)[-1] + guess_uid = os.path.splitext(res_name)[0] + + # Si ya conocemos este uid (por nombre) y el etag coincide -> skip. + if guess_uid in db_etags and db_etags[guess_uid] == remote_etag and remote_etag: + seen_uids.add(guess_uid) + unchanged += 1 + continue + + got = dav_get_resource(cfg.dav_base, cfg.dav_user, pwd, href) + if got.get("status") != "ok": + # Un recurso ilegible no aborta el pull entero. + continue + parsed = davparse.parse_vcard(got.get("text", "")) + uid = parsed["uid"] or guess_uid + seen_uids.add(uid) + + existed = uid in db_etags + # Confirmación con el uid real: si el etag ya casa, no es cambio. + if existed and db_etags[uid] == remote_etag and remote_etag: + unchanged += 1 + continue + + row = { + "uid": uid, + "collection": collection, + "etag": remote_etag, + "fn": parsed["fn"] or None, + "tels": _json(parsed["tels"]), + "emails": _json(parsed["emails"]), + "raw": got.get("text", ""), + # note_path se re-enlaza después; preserva el existente si lo había. + "note_path": None, + "updated_at": _now(), + } + up = duckdb_upsert( + cfg.db_path, + "contacts", + [row], + key_cols=["uid"], + update_cols=["collection", "etag", "fn", "tels", "emails", "raw", "updated_at"], + ) + if up.get("status") != "ok": + return {"status": "error", "error": up.get("error")} + pulled += 1 + if existed: + updated += 1 + + # Borra los uids que la DB tenía en esta colección y ya no están remotos. + for uid in db_etags: + if uid not in seen_uids: + rm = duckdb_execute( + cfg.db_path, "DELETE FROM contacts WHERE uid = ?", [uid] + ) + if rm.get("status") == "ok": + deleted += rm.get("rowcount", 0) + + # Re-enlace de contactos + reconstrucción de derivadas, bajo el lock. + with write_conn(cfg.db_path) as conn: + conn.execute("BEGIN") + try: + _link_contacts(conn) + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + rebuild_derived(conn) + + return { + "status": "ok", + "pulled": pulled, + "updated": updated, + "deleted": deleted, + "unchanged": unchanged, + } diff --git a/tests/test_osint_db.py b/tests/test_osint_db.py index de0e144..5914f62 100644 --- a/tests/test_osint_db.py +++ b/tests/test_osint_db.py @@ -541,3 +541,162 @@ def test_api_person_render_preserva_prosa(client, cfg): after = open(note_file, encoding="utf-8").read() assert "Ficha de prueba." in after # prosa preservada assert "+34 622 333 444" in after # frontmatter actualizado + + +# --- F4: push de agenda SIN OSINT + sync inverso por etag ------------------ + + +def test_compose_agenda_vcard_sin_osint_con_direcciones(client, cfg, monkeypatch): + """upsert_contact compone un vCard de agenda con ADR de la persona y SIN X-OSINT. + + Crea una persona con DNI/sexo/dirección y un contacto enlazado por teléfono; + captura el vCard que se manda a Xandikos y verifica que (a) NO lleva ninguna + línea X-OSINT-*, (b) SÍ lleva la dirección de la persona como ADR. + """ + from server import writes + + client.post("/api/ingest/vault") + + # Persona con campos OSINT (dni/sexo/fecha_nac) + direccion + alias. + client.post( + "/api/person", + json={ + "slug": "marca-osint", + "nombre": "Marca Osint", + "dni": "99999999R", + "sexo": "mujer", + "fecha_nacimiento": "1985-01-01", + "pais": "españa", + "contexto": "investigacion", + "telefonos": ["+34 655 100 200"], + "direcciones": ["Calle Falsa 123, Madrid"], + "aliases": ["La Marca"], + }, + ) + # El contacto se enlaza a la ficha por teléfono al re-ingestar el vault. + with write_conn(cfg.db_path) as conn: + conn.execute( + "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "uid-marca", + "/enmanuel/contacts/addressbook/", + None, + "Marca Osint", + '["+34 655 100 200"]', + "[]", + "BEGIN:VCARD...", + None, + datetime.now(tz=timezone.utc), + ], + ) + client.post("/api/ingest/vault") # enlaza uid-marca -> personas/marca-osint.md + + # Capturamos el vCard que upsert_contact empuja a Xandikos. + pushed_vcards: list = [] + + def fake_put(base, user, pwd, collection, uid, vcard, **kw): + pushed_vcards.append(vcard) + return {"status": "ok", "http_status": 201, "url": "x"} + + monkeypatch.setattr(writes, "pass_get_secret", lambda *_: {"status": "ok", "value": "pw"}) + monkeypatch.setattr(writes, "carddav_put_vcard", fake_put) + monkeypatch.setattr( + writes, + "dav_list_resources", + lambda *a, **k: { + "status": "ok", + "http_status": 207, + "resources": [{"href": "/enmanuel/contacts/addressbook/uid-marca.vcf", "etag": '"newtag"'}], + }, + ) + + r = client.put( + "/api/contact/uid-marca", + json={"uid": "uid-marca", "fn": "Marca Osint", "tels": ["+34 655 100 200"]}, + ).json() + assert r["status"] == "ok" + assert r["pushed"] is True + assert r["etag"] == '"newtag"' # etag del recurso capturado tras el push + + assert pushed_vcards, "no se empujó ningún vCard" + vcard = pushed_vcards[-1] + # (a) Privacidad: ninguna línea X-OSINT-* (DNI/sexo/fecha-nac/pais/contexto). + assert "X-OSINT-" not in vcard + assert "99999999R" not in vcard # el DNI no se filtra al móvil + # (b) Direcciones y alias de la persona enlazada SÍ viajan (agenda). + assert "ADR;TYPE=HOME:;;Calle Falsa 123\\, Madrid;;;;" in vcard + assert "NICKNAME:La Marca" in vcard + + # El etag nuevo quedó persistido en la DB (sync inverso fiable). + q = client.post( + "/api/query", json={"sql": "SELECT etag, raw FROM contacts WHERE uid = 'uid-marca'"} + ).json() + assert q["rows"][0]["etag"] == '"newtag"' + assert "X-OSINT-" not in q["rows"][0]["raw"] # el raw guardado tampoco lleva OSINT + + +def test_pull_dav_incremental_por_etag(client, cfg, monkeypatch): + """pull_dav actualiza solo el etag cambiado, no toca el igual y borra el ausente.""" + from server import writes + + client.post("/api/ingest/vault") + + coll = "/enmanuel/contacts/addressbook/" + now = datetime.now(tz=timezone.utc) + # Tres contactos en la DB con etags conocidos. + with write_conn(cfg.db_path) as conn: + for uid, etag, fn in [ + ("c-same", '"e-same"', "Sin Cambios"), + ("c-changed", '"e-old"', "Antes"), + ("c-gone", '"e-gone"', "Se Borra"), + ]: + conn.execute( + "INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + [uid, coll, etag, fn, "[]", "[]", "BEGIN:VCARD...", None, now], + ) + + # Remoto: c-same igual, c-changed con etag nuevo, c-gone ausente. + def fake_list(base, user, pwd, collection, **kw): + return { + "status": "ok", + "http_status": 207, + "resources": [ + {"href": coll + "c-same.vcf", "etag": '"e-same"'}, + {"href": coll + "c-changed.vcf", "etag": '"e-new"'}, + ], + } + + def fake_get(base, user, pwd, href, **kw): + # Solo debería pedirse el recurso cambiado (c-same tiene etag igual). + assert "c-changed" in href, f"GET inesperado de {href}" + return { + "status": "ok", + "http_status": 200, + "text": ( + "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c-changed\r\n" + "FN:Despues\r\nTEL;TYPE=CELL:+34 600 000 000\r\nEND:VCARD\r\n" + ), + "url": href, + } + + monkeypatch.setattr(writes, "pass_get_secret", lambda *_: {"status": "ok", "value": "pw"}) + monkeypatch.setattr(writes, "dav_list_resources", fake_list) + monkeypatch.setattr(writes, "dav_get_resource", fake_get) + + r = client.post("/api/sync/dav-pull").json() + assert r["status"] == "ok" + assert r["pulled"] == 1 # solo c-changed se descargó + assert r["updated"] == 1 # y era una actualización (ya existía) + assert r["unchanged"] == 1 # c-same no se tocó + assert r["deleted"] == 1 # c-gone se borró (ausente en el PROPFIND) + + rows = client.post( + "/api/query", + json={"sql": "SELECT uid, etag, fn FROM contacts ORDER BY uid"}, + ).json()["rows"] + by_uid = {row["uid"]: row for row in rows} + assert set(by_uid) == {"c-changed", "c-same"} # c-gone desapareció + assert by_uid["c-same"]["fn"] == "Sin Cambios" # intacto + assert by_uid["c-same"]["etag"] == '"e-same"' + assert by_uid["c-changed"]["fn"] == "Despues" # FN del vCard nuevo + assert by_uid["c-changed"]["etag"] == '"e-new"' # etag remoto persistido