caudal/programa/cartera/enlace.py
2026-10-25 18:29:32 -05:00

568 lines
29 KiB
Python

"""
enlace.py — Sincronizar dos Caudal: el de un servidor y otro que se enlaza con
él (el del USB). Mezcla, no sustituye: lo que se haya apuntado en cada lado
acaba en los dos.
Quién es quién. El SERVIDOR es el Caudal que atiende en la red y pide
contraseña. El otro se ENLAZA una vez (hace falta la contraseña del servidor,
que no se guarda: se cambia por una credencial propia de ese equipo, que se
puede retirar) y a partir de ahí sincroniza cuando se le pide.
Cómo se sincroniza, en un solo viaje:
1. El enlazado envía TODO lo suyo: los registros (también los borrados, que
son marcas) y los ajustes.
2. El servidor lo comprueba entero antes de tocar nada, lo mezcla con lo
suyo y responde con el resultado, que es lo que queda en los dos.
3. El enlazado comprueba la respuesta y la pone en lugar de lo suyo.
4. Después se intercambian los justificantes que le falten a cada uno.
Para que mezclar no pueda romper nada:
· Nada se pierde: la mezcla es la unión de las dos copias; si un registro
está en las dos con contenido distinto gana el más reciente, y un borrado
es una marca, no una ausencia (datos.fusionar).
· Dos inversiones distintas con el mismo identificador no se pisan: a la
que llega se le da otro y sus operaciones la siguen (datos.choques_de_activos).
· La misma línea de extracto apuntada en los dos sitios no queda duplicada
(datos.unir_duplicadas).
· Antes de aplicar, cada lado guarda una copia local de lo que tenía.
· Si las dos copias son de versiones distintas del formato, no se mezclan.
· Se puede simular: dice qué cambiaría en cada lado y no cambia nada. Si las
dos copias no tienen nada en común, se avisa: serían dos carteras juntas.
· El enlazado no aplica una respuesta en la que falte algo de lo que envió.
Si el viaje se corta a medias no pasa nada: repetirlo lleva al mismo sitio.
"""
from __future__ import annotations
import base64
import datetime as dt
import http.client
import json
import os
import socket
import ssl
import tempfile
from pathlib import Path
from urllib.parse import urlsplit
from . import adjuntos, almacen
from .datos import (FICHEROS, VERSION, Datos, ErrorDatos, choques_de_activos, clave, escribir, fusionar, huella_datos,
renombrar_activos, unir_duplicadas)
from .sincronizar import _canonico, elegir_ajustes, escribir_ajuste, leer_ajustes
AJUSTES = ("bitcoin.json", "extractos.json") # los que viajan; el índice de la copia en git no es de aquí
FICHERO = "enlace.json"
FICHERO_CERTIFICADO = "enlace-certificado.crt" # el certificado raíz del servidor, si se eligió como fichero
FICHERO_DIRECCIONES = "enlace-direcciones.json" # en el servidor: las direcciones que lleva el QR de enlazar
VARIABLE_DIRECCIONES = "CAUDAL_DIRECCIONES" # las que da el sistema (StartOS), como lista JSON de URLs
TIEMPO = 120
TIEMPO_CONECTAR = 8 # si en este rato no hay camino, se prueba el siguiente
TIEMPO_TOR = 60 # por Tor, abrir el camino tarda
class ErrorEnlace(Exception):
"""Lo que se le dice al usuario tal cual."""
class SinCamino(ErrorEnlace):
"""No se llega al servidor por esa dirección (quizá sí por la otra)."""
def es_onion(url: str) -> bool:
return (urlsplit(url).hostname or "").endswith(".onion")
def normalizar_onion(url_tor: str) -> str:
"""La dirección .onion tal como se guarda («http://» delante si no lo lleva), o «» si viene vacía."""
url_tor = (url_tor or "").strip().rstrip("/")
if not url_tor:
return ""
if "://" not in url_tor:
url_tor = "http://" + url_tor
if not url_tor.startswith(("http://", "https://")) or not es_onion(url_tor):
raise ErrorEnlace("Tiene que ser una dirección .onion (la que da tu servidor para Caudal).")
return url_tor
# ── las direcciones que lleva el QR de enlazar (en el servidor) ──
#
# El QR lleva la dirección de casa y, si la hay, la .onion para fuera de casa. La de casa
# no puede ser un nombre .local: el teléfono no sabe resolverlos. En StartOS las da el
# propio sistema (el paquete las pasa en CAUDAL_DIRECCIONES); en otros servidores se
# escriben una vez en la página. Lo escrito a mano manda sobre lo del sistema.
def _orden_casa(url: str) -> tuple | None:
"""Cuánto conviene una dirección como «la de casa» (menos es mejor), o None si no vale."""
u = urlsplit(url)
host = (u.hostname or "").lower()
if u.scheme not in ("http", "https") or not host or host.endswith(".onion"):
return None
if host in ("localhost",) or host.startswith("127.") or ":" in host: # ni el propio equipo ni IPv6
return None
partes = host.split(".")
es_ip = len(partes) == 4 and all(p.isdigit() for p in partes)
if es_ip:
a, b = int(partes[0]), int(partes[1])
if a == 192 and b == 168:
grupo = 0 # la red de casa de casi todos los routers
elif a == 172 and 16 <= b <= 31:
grupo = 1
elif a == 10:
grupo = 2 # también la usan túneles y contenedores: después
else:
return None
elif host.endswith(".local"):
grupo = 4 # el teléfono no los resuelve: solo si no hay otra
else:
grupo = 3 # un nombre de dominio propio
return (grupo, 0 if u.scheme == "https" else 1, url)
def elegir_direcciones(urls) -> dict:
"""De las direcciones que da el sistema, la mejor para casa y la .onion (sin cifrar si la hay:
por Tor ya va cifrado y así no hace falta el certificado)."""
urls = [str(u).strip().rstrip("/") for u in (urls or []) if isinstance(u, str) and u.strip()]
casas = sorted(o for o in map(_orden_casa, urls) if o)
tors = sorted((0 if u.startswith("http://") else 1, u) for u in urls if es_onion(u))
return {"casa": casas[0][2] if casas else "", "tor": tors[0][1] if tors else ""}
def direcciones_del_sistema() -> dict:
try:
urls = json.loads(os.environ.get(VARIABLE_DIRECCIONES) or "[]")
except ValueError:
return {"casa": "", "tor": ""}
return elegir_direcciones(urls if isinstance(urls, list) else [])
def direcciones_de_enlace(carpeta_datos: Path) -> dict:
"""Las del QR: lo escrito a mano y, para lo que no se haya escrito, lo que da el sistema."""
sistema = direcciones_del_sistema()
try:
manual = json.loads(almacen.leer_texto(Path(carpeta_datos).resolve().parent / FICHERO_DIRECCIONES))
except (OSError, ValueError):
manual = {}
manual = manual if isinstance(manual, dict) else {}
return {"casa": manual.get("casa") or sistema["casa"], "tor": manual.get("tor") or sistema["tor"],
"sistema": sistema, "manual": {k: manual.get(k) or "" for k in ("casa", "tor")}}
def poner_direcciones_de_enlace(carpeta_datos: Path, casa: str, tor: str) -> dict:
"""Guarda las direcciones escritas a mano (vacías: se usan las del sistema)."""
casa = (casa or "").strip().rstrip("/")
if casa:
if "://" not in casa:
casa = "http://" + casa
if _orden_casa(casa) is None and not urlsplit(casa).hostname:
raise ErrorEnlace("La dirección de casa tiene que ser como https://192.168.1.50:8080 o la de tu dominio.")
if es_onion(casa):
raise ErrorEnlace("La dirección de casa no puede ser una .onion: esa va en su sitio.")
if (urlsplit(casa).hostname or "").endswith(".local"):
raise ErrorEnlace("Un nombre .local no sirve en el teléfono: pon la IP del servidor en tu red (por ejemplo, 192.168.1.50).")
tor = normalizar_onion(tor)
ruta = Path(carpeta_datos).resolve().parent / FICHERO_DIRECCIONES
if casa or tor:
almacen.escribir(ruta, json.dumps({"casa": casa, "tor": tor}, ensure_ascii=False, indent=1).encode())
else:
ruta.unlink(missing_ok=True)
return direcciones_de_enlace(carpeta_datos)
class _Respuesta:
def __init__(self, estado: int, cuerpo: bytes):
self.status_code, self._cuerpo = estado, cuerpo
def json(self):
return json.loads(self._cuerpo)
class _SesionTor:
"""Lo justo de «requests.Session» para llegar a una dirección .onion por el
Tor de este equipo, sin instalar nada más (el SOCKS5 es el de electrum.py)."""
def __init__(self, proxy: tuple[str, int], certificado: str = ""):
self.proxy, self.certificado = proxy, certificado
self.cookies: dict[str, str] = {}
def post(self, url: str, json=None, headers=None, timeout=TIEMPO):
from .electrum import ErrorElectrum, _socks5
import json as _json
u = urlsplit(url)
puerto = u.port or (443 if u.scheme == "https" else 80)
try:
sock = _socks5(u.hostname, puerto, self.proxy[0], self.proxy[1], TIEMPO_TOR)
except ErrorElectrum as e:
raise SinCamino(f"No llego al servidor por Tor: {e}.") from None
try:
if u.scheme == "https":
# Por .onion es Tor quien garantiza con quién se habla; si además se ha dado el
# certificado raíz del servidor, se comprueba también la firma (no el nombre)
contexto = ssl.create_default_context(cafile=self.certificado) if self.certificado \
else ssl._create_unverified_context()
contexto.check_hostname = False
try:
# El nombre va en el saludo (SNI) aunque no se compruebe: un servidor que reparte
# varios servicios por el mismo puerto, como StartOS, lo necesita para saber cuál es
sock = contexto.wrap_socket(sock, server_hostname=u.hostname)
except ssl.SSLError:
raise ErrorEnlace("El certificado que presenta el servidor por Tor no es de la autoridad que indicaste.") from None
sock.settimeout(timeout if isinstance(timeout, (int, float)) else TIEMPO)
conexion = http.client.HTTPConnection(u.hostname, puerto)
conexion.sock = sock
cabeceras = {"Host": u.netloc, "Content-Type": "application/json", "Connection": "close", **(headers or {})}
if self.cookies:
cabeceras["Cookie"] = "; ".join(f"{k}={v}" for k, v in self.cookies.items())
conexion.request("POST", u.path or "/", _json.dumps(json or {}).encode(), cabeceras)
r = conexion.getresponse()
for galleta in r.headers.get_all("Set-Cookie") or []:
nombre, _, valor = galleta.split(";")[0].partition("=")
self.cookies[nombre.strip()] = valor.strip()
return _Respuesta(r.status, r.read())
except (OSError, http.client.HTTPException) as e:
raise SinCamino(f"Se ha cortado la conexión por Tor ({e}).") from None
finally:
sock.close()
NO_ENTIENDO = "No entiendo esa petición."
def _ahora() -> str:
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
# ── lo que viaja ──
def empaquetar(datos: Datos, ajustes: dict) -> dict:
return {"version": VERSION, "datos": {n: getattr(datos, n) for n in FICHEROS},
"ajustes": {n: ajustes[n] for n in AJUSTES if isinstance(ajustes.get(n), dict)}}
def desempaquetar(paquete: dict) -> tuple[Datos, dict]:
"""(datos, ajustes) de lo recibido, comprobado entero: si algo no vale, no vale nada."""
if not isinstance(paquete, dict) or not isinstance(paquete.get("datos"), dict):
raise ErrorEnlace("Lo recibido no es una copia de Caudal.")
if paquete.get("version") != VERSION:
raise ErrorEnlace("Los dos Caudal no son de la misma versión: actualiza el más antiguo y vuelve a sincronizar.")
tablas = {}
for nombre in FICHEROS:
registros = paquete["datos"].get(nombre, [])
if not isinstance(registros, list) or not all(isinstance(r, dict) for r in registros):
raise ErrorEnlace("Lo recibido no es una copia de Caudal.")
claves = [clave(nombre, r) for r in registros if (r.get("id") or r.get("activo"))]
if len(claves) != len(registros) or len(set(claves)) != len(claves):
raise ErrorEnlace("Lo recibido trae registros repetidos o sin identificar: no se mezcla.")
tablas[nombre] = registros
datos = Datos(**tablas)
with tempfile.TemporaryDirectory() as tmp: # la misma comprobación que al guardar
try:
escribir(Path(tmp), datos)
except ErrorDatos as e:
raise ErrorEnlace(f"Lo recibido tiene un dato que no vale ({e}): no se mezcla.") from None
ajustes = paquete.get("ajustes") if isinstance(paquete.get("ajustes"), dict) else {}
return datos, {n: ajustes[n] for n in AJUSTES if isinstance(ajustes.get(n), dict)}
def _ajustes_renombrados(ajustes: dict, mapa: dict[str, str]) -> dict:
"""Los ajustes que nombran inversiones (monederos, cuentas recordadas), con los identificadores nuevos."""
if not mapa:
return ajustes
salida = json.loads(json.dumps(ajustes))
for m in (salida.get("bitcoin.json") or {}).get("monederos") or []:
if m.get("activo") in mapa:
m["activo"] = mapa[m["activo"]]
ex = salida.get("extractos.json") or {}
for campo in ("cuenta_intereses", "destinos_exchange"):
if isinstance(ex.get(campo), dict):
ex[campo] = {k: mapa.get(v, v) for k, v in ex[campo].items()}
return salida
def mezclar(mios: Datos, mis_ajustes: dict, suyos: Datos, sus_ajustes: dict, prefijos: dict) -> dict:
"""La mezcla de dos copias, sin tocar ninguna. Devuelve los datos y ajustes
que deben quedar en las dos y el recuento de lo que cambia en cada lado."""
mapa = choques_de_activos(mios, suyos, prefijos)
suyos = renombrar_activos(suyos, mapa)
sus_ajustes = _ajustes_renombrados(sus_ajustes, mapa)
en_comun = sum(len({clave(n, r) for r in getattr(mios, n)} & {clave(n, r) for r in getattr(suyos, n)})
for n in ("activos", "movimientos", "recurrentes"))
fusionado, recuento = fusionar(mios, suyos)
fusionado = json.loads(json.dumps({n: getattr(fusionado, n) for n in FICHEROS})) # copia: no tocar los originales
fusionado = Datos(**fusionado)
retiradas = unir_duplicadas(fusionado, _ahora())
locales = {n: mis_ajustes.get(n) for n in AJUSTES if mis_ajustes.get(n) is not None}
ajustes = {n: v for n, v in elegir_ajustes(locales, sus_ajustes).items() if n in AJUSTES}
vivos = lambda d: len(d.vivos("activos")) + len(d.vivos("movimientos")) # noqa: E731
return {
"datos": fusionado, "ajustes": ajustes,
"resumen": {
# «aquí» es quien mezcla (el servidor); «alli», quien ha enviado lo suyo
"llegan_aqui": recuento["de_remoto"], "cambian_aqui": recuento["actualizados_desde_remoto"],
"llegan_alli": recuento["de_local"], "cambian_alli": recuento["actualizados_desde_local"],
"ajustes_aqui": [n for n in AJUSTES if _canonico(ajustes.get(n)) != _canonico(mis_ajustes.get(n))],
"ajustes_alli": [n for n in AJUSTES if _canonico(ajustes.get(n)) != _canonico(sus_ajustes.get(n))],
"renombradas": [{"de": k, "a": v, "nombre": next((a.get("nombre", "") for a in suyos.activos if a["id"] == v), "")}
for k, v in mapa.items()],
"duplicadas": len(retiradas), "en_comun": en_comun,
"carteras_distintas": en_comun == 0 and vivos(mios) > 0 and vivos(suyos) > 0,
},
}
def justificantes(carpeta: Path, datos: Datos) -> tuple[list[str], list[str]]:
"""(los que hay en este equipo, los que nombran los datos y faltan aquí)."""
nombrados = sorted(adjuntos.referenciados(datos.movimientos))
tengo = [h for h in nombrados if adjuntos.tiene(carpeta, h)]
return tengo, [h for h in nombrados if h not in tengo]
# ── el lado que se enlaza (el del USB) ──
class Enlace:
"""La dirección del servidor y la credencial de este equipo, junto a los datos."""
def __init__(self, carpeta_datos: Path):
self.ruta = Path(carpeta_datos).resolve().parent / FICHERO
def config(self) -> dict:
try:
return json.loads(almacen.leer_texto(self.ruta))
except (OSError, ValueError):
return {}
def guardar(self, config: dict) -> None:
almacen.escribir(self.ruta, json.dumps(config, ensure_ascii=False, indent=1).encode())
def olvidar(self) -> None:
self.ruta.unlink(missing_ok=True)
(self.ruta.parent / FICHERO_CERTIFICADO).unlink(missing_ok=True)
def guardar_certificado(self, pem: str) -> str:
"""Guarda junto a los datos el certificado raíz que se ha elegido como fichero (en el
teléfono no hay una ruta que escribir) y devuelve dónde ha quedado."""
pem = (pem or "").strip()
if len(pem) > 50_000 or "-----BEGIN CERTIFICATE-----" not in pem:
raise ErrorEnlace("Ese fichero no es un certificado (se espera un .crt o .pem, el que descargaste del servidor).")
try:
ssl.create_default_context(cadata=pem)
except (ssl.SSLError, ValueError):
raise ErrorEnlace("No puedo leer ese certificado: ¿es el certificado raíz que descargaste del servidor?") from None
destino = self.ruta.parent / FICHERO_CERTIFICADO
tmp = destino.with_name(destino.name + ".tmp")
tmp.write_text(pem + "\n", encoding="ascii", errors="replace")
os.chmod(tmp, 0o600)
os.replace(tmp, destino)
return str(destino)
def _proxy(self) -> tuple[str, int]:
"""El Tor de este equipo: el mismo que se usa para llegar al nodo de bitcoin."""
from . import btc
return btc.proxy_de(btc.cargar(self.ruta.parent / "datos"))
def _sesion(self, url: str):
c = self.config()
if es_onion(url):
return _SesionTor(self._proxy(), c.get("certificado") or "")
import requests
s = requests.Session()
s.verify = c.get("certificado") or True
return s
def caminos(self) -> list[str]:
"""Las direcciones del servidor, en el orden en que se prueban: la de
casa y, si la hay, la .onion para cuando no se está en casa."""
c = self.config()
return [u.rstrip("/") for u in (c.get("url"), c.get("url_tor")) if u]
def _pedir(self, sesion, url: str, cuerpo: dict, cabeceras: dict):
import requests
try:
r = sesion.post(url, json=cuerpo, headers=cabeceras, timeout=(TIEMPO_CONECTAR, TIEMPO))
except requests.exceptions.SSLError:
raise ErrorEnlace("No puedo comprobar el certificado del servidor. Si tu servidor usa su propia autoridad "
"(como StartOS), descarga su certificado raíz e indica aquí dónde está.") from None
except requests.exceptions.RequestException:
raise SinCamino("No llego al servidor: comprueba la dirección y que haya red hasta él.") from None
try:
datos = r.json()
except ValueError:
raise ErrorEnlace("Esa dirección contesta, pero no parece un Caudal.") from None
if r.status_code != 200:
raise ErrorEnlace(datos.get("error") or f"El servidor ha contestado con un error ({r.status_code}).")
return datos
def enlazar(self, url: str, clave_servidor: str, certificado: str = "", nombre: str = "", codigo: str = "",
certificado_pem: str = "", url_tor: str = "") -> dict:
"""Entra una vez con la contraseña del servidor y la cambia por una credencial de este equipo.
Con un código de enlace (el que enseña el servidor) no hace falta la contraseña. El certificado
raíz llega como ruta a un fichero o, elegido en la página, con su contenido. Si llega también la
.onion (la trae el QR), queda como camino de reserva y, si la dirección de casa no responde
(enlazando fuera de casa), se enlaza por ella."""
url = (url or "").strip().rstrip("/")
if not url.startswith(("http://", "https://")):
raise ErrorEnlace("La dirección tiene que empezar por https:// (o http:// si es dentro de tu casa o una .onion).")
url_tor = normalizar_onion(url_tor) if url_tor and url_tor.strip().rstrip("/") != url else ""
if certificado_pem:
certificado = self.guardar_certificado(certificado_pem)
if certificado and not Path(certificado).is_file():
raise ErrorEnlace("No encuentro ese fichero de certificado.")
self.guardar({"url": url, "certificado": certificado or ""})
caminos = [url] + ([url_tor] if url_tor else [])
credencial, fallo = None, None
try:
for base in caminos:
try:
credencial = self._credencial_nueva(base, clave_servidor, nombre, codigo)
break
except SinCamino as e: # por aquí no se llega (el código sigue valiendo): el siguiente
fallo = e
if credencial is None:
if len(caminos) > 1:
raise SinCamino(f"No llego al servidor ni por la dirección de casa ni por la .onion. {fallo}")
raise fallo
except ErrorEnlace:
self.olvidar()
raise
self.guardar({"url": url, **({"url_tor": url_tor} if url_tor else {}), "certificado": certificado or "",
"credencial": credencial, "enlazado": _ahora()})
return {"url": url, "url_tor": url_tor}
def _credencial_nueva(self, base: str, clave_servidor: str, nombre: str, codigo: str) -> str:
sesion = self._sesion(base)
if codigo.strip():
return self._pedir(sesion, f"{base}/api/enlace/canje", {"codigo": codigo, "nombre": nombre or "Otro Caudal"},
{"X-Caudal": "1"})["credencial"]
entrada = self._pedir(sesion, f"{base}/api/acceso/entrar", {"clave": clave_servidor}, {"X-Caudal": "1"})
alta = self._pedir(sesion, f"{base}/api/enlace/alta", {"nombre": nombre or "Otro Caudal"}, {"X-Token": entrada["token"]})
self._pedir(sesion, f"{base}/api/acceso/salir", {}, {"X-Caudal": "1"})
return alta["resultado"]["credencial"]
def llamar(self, orden: str, cuerpo: dict) -> dict:
c = self.config()
if not c.get("credencial"):
raise ErrorEnlace("Este Caudal no está enlazado con ningún servidor.")
return self._por_algun_camino(self.caminos(), orden, cuerpo, c["credencial"])
def _por_algun_camino(self, caminos: list[str], orden: str, cuerpo: dict, credencial: str) -> dict:
fallo = None
for base in caminos:
try:
return self._pedir(self._sesion(base), f"{base}/api/enlace", {"orden": orden, **cuerpo},
{"X-Enlace": credencial})
except SinCamino as e:
fallo = e
if len(caminos) > 1:
raise SinCamino(f"No llego al servidor ni por la dirección de casa ni por la .onion. {fallo}")
raise fallo or ErrorEnlace("Este Caudal no está enlazado con ningún servidor.")
def poner_onion(self, url_tor: str) -> dict:
"""Añade (o quita, con «») la dirección .onion del servidor, para sincronizar
fuera de casa. Antes de guardarla comprueba que por ella se llega al mismo
servidor y que acepta la credencial de este equipo."""
c = self.config()
if not c.get("credencial"):
raise ErrorEnlace("Este Caudal no está enlazado con ningún servidor.")
url_tor = normalizar_onion(url_tor)
if not url_tor:
self.guardar({k: v for k, v in c.items() if k != "url_tor"})
return {"url_tor": ""}
try:
self._por_algun_camino([url_tor], "hola", {}, c["credencial"])
except SinCamino:
raise
except ErrorEnlace as e:
# Un Caudal anterior a esta orden contesta que no la entiende: vale igual,
# porque para contestar eso ya ha dado por buena la credencial
if str(e) != NO_ENTIENDO:
raise
self.guardar({**c, "url_tor": url_tor})
return {"url_tor": url_tor}
def sincronizar(app, simular: bool) -> dict:
"""Desde el Caudal enlazado: envía lo suyo, recibe la mezcla y la aplica (salvo que sea un simulacro)."""
enlace = Enlace(app.carpeta)
carpeta = app.cartera.carpeta
with app.cartera._lock:
mios = app.cartera.datos
mis_ajustes = leer_ajustes(carpeta.parent)
tengo, _ = justificantes(carpeta, mios)
respuesta = enlace.llamar("simular" if simular else "mezclar", {**empaquetar(mios, mis_ajustes), "justificantes": tengo})
resumen = respuesta["resumen"]
if simular:
return {"simulacro": True, "resumen": resumen}
fusionado, ajustes = desempaquetar(respuesta)
# Lo que vuelve tiene que contener todo lo que se envió (salvo lo que el servidor renombró)
renombradas = {x["de"] for x in resumen["renombradas"]}
for nombre in FICHEROS:
vuelven = {clave(nombre, r) for r in getattr(fusionado, nombre)}
faltan = [k for k in (clave(nombre, r) for r in getattr(mios, nombre))
if k not in vuelven and k.split("|")[0] not in renombradas]
if nombre in ("movimientos", "recurrentes") and faltan:
raise ErrorEnlace("El servidor ha devuelto menos de lo que le envié: no aplico nada. Vuelve a intentarlo.")
cambia = huella_datos(fusionado) != huella_datos(mios)
cambian_ajustes = [n for n in AJUSTES if _canonico(ajustes.get(n)) != _canonico(mis_ajustes.get(n))]
if cambia or cambian_ajustes:
app._copia_datos() # lo que había, a la carpeta de copias locales
if cambia:
app.cartera.reemplazar(fusionado)
for nombre in cambian_ajustes:
escribir_ajuste(carpeta.parent / nombre, ajustes[nombre])
# Los justificantes, después y de uno en uno: si alguno falla, el siguiente sincronizar lo reintenta
subidos = bajados = 0
for h in respuesta.get("le_faltan") or []:
if adjuntos.tiene(carpeta, h):
enlace.llamar("subir", {"contenido": base64.b64encode(adjuntos.leer(carpeta, h)).decode()})
subidos += 1
for h in justificantes(carpeta, app.cartera.datos)[1]:
if h in (respuesta.get("tiene") or []):
contenido = base64.b64decode(enlace.llamar("bajar", {"huella": h})["contenido"])
if adjuntos.guardar(carpeta, contenido) == h:
bajados += 1
config = enlace.config()
enlace.guardar({**config, "ultima": _ahora()})
return {"simulacro": False, "resumen": resumen, "justificantes": {"subidos": subidos, "bajados": bajados}}
# ── el lado del servidor ──
def atender(app, orden: str, cuerpo: dict, prefijos: dict) -> dict:
"""Lo que pide un Caudal enlazado. Quien llama ya ha comprobado su credencial."""
carpeta = app.cartera.carpeta
if orden == "subir":
try:
return {"huella": adjuntos.guardar(carpeta, base64.b64decode(str(cuerpo.get("contenido") or ""), validate=True))}
except (ValueError, adjuntos.ErrorAdjunto) as e:
raise ErrorEnlace(f"Ese justificante no se puede guardar: {e}") from None
if orden == "bajar":
try:
return {"contenido": base64.b64encode(adjuntos.leer(carpeta, str(cuerpo.get("huella") or ""))).decode()}
except adjuntos.ErrorAdjunto as e:
raise ErrorEnlace(str(e)) from None
if orden == "hola": # para probar un camino sin mover nada
return {"hola": True}
if orden not in ("simular", "mezclar"):
raise ErrorEnlace(NO_ENTIENDO)
suyos, sus_ajustes = desempaquetar(cuerpo)
with app.cartera._lock:
mios = app.cartera.datos
mis_ajustes = leer_ajustes(carpeta.parent)
m = mezclar(mios, mis_ajustes, suyos, sus_ajustes, prefijos)
if orden == "simular":
return {"resumen": m["resumen"]}
r = m["resumen"]
# Se compara el resultado entero con lo que había, no los recuentos: una inversión puede «ganar»
# y aun así salir distinta de la mezcla (con los lugares que se añadieron en el otro lado)
cambia = huella_datos(m["datos"]) != huella_datos(mios)
if cambia or r["ajustes_aqui"]:
app._copia_datos()
if cambia:
app.cartera.reemplazar(m["datos"])
for nombre in r["ajustes_aqui"]:
escribir_ajuste(carpeta.parent / nombre, m["ajustes"][nombre])
tengo, me_faltan = justificantes(carpeta, m["datos"])
return {**empaquetar(m["datos"], m["ajustes"]), "resumen": r, "tiene": tengo,
"le_faltan": [h for h in me_faltan if h in (cuerpo.get("justificantes") or [])]}