""" 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 [])]}