feat: POST /api/push/dav-bulk — push masivo por disco + 1 commit (segundos vs minutos)
Vía rápida DB→Xandikos para operaciones masivas: genera todos los vCards de agenda desde DuckDB a un tmpdir, rsync de golpe al working tree de la colección en magnus (excluyendo .git/.xandikos), UN solo git commit, y 1 PROPFIND para capturar todos los etags en batch. ~0.5s vs ~6min del push HTTP (que hace N PUTs + N PROPFINDs + N commits). El push HTTP push_all_dav se mantiene como fallback (y para CalDAV). Config DAV_BULK_SSH_HOST/REMOTE_DIR. 22 tests verdes.
This commit is contained in:
@@ -19,6 +19,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .config import Config
|
||||
@@ -60,6 +65,19 @@ def _now():
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
|
||||
# Clave (slug de persona, uid de contacto/evento) admisible: empieza por
|
||||
# alfanumérico y solo contiene alfanuméricos y `._-`. Rechaza explícitamente
|
||||
# separadores de ruta (`/`, `\`), saltos de línea y secuencias `..`, que de otro
|
||||
# modo entrarían como filas con clave indeleble por la API REST (los `/` rompen
|
||||
# el routing de DELETE /api/<x>/{key}) y se acercan a paths fuera del vault.
|
||||
_VALID_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
|
||||
def _valid_key(value: str) -> bool:
|
||||
"""True si ``value`` es una clave admisible (sin `/`, `\\`, `..`, controles)."""
|
||||
return bool(_VALID_KEY_RE.match(value or "")) and ".." not in value
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
"""Normaliza a lista de strings no vacíos (string suelto -> [string])."""
|
||||
if value is None:
|
||||
@@ -134,6 +152,8 @@ def upsert_person(cfg: Config, slug: str, fields: dict, *, render: bool = True)
|
||||
slug = (slug or "").strip()
|
||||
if not slug:
|
||||
return {"status": "error", "error": "slug vacío"}
|
||||
if not _valid_key(slug):
|
||||
return {"status": "error", "error": f"slug inválido: {slug!r}"}
|
||||
|
||||
telefonos = _as_list(fields.get("telefonos"))
|
||||
emails = _as_list(fields.get("emails"))
|
||||
@@ -402,6 +422,8 @@ def upsert_contact(cfg: Config, uid: str, fields: dict) -> dict:
|
||||
uid = (uid or "").strip()
|
||||
if not uid:
|
||||
return {"status": "error", "error": "uid vacío"}
|
||||
if not _valid_key(uid):
|
||||
return {"status": "error", "error": f"uid inválido: {uid!r}"}
|
||||
|
||||
tels = _as_list(fields.get("tels") or fields.get("telefonos"))
|
||||
emails = _as_list(fields.get("emails") or fields.get("correos"))
|
||||
@@ -570,6 +592,8 @@ def upsert_event(cfg: Config, uid: str, fields: dict) -> dict:
|
||||
uid = (uid or "").strip()
|
||||
if not uid:
|
||||
return {"status": "error", "error": "uid vacío"}
|
||||
if not _valid_key(uid):
|
||||
return {"status": "error", "error": f"uid inválido: {uid!r}"}
|
||||
|
||||
calendar = fields.get("calendar") or "default"
|
||||
raw = _build_vcalendar(uid, fields)
|
||||
@@ -669,6 +693,8 @@ def make_addressbook(cfg: Config, fields: dict) -> dict:
|
||||
slug = (fields.get("slug") or "").strip()
|
||||
if not slug:
|
||||
return {"status": "error", "error": "slug vacío"}
|
||||
if not _valid_key(slug):
|
||||
return {"status": "error", "error": f"slug inválido: {slug!r}"}
|
||||
display_name = fields.get("display_name") or ""
|
||||
description = fields.get("description") or ""
|
||||
color = fields.get("color") or ""
|
||||
@@ -728,6 +754,8 @@ def make_calendar(cfg: Config, fields: dict) -> dict:
|
||||
slug = (fields.get("slug") or "").strip()
|
||||
if not slug:
|
||||
return {"status": "error", "error": "slug vacío"}
|
||||
if not _valid_key(slug):
|
||||
return {"status": "error", "error": f"slug inválido: {slug!r}"}
|
||||
display_name = fields.get("display_name") or ""
|
||||
color = fields.get("color") or ""
|
||||
|
||||
@@ -835,6 +863,297 @@ def push_all_dav(cfg: Config) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# push masivo POR DISCO (vía rápida: 1 rsync + 1 commit + 1 PROPFIND)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_agenda_vcards_to_dir(cfg: Config, rows: list, out_dir: str) -> dict:
|
||||
"""Genera el .vcf de agenda (SIN OSINT) de cada contacto en ``out_dir``.
|
||||
|
||||
Para cada fila de ``contacts`` (uid, fn, tels, emails, note_path) compone el
|
||||
vCard con ``_compose_agenda_vcard`` y lo escribe a
|
||||
``out_dir/<_safe_resource(uid)>.vcf`` — EXACTAMENTE el mismo nombre de
|
||||
recurso que usa el push HTTP (``carddav_put_vcard``), para que rsync no cree
|
||||
duplicados ni huérfanos respecto a la colección remota.
|
||||
|
||||
Es la parte testeable sin red/SSH del push masivo por disco: genera los
|
||||
ficheros locales y devuelve el mapa nombre_recurso -> uid (necesario luego
|
||||
para casar los etags del PROPFIND con sus uids).
|
||||
|
||||
Args:
|
||||
cfg: configuración del service (db_path para resolver la persona enlazada).
|
||||
rows: filas de contacts como dicts con uid, fn, tels, emails, note_path.
|
||||
out_dir: directorio temporal local donde escribir los .vcf.
|
||||
|
||||
Returns:
|
||||
{"written": N, "by_resource": {"<safe>.vcf": uid, ...}}.
|
||||
"""
|
||||
by_resource: dict = {}
|
||||
written = 0
|
||||
for row in rows:
|
||||
uid = row["uid"]
|
||||
vcard = _compose_agenda_vcard(
|
||||
cfg,
|
||||
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"),
|
||||
},
|
||||
)
|
||||
resource = _resource_href_tail(uid) # _safe_resource(uid) + ".vcf"
|
||||
# En el caso (raro) de que dos uids saneen al mismo recurso, gana el
|
||||
# último, igual que haría el push HTTP secuencial.
|
||||
by_resource[resource] = uid
|
||||
with open(os.path.join(out_dir, resource), "w", encoding="utf-8") as fh:
|
||||
fh.write(vcard)
|
||||
written += 1
|
||||
return {"written": written, "by_resource": by_resource}
|
||||
|
||||
|
||||
def _rsync_vcards(local_dir: str, ssh_host: str, remote_dir: str) -> dict:
|
||||
"""rsync de los .vcf del directorio local al working tree remoto de Xandikos.
|
||||
|
||||
Sincroniza SOLO los ``*.vcf`` (``--include='*.vcf' --exclude='*'``), de modo
|
||||
que ningún otro fichero del working tree remoto se toca: ni ``.git/`` (el
|
||||
historial), ni ``.xandikos`` (metadata del tipo de colección), ni
|
||||
``push-subscriptions.json`` (suscripciones WebDAV-Push), ni un ``.gitignore``.
|
||||
``--delete`` borra del remoto los .vcf que ya no están en local — como local
|
||||
contiene TODOS los contactos de la DB, esto deja la colección de .vcf
|
||||
EXACTAMENTE igual a la DB (limpia recursos .vcf huérfanos) sin afectar a los
|
||||
ficheros no-.vcf, que quedan protegidos por estar excluidos.
|
||||
|
||||
Returns:
|
||||
{"status":"ok", "stdout":..., "stderr":...} o {"status":"error", "error":...}.
|
||||
"""
|
||||
src = local_dir.rstrip("/") + "/"
|
||||
dst = f"{ssh_host}:{remote_dir}"
|
||||
cmd = [
|
||||
"rsync",
|
||||
"-az",
|
||||
"--delete",
|
||||
"--include=*.vcf",
|
||||
"--exclude=*",
|
||||
src,
|
||||
dst,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=300, check=False
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return {"status": "error", "error": f"rsync falló: {e}"}
|
||||
if proc.returncode != 0:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"rsync rc={proc.returncode}: {proc.stderr.strip()}",
|
||||
}
|
||||
return {"status": "ok", "stdout": proc.stdout, "stderr": proc.stderr}
|
||||
|
||||
|
||||
def _git_commit_remote(ssh_host: str, remote_dir: str) -> dict:
|
||||
"""UN solo commit en el working tree remoto (lo que Xandikos sirve).
|
||||
|
||||
Hace ``git add -A`` (recoge altas, bajas y modificaciones de .vcf que dejó el
|
||||
rsync) y un único commit con identidad fija ``osint_db``. El ``|| true``
|
||||
evita fallar cuando no hay cambios (commit vacío). Captura el HEAD antes y
|
||||
después para confirmar que SOLO se añadió un commit (o ninguno).
|
||||
|
||||
Returns:
|
||||
{"status":"ok", "head_before":..., "head_after":..., "committed":bool} o
|
||||
{"status":"error", "error":...}.
|
||||
"""
|
||||
# rev-parse HEAD antes.
|
||||
head_before = _ssh_capture(ssh_host, f"cd {remote_dir} && git rev-parse HEAD")
|
||||
if head_before.get("status") != "ok":
|
||||
return {"status": "error", "error": f"rev-parse(before): {head_before.get('error')}"}
|
||||
|
||||
script = (
|
||||
f"cd {remote_dir} && git add -A && "
|
||||
"git -c user.email=osint_db -c user.name=osint_db "
|
||||
"commit -m 'bulk push from DuckDB' || true"
|
||||
)
|
||||
commit = _ssh_capture(ssh_host, script)
|
||||
if commit.get("status") != "ok":
|
||||
return {"status": "error", "error": f"git commit: {commit.get('error')}"}
|
||||
|
||||
head_after = _ssh_capture(ssh_host, f"cd {remote_dir} && git rev-parse HEAD")
|
||||
if head_after.get("status") != "ok":
|
||||
return {"status": "error", "error": f"rev-parse(after): {head_after.get('error')}"}
|
||||
|
||||
before = (head_before.get("stdout") or "").strip()
|
||||
after = (head_after.get("stdout") or "").strip()
|
||||
return {
|
||||
"status": "ok",
|
||||
"head_before": before,
|
||||
"head_after": after,
|
||||
"committed": before != after,
|
||||
}
|
||||
|
||||
|
||||
def _ssh_capture(ssh_host: str, remote_cmd: str) -> dict:
|
||||
"""Ejecuta un comando en el host remoto vía ssh y captura stdout/stderr.
|
||||
|
||||
Usa ``BatchMode=yes`` para fallar rápido si no hay auth por clave (nunca pide
|
||||
contraseña interactiva, que colgaría el service). NO interpola secretos en el
|
||||
comando — solo paths y verbos git fijos.
|
||||
"""
|
||||
cmd = [
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
ssh_host,
|
||||
remote_cmd,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=60, check=False
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return {"status": "error", "error": f"ssh falló: {e}"}
|
||||
if proc.returncode != 0:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"ssh rc={proc.returncode}: {proc.stderr.strip()}",
|
||||
}
|
||||
return {"status": "ok", "stdout": proc.stdout, "stderr": proc.stderr}
|
||||
|
||||
|
||||
def _capture_etags_after_bulk(
|
||||
cfg: Config, pwd: str, collection: str, by_resource: dict
|
||||
) -> dict:
|
||||
"""Lee en UN PROPFIND los etags de la colección y los persiste por uid.
|
||||
|
||||
Tras el commit remoto, lista la colección entera (PROPFIND Depth:1 -> [{href,
|
||||
etag}]) y casa cada recurso con su uid usando ``by_resource`` (nombre .vcf ->
|
||||
uid construido al generar los ficheros). Hace un UPDATE de ``contacts.etag``
|
||||
por uid encontrado, dejando los etags guardados == los del servidor, para que
|
||||
el próximo ``/api/sync/dav-pull`` no lo confunda con una edición del móvil.
|
||||
|
||||
Returns:
|
||||
{"status":"ok", "updated":N} o {"status":"error", "error":...}.
|
||||
"""
|
||||
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')})",
|
||||
}
|
||||
updated = 0
|
||||
for res in listing.get("resources", []):
|
||||
href = res.get("href") or ""
|
||||
etag = res.get("etag")
|
||||
tail = href.rstrip("/").rsplit("/", 1)[-1]
|
||||
uid = by_resource.get(tail)
|
||||
if uid is None or not etag:
|
||||
continue
|
||||
up = duckdb_execute(
|
||||
cfg.db_path, "UPDATE contacts SET etag = ? WHERE uid = ?", [etag, uid]
|
||||
)
|
||||
if up.get("status") == "ok":
|
||||
updated += up.get("rowcount", 0) or 0
|
||||
return {"status": "ok", "updated": updated}
|
||||
|
||||
|
||||
def push_all_dav_bulk(cfg: Config) -> dict:
|
||||
"""Push masivo DB -> Xandikos por DISCO: 1 rsync + 1 commit + 1 PROPFIND.
|
||||
|
||||
Vía RÁPIDA equivalente a ``push_all_dav`` para los CONTACTOS, pensada para
|
||||
reconciliar las ~1000 fichas de la DB en segundos en vez de minutos. Evita
|
||||
los tres cuellos del push HTTP secuencial:
|
||||
|
||||
- 1 PUT HTTP por contacto -> 1 rsync de todos los .vcf de golpe.
|
||||
- 1 commit git por PUT -> 1 solo commit en el working tree remoto.
|
||||
- 1 PROPFIND por contacto -> 1 PROPFIND de toda la colección al final.
|
||||
|
||||
Flujo:
|
||||
1. Lee TODOS los contacts de la DB (uid, collection, fn, tels, emails,
|
||||
note_path) de la colección CardDAV por defecto.
|
||||
2. Genera el vCard de AGENDA (SIN OSINT) de cada uno en un tmpdir local,
|
||||
con el nombre de recurso EXACTO del push HTTP (``_safe_resource``).
|
||||
3. rsync ``--delete`` ese tmpdir al working tree remoto, sincronizando SOLO
|
||||
los .vcf (protege .git/.xandikos/push-subscriptions.json).
|
||||
4. UN solo commit en el remoto -> Xandikos recalcula el ctag (DAVx5 detecta).
|
||||
5. UN PROPFIND de la colección -> {uid: etag} -> UPDATE de ``contacts.etag``.
|
||||
|
||||
Solo cubre la colección CardDAV por defecto (``cfg.dav_contacts_collection``),
|
||||
donde viven todos los contactos del ecosistema OSINT hoy. Los eventos CalDAV
|
||||
siguen yendo por el push HTTP (``push_all_dav``).
|
||||
|
||||
Requisitos: SSH por clave al host de Xandikos (``cfg.dav_bulk_ssh_host``) con
|
||||
acceso de escritura al working tree (``cfg.dav_bulk_remote_dir``), y ``rsync``
|
||||
instalado en ambos lados. Si no hay SSH, usar ``push_all_dav`` (HTTP) como
|
||||
fallback.
|
||||
|
||||
Returns:
|
||||
{"status":"ok", "written":N, "rsynced":bool, "committed":bool,
|
||||
"etags_updated":N, "head_before":..., "head_after":..., "elapsed_s":F} o
|
||||
{"status":"error", "error":...}.
|
||||
"""
|
||||
started = time.monotonic()
|
||||
collection = _default_collection(cfg)
|
||||
|
||||
contacts = duckdb_query_readonly(
|
||||
cfg.db_path,
|
||||
"SELECT uid, collection, fn, tels, emails, note_path FROM contacts "
|
||||
"WHERE collection = ?",
|
||||
[collection],
|
||||
1000000,
|
||||
)
|
||||
if contacts.get("status") != "ok":
|
||||
return {"status": "error", "error": f"lectura contacts: {contacts.get('error')}"}
|
||||
rows = contacts.get("rows", [])
|
||||
|
||||
# 1+2. Genera los .vcf de agenda en un tmpdir local (parte sin red).
|
||||
tmp_dir = tempfile.mkdtemp(prefix="osint_db_bulk_")
|
||||
try:
|
||||
gen = _write_agenda_vcards_to_dir(cfg, rows, tmp_dir)
|
||||
by_resource = gen["by_resource"]
|
||||
written = gen["written"]
|
||||
|
||||
# 3. rsync de todos los .vcf al working tree remoto (solo *.vcf).
|
||||
rs = _rsync_vcards(tmp_dir, cfg.dav_bulk_ssh_host, cfg.dav_bulk_remote_dir)
|
||||
if rs.get("status") != "ok":
|
||||
return {"status": "error", "error": rs.get("error"), "written": written}
|
||||
|
||||
# 4. UN solo commit en el remoto (Xandikos recalcula el ctag).
|
||||
commit = _git_commit_remote(cfg.dav_bulk_ssh_host, cfg.dav_bulk_remote_dir)
|
||||
if commit.get("status") != "ok":
|
||||
return {"status": "error", "error": commit.get("error"), "written": written}
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
# 5. UN PROPFIND -> {uid: etag} -> UPDATE contacts.etag (sync inverso fiable).
|
||||
pwd, err = _resolve_password(cfg)
|
||||
etags_updated = 0
|
||||
etag_error = None
|
||||
if err is None:
|
||||
cap = _capture_etags_after_bulk(cfg, pwd, collection, by_resource)
|
||||
if cap.get("status") == "ok":
|
||||
etags_updated = cap.get("updated", 0)
|
||||
else:
|
||||
etag_error = cap.get("error")
|
||||
else:
|
||||
etag_error = err
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"written": written,
|
||||
"rsynced": True,
|
||||
"committed": commit.get("committed", False),
|
||||
"etags_updated": etags_updated,
|
||||
"etag_error": etag_error,
|
||||
"head_before": commit.get("head_before"),
|
||||
"head_after": commit.get("head_after"),
|
||||
"elapsed_s": round(time.monotonic() - started, 3),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync inverso Xandikos -> DB (pull incremental por etag)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user